Ctrl K
ComunidadGuías…

CHANGELOG.1.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog (0.8.8.7 and older)

Releases 0.8.8.8 and later are in [CHANGELOG.md](CHANGELOG.md).

## [0.8.8.7] - 2026-09-19

### Added
- **Skills: `npx skills add … -a ziya` installer contract pinned, and the upstream registration PR prepared** (`tests/test_skill_installer_contract.py` (new, 4), `design/upstream/vercel-skills-ziya-agent.md` (new), `Docs/Skills.md`). The `vercel-labs/skills` CLI (`npx skills`) has no discovery protocol — each supported agent is a hardcoded `src/agents.ts` entry keyed on a detection probe, a project skills dir and a global skills dir. Ziya already satisfies all three (`~/.ziya` / `$ZIYA_HOME` created on first run; `.agents/skills` in `DISCOVERY_PATHS`; `~/.ziya/skills` via `discover_user_skills`), so registration is a small upstream PR; `design/upstream/vercel-skills-ziya-agent.md` holds the exact `agents.ts` / `types.ts` snippets, the README link, the PR text and the post-merge verification commands, following their `AGENTS.md` "Adding a New Agent" steps. `skillsDir` is the universal `.agents/skills` (not `.ziya/skills`) so a no-flag install and a skill already installed for Cursor/Codex in the same project are both visible to Ziya without duplication. The CLI's default install method is symlink; the new tests assert discovery follows a symlinked skill directory in both `.agents/skills` and `$ZIYA_HOME/skills`, still rejects a link whose name differs from the SKILL.md `name`, and that resolving `$ZIYA_HOME` creates the directory the installer probes. `Docs/Skills.md` now lists every discovery root with its precedence and documents the install commands, leading with `-g`: project-scope installs write into the current directory's `.agents/skills/` and are the right shape only for repo-specific skills; general-purpose skills belong in one global copy rather than a drifting copy per project.
- **Skills: `~/.agents/skills` is scanned as a user-global root** (`app/services/skill_discovery.py` (`USER_HARNESS_PATHS`), `tests/test_skill_installer_contract.py` (+2)). `npx skills add -g` for every "universal" agent (Cursor, Codex, Copilot, Cline, Zed, …) places the canonical copy in `~/.agents/skills/<name>` and creates no per-agent link (`installer.ts` short-circuits to the canonical dir), so a skill installed globally for any of those was invisible to Ziya even though the project-level `.agents/skills` was already read. Precedence is unchanged: `~/.ziya/skills` still wins over it by name, project roots still beat user roots. `~/.config/agents/skills` (Amp/OpenCode's listed global dir) is deliberately not added — the installer never writes there for those agents.
- **Document export: typeset aesthetics — numbered figure/table captions, section numbering, a real title block, a document footer, and typeset-grade pagination** (`app/utils/document_print_decor.py` (new: injected document stylesheet, browser-side decoration pass, document footer, `format_document_date`, `page_content_box_px`), `app/utils/document_ir.py` (`subtitle`, `date`, `numbering`, `layout: titlepage`), `app/services/pdf_exporter.py` (`capture_pdf` gains `media` / `viewport` / `pre_capture_css` / `pre_capture_js`; `_KEEP_WITH_NEXT_JS` chains stacked headings and binds to tables, code, figures and display math; `export_document_pdf` drives the layer under print media), `frontend/src/components/PrintRenderPage.tsx` (subtitle, server-formatted date, `titlepage` class), `app/data/built_in_skills.py` (`document_authoring`: caption convention, layout guidance, pagination rules of thumb), `Docs/DocumentAuthoring.md`, `tests/test_document_print_decor.py` (new, 22: helpers, real-Chromium decoration and keep-with-next passes, PDF text-layer header-repeat check), `tests/test_document_export.py` (+2 seam)). A rendered report read like a chat screen dump: no figure or table labels, a `9/19/2026` locale date under a title no larger than the body `h1`, every diagram floating in the renderer's padded screen box (graphviz stretched to `width:1280px`, poster-sized glyphs), full-grid tables, a marketing tagline as the footer, and every table atomic — so a page-tall table jumped to a fresh page behind a blank band. Now: a `Figure:` paragraph under a diagram or a `Table:` paragraph over a table becomes a numbered caption (`Figure 2. …`; label alone when no line is given, `numbering.figures/tables: false` to opt out); `numbering.sections` prefixes `1 / 1.1 / 1.1.1` into headings AND the PDF bookmarks; the title block gets subtitle, `Author · September 19, 2026` byline and a rule (`layout: titlepage` gives it its own page); body headings step down from the title; diagrams are centred at natural size (viewBox-clamped, points→px for graphviz) with the renderer's reserved height, padding and hover toolbar collapsed; tables get a header rule, hairline rows, zebra fill and tabular digits, and one taller than 45 % of the page flows across pages with the `<thead>` repeated; the footer reads `Title · Author · Date … Page N of M` with the tool credit demoted to a lighter second line. Two driver findings underpin the pagination: Chromium repeats table headers ONLY under print media (under the pipeline's `emulate_media("screen")` the header appears once — verified in isolation and asserted on the PDF text layer), so documents now render under print media (the served bundle's only `@media print` rule drops Prism's text-shadow); and the in-page heuristics were measuring a 1224 px screen layout while the PDF paginates at ~673 px, so the document viewport is now sized to the printable box. The decoration pass runs from the driver before keep-with-next and the outline sentinels (one pagination for every capture), passes document text as an `evaluate` argument rather than splicing it into code, and is idempotent; where D3Renderer's resize observer rewrites an inline height after the box collapses, the stylesheet's `!important` — not the pass — owns the collapse. Transcript export is untouched apart from the shared keep-with-next improvement.
- **Task cards: model-staged cards are conversation-only by default — they no longer accumulate in the permanent deck** (`app/mcp/tools/task_card_stage.py` (`persist` flag; creates with `draft` unless persisting; promotes rather than strands when no binding can be written), `app/mcp/tools/task_card_tools.py` (`task_card_list bound_to_current_chat` includes drafts; `conversation_only` on each row), `app/storage/task_cards.py` (`prune_stale_drafts` keeps any draft still referenced by a binding), `app/storage/task_bindings.py` (`all_bound_card_ids`), `app/data/built_in_skills.py` (task_cards skill text), `frontend/src/components/TaskCard/TaskCardInlineTile.tsx` (staged tile: `conversation-only` tag + **Save to deck**), `TaskCardsLibrary.tsx` (**Add to deck** for an unlisted card opened by id), `tests/test_task_card_stage_tool.py` (+4), `tests/test_task_card_drafts.py` (+3), `frontend/src/components/__tests__/stagedCardConversationOnly.test.ts` (new)). Models are now encouraged to use `task_card_stage` to hand sub-tasks off — to parallelize and to keep the parent conversation's context small — and every one of those cards was landing in the Task Cards library as a permanent artifact. The tool now files a card as an unlisted draft (the same lifecycle the fenced `task-card` proposal already used): its tile, run, bindings and the model's own `bound_to_current_chat` listing all resolve it by id, but the deck never shows it. `persist: true` files it in the deck up front; the staged tile's **Save to deck** (and the deck editor's **Add to deck**, reachable via a tile's Edit backlink) promote in place — an update, so block ids and any signature obtained against them survive. Two guards keep a card from becoming unreachable: with no conversation to bind to, or if writing the binding fails, the card is filed in the deck instead; and the week-old-draft GC now checks reachability, not just age, so a staged tile the user has not clicked yet keeps its card. `/goal` still files its synthesized card in the deck — unchanged here.
- **Docs/self-description remediation: capability glossary, over-claim corrections, non-goals, a rewritten competitive analysis, and a conformance test that keeps the docs honest going forward** (`Docs/CapabilityGlossary.md` (new, generated from the ledger `aliases` field, ~30 hand-annotated terminology entries naming the competitor term where one exists), `Docs/Capabilities.md`, `Docs/FeatureInventory.md`, `Docs/DesignPhilosophy.md` (new "What Ziya deliberately does not do" section, 28 non-goals with rationale and cost), `Docs/competitive-analysis.md` (replaced; derived from the corpus with the absent/not_applicable/unknown split and a provenance line naming the source runs), `Docs/CONTRIBUTING.md` (new Documentation Conventions subsection), `tests/test_docs_ledger_conformance.py` (new), `scripts/complandscape_docdefects.py`, `.ziya/complandscape/60-synthesis/s1-20260910/DOC-DEFECTS.json`). Closes out the plan in `design/docs-self-description-remediation.md`: ~20 ledger over-claims and Critic-A's 12 inflated scores were qualified or removed before anything was added (WS1); 107 mature, competitively-relevant capabilities that were shipped but absent from the docs got one `FeatureInventory.md` line and one `Capabilities.md` paragraph each, at the critic-corrected ledger maturity, never above it (WS3); 28 deliberate non-goals — previously indistinguishable from ordinary gaps — are now stated as decisions with a one-line rationale and cost (WS4); the vocabulary layer readers were missing (searching by capability name or a competitor's term and finding nothing) is now a generated glossary plus inline "also known as" lines on the 24 confirmed terminology artifacts (WS2). `tests/test_docs_ledger_conformance.py` is the anti-recurrence check: every ledger capability at or above the critic-corrected mature threshold must be findable in `Docs/` by id, name or alias (391 checked, 0 missing — coverage residue is 0, no deliberate-exclusion exemptions were needed), and the four `over`-class rows WS1 closed with a `removed_sentence` are asserted absent from their target docs by exact (whitespace-normalised) text match, so a reintroduced over-claim fails the suite instead of drifting back in silently. One residual finding from this pass, not yet fixed: the generated region of `CapabilityGlossary.md` is stale against a fresh regeneration (~30 links point at `Capabilities.md` rather than the section anchors added to it since the glossary was last written) — tracked for a `scripts/complandscape_docdefects.py glossary --write` re-run rather than papered over in the test.

- **Chat turns run as a server-owned relay, so a reload or a second window reattaches to the live stream** (`app/agents/chat_turn_relay.py` (new), `app/routes/misc_routes.py`, `app/server.py` (reattach/status endpoints), `frontend/src/components/App.tsx`, `frontend/src/components/ChatTurnReattachWatcher.tsx` (new), `frontend/src/utils/chatTurnReattach.ts` (new), `tests/test_chat_turn_relay.py` (new), `tests/test_chat_turn_endpoints.py` (new)). Each chat turn now executes as a server-owned task that buffers its output; the browser subscribes to that relay rather than owning the SSE stream directly, so reloading the page or opening the conversation in another window reattaches to the turn still in flight instead of dropping the in-progress response. Client stop and throttled-retry are routed through the relay's `cancel_turn` / `start_turn` so they behave identically to a first attempt.
- **Plugin interface surface extended** (`app/plugins/interfaces.py`, `app/plugins/__init__.py`). Additional extension hooks on the plugin interface.

### Fixed
- **MCP: a server installed from the registry now appears in the MCP status view immediately, without a manual reinitialize** (`app/mcp/manager.py` (`_restart_server_locked` registers `new_config` in `server_configs`, preserving a builtin's flag), `frontend/src/components/MCPStatusModal.tsx` (the `mcpStatusChanged` handler refetches at once, silently, instead of after a fixed 1 s settle), `tests/test_mcp_restart_registers_new_server.py` (new, 4)). The install path connected the new server via `restart_server(name, config)`, which created the client and started it — tools live, install reported as success — but only wrote `self.clients`; `get_server_status()` and the modal's server list both enumerate `self.server_configs`, which had never seen the new entry, so the server was absent from every listing until a full reinitialize re-read `mcp_config.json` from disk. The seam test starts a server from a config the manager does not hold and asserts it is present in `get_server_status()` (red before the fix, green after); a second asserts a builtin restarted with a new config keeps `builtin: True` so it does not migrate into the user-servers section. The frontend delay was compensating for nothing — every dispatcher of the event awaits its backend call first — and could catch a slow handshake mid-way and paint `Disconnected`.
- **MCP registry: installed services are listed in the shape the UI reads, and hand-configured servers correlate to a registry entry deterministically** (`app/utils/mcp_installed_listing.py` (new: `installed_entry_to_api`, `correlate_server_to_registry`), `app/routes/mcp_routes.py` (`GET /registry/services/installed`), `tests/test_mcp_installed_listing.py` (new, 11 incl. 2 route seam tests)). The route appended the registry manager's snake_case records (`server_name`, `service_id`) to a list the frontend's camelCase `InstalledService` type consumes, and checked `svc.get('serviceId')` against them — so the accounted set was always empty, `ServiceCard` dropped every registry install (it returns null without `serviceId`), `isServiceInstalled` never matched one, and each install reappeared only through the substring-correlation fallback, tagged "Manually Configured" and bound to whichever of ~10k registry entries the aggregator happened to return first (`slack-mcp` resolved to `jtalk22/slack-mcp-server` on one call and the internal `slack-mcp` on the next; `quip_mcp` to `bug-breeder/quip-mcp`). Registry records are now converted to the API shape, servers whose config carries `registry_provider` are never re-derived by name, and correlation accepts only exact correspondences (normalised server name equal to the service id, its last path segment, or the service name, or an identical repository URL), ranked by match tier then id so the result is order-independent; substring matches are refused (`fetch` is not "Secure Fetch").
- **MCP registry: README-sized service descriptions are reduced to a one-line headline wherever a name or summary is displayed** (`app/mcp/description_summary.py` (new), `frontend/src/utils/mcpDescriptionSummary.ts` (new, mirror), `app/mcp/registry_manager.py` (`_add_to_config` summarises `description` before writing `mcp_config.json`; `_get_installed_from_config` summarises legacy blobs on read), `frontend/src/components/MCPStatusModal.tsx` (`getServerDisplayName`), `ServiceCard.tsx` (headline collapsed, markdown-rendered full text expanded), `MCPRegistryModal.tsx` (tool-search results, preview drawer), `tests/test_mcp_description_summary.py` (new, 16), `frontend/src/utils/__tests__/mcpDescriptionSummary.test.ts` (new, 9), `tests/fixtures/mcp_descriptions/` (real registry payloads)). Some registry providers return the server's entire README as `serviceDescription` — the Slack MCP entry is 20 KB of markdown, and the installed `quip_mcp` entry carried a 1.8 KB tool catalogue. Nothing in the pipeline reduced it: providers copied it verbatim into the config's `description`, the installed-services list used `description` as the service *name*, and the MCP status panel used it as the server header, so a panel row read `## Tools (37 total, 3 access tiers) …` where a name belonged. The summariser takes the first paragraph that reads as prose (skipping headings, `a | b | c` bylines, rules, lists, tables, code fences and awesome-list badge residue), keeps its lead sentence(s), and caps at 160 characters on a word boundary; run over all 10,256 entries the local registry currently serves it produced no empty results. Both seams are at shared chokepoints so closed-plugin providers are covered; the two seam tests fail against the unpatched manager. Existing configs that already hold a blob display correctly without editing (the read seam) and are rewritten on reinstall.
- **Shell tool: globs follow the in-process `cd`, and `VAR=value` assignments store the expanded value** (`app/mcp_servers/shell_server.py` (`_expand_and_tokenize` gains `cwd`, globs via `glob.glob(pattern, root_dir=cwd)`; `_execute_pipeline` expands each peeled assignment RHS through `_expand_vars` + `expanduser` before recording it and passes `seg_cwd` to the tokenizer), `tests/test_shell_glob_cwd_and_assignment_expansion.py` (new, 9)). `cd /abs/pkg && cp src/*.ts /tmp/out/` failed with "No such file": the glob was resolved against the server process's cwd rather than the directory the executor had just switched to, so the literal pattern reached `cp`. Separately, `P=$HOME/pkg; cp $P/a b` handed `cp` a path still containing the literal `$HOME`, because `_peel_env_prefix` recorded the raw RHS and the later `$P` expansion is a single pass. Both now match shell semantics; unmatched globs are still passed through verbatim, absolute patterns are unaffected by cwd, and assignments on one segment resolve left to right (`A=x B=$A/y cmd`).
- **Shell config: the "Temporary grant staged" banner now lists what it is staging** (`app/routes/mcp_routes.py` (`_session_pending_status` (new); GET `/shell-config` returns `sessionPendingDelta`; `request-session-grant` returns `pendingDelta`), `frontend/src/components/ShellConfigModal.tsx` (`sessionStagedDelta` state, hydrated from both responses and cleared on discard; banner renders one `field: values` line per entry, or says the request is floor-only), `tests/test_shell_config_session_pending_delta.py` (new, 7), `frontend/src/components/__tests__/shellConfigEscalationUX.test.ts` (+4)). The persistent "Unsigned privilege escalation" banner has always listed its beyond-floor delta; the staged temporary banner only said *that* something was staged, so a user about to run `sudo ziya-approve --session` could not see what they were about to sign for. The delta is computed server-side from `~/.ziya/pending_session_shell.json` with the same canonical code (`scope_canonical`) the persistent banner uses, so the two banners list like for like. A present-but-unreadable pending file still reports `sessionPending: true` with an empty delta so Apply/Discard stay reachable. Not addressed here: a stale pending file can coexist with an unsigned persistent escalation (Save clears the pending file, but other writers of `mcp_config.json` do not), in which case both banners still render.
- **Task runs: a run stuck inside a block can now be force-stopped in place and resumed from the interrupted block** (`app/agents/live_runs.py` (new), `app/api/task_cards.py` (retain the run's `asyncio.Task`; `except asyncio.CancelledError`), `app/storage/task_runs.py` (`mark_aborted`), `app/api/task_runs.py` (`POST /cancel?force=true`), `frontend/src/components/TaskCard/runControls.ts` (`canForceAbort`), `liveActivity.ts` (`HUNG_AFTER_S`, `isHung`), `TaskCardInlineTile.tsx` (Force stop button), `holdChain.ts` / `RunRecoveryBanner.tsx` (`user_abort` wording), `services/taskRunApi.ts`, `tests/test_task_run_force_abort.py` (new, 13), `frontend/src/components/TaskCard/__tests__/forceAbort.test.ts` (new, 10), `Docs/Capabilities.md`, `design/task-cards.md`). Soft-cancel sets `cancel_requested` and relies on the executor reading it at the next block boundary; a run whose in-flight Task never returns — GFX Stage 2 run `b893cf07` streamed "starting build verification in frontend/" and then nothing for six hours, with cancel pressed two hours in — never reaches one, and four earlier runs of the same card show the same `npm run build`-then-silence signature. The launch path dropped the `asyncio.create_task` handle, so there was no in-process lever left: the cancel button was a no-op, and the run read `running` for as long as the server stayed up. The handle is now kept per run; `?force=true` cancels the coroutine, waits briefly for it to unwind, and the run is recorded as `held` / `user_abort` at the innermost running block — the same shape `mark_held` and the restart reconciler write, so the existing recovery banner offers resume-from-block and only the interrupted block is redone. The coroutine distinguishes a force-stop from an event-loop shutdown via recorded intent, so a server stop still leaves the record to the startup reconciler. The tile swaps Cancel for a labelled **Force stop** once a requested cancel has not landed, or after ten minutes of silence (`HUNG_AFTER_S`, deliberately well above the two-minute amber threshold so a legitimate long build is not offered for interruption). The end-to-end test launches a card against a model stream that hangs forever, asserts that plain cancel leaves it `running` with the lock held, then asserts force holds it at the leaf and releases the executor lock — a held record over a still-live executor would have been a zombie with a nicer label. Not addressed here: why the underlying `npm run build` tool call never returned despite its 1200 s shell grant.
- **Task cards: a card the model launches with `task_card_launch` now shows its monitor tile in the launching conversation** (`app/mcp/tools/task_card_launch.py`, `frontend/src/apis/chatApi.ts`, `tests/test_task_card_launch.py` (+3), `frontend/src/apis/__tests__/taskCardStageSync.test.ts` (rewritten, 6)). The chat renders task monitors from `TaskBinding` records, not from runs; every human launch path (tile Run button, `/goal`, the bindings endpoint) writes one, but the tool called `_launch_run_for_card` directly and wrote none, so a model-launched run executed with no tile, no gear affordance and no inspector reachable from the chat — visible only under the project's run list. The tool now records an unanchored binding carrying the run id (the same shape `task_card_stage` writes, minus `run_id=None`) and returns `binding_id`; the stream handler fires `task-binding-created` for `task_card_launch` results as it already did for `task_card_stage`, so the tile appears mid-turn rather than after a reload. Headless launches (no conversation in context) still succeed and say where to find the run. The new seam test drives the tool against the real `TaskBindingStorage` in a tmp project dir and asserts the chat reads back exactly one bound binding; it fails against the unpatched tool. The frontend seam test had been red since its scoping anchor — a comment in the next handler — was reworded; it now brace-matches the `if` that names the tool and covers both tools.
- **Prompt caching: the system-prompt prefix is now byte-identical from one turn to the next; per-turn volatile text (bead status, live memory counts and handles) moves to the current user message** (`app/utils/precision_prompt_system.py` (unconditional `## Message Timing` paragraph; `volatile_tail` collected during assembly and appended to the last user message after history is inserted), `app/memory/prompt.py` (`get_memory_prompt_sections()` returning `(stable, volatile)`; `get_memory_prompt_section()` kept as the byte-identical combined form), `tests/test_prompt_volatile_tail.py` (new, 6), `tests/test_message_timestamps.py` (assertion narrowed to `<CurrentDateTime value=`, shadow tag pinned empty)). With history caching fixed (below), a two-message conversation (`91acef70`, turns 15 s apart) still read 0 tokens from cache: the ~200K system block changed by 137 tokens between turns and the whole prefix was rewritten. Three pieces of the system block varied turn to turn — the Message Timing paragraph was emitted only when `chat_history` was non-empty (so turn 1 ≠ turn 2), the bead-check nudge appears at turn ≥ 3 and disappears when a bead is parked, and the memory overview carries live totals, probation counts and per-domain memory counts that change whenever a memory promotes. The first is now unconditional; the other two are appended to the current user message, after every cache breakpoint, where `CurrentDateTime` and the shadow-session tag already live. The rule this establishes: anything that can differ between two consecutive turns of one conversation belongs after the cache boundary. Tests assert on the assembled messages that the system block is identical across a turn-1/turn-2 build, across the bead-nudge threshold, and across a memory-count change, and that the volatile text is still present on the user turn.
- **Prompt caching: replayed conversation history is now cache-marked on the first call of every turn, not only from the second tool iteration on** (`app/providers/bedrock.py`, `app/providers/anthropic_direct.py` (`prepare_cache_control`), `tests/test_providers/test_cache_control_iteration_zero.py` (new, 10)). Both providers returned history unmarked when `iteration == 0`, on the comment "first iteration ... no conversation caching needed" — but iteration 0 is the first LLM call of *every* user turn, so history was never cache-read at turn start and, on tool-less turns, never cache-written at all. The usage ledger for one long session shows the shape: each turn's first call wrote ~154K (the system block only) and processed the entire history as fresh input, growing 41K → 118K → 147K → 165K over the session; the first turn on the fixed code wrote system + history (320K) and fresh input fell to 6K. The `< 6` / `< 3` short-history floors and the `ZIYA_DISABLE_PROMPT_CACHE` kill switch are unchanged. The new tests assert the iteration-0 marker lands on the same history message as iteration 1 and were confirmed to fail with the old guard reinstated. Follow-up found in the same investigation and not yet fixed: the cached prefix itself is byte-unstable turn to turn (the "Message Timing" paragraph is appended to the system prompt only once history exists; the bead-check nudge and live memory counts also live in the cached block), which defeats the cross-turn hit even with the marker in place — see `design/tool-result-aging.md`.
- **Replayed history: a tool result provably redundant with a later one is replaced by a one-line note when history is sent to the model** (`app/utils/tool_history_rewrite.py` (`plan_elisions`, `read_path_for_block`, `payload_for_block`), `app/models/project.py` (`ContextManagementSettings.elide_redundant_tool_results`), `frontend/src/types/project.ts`, `frontend/src/components/ProjectManagerModal.tsx` (Advanced panel), `app/server.py`, `tests/test_tool_history_rewrite.py` (+14), `scripts/measure_context_composition.py` (new), `design/tool-result-aging.md` (new)). Two rules only, both lossless by construction: a read of path P whose body is contained verbatim in a later read of P, or an identical body for the same tool recurring later. The record is never modified. A survey of 350 stored conversations (≥ 20 messages, ~49M replayed tokens) measured this at 0.4% of history — tool bodies are 55% and bodies over 4K chars 29%, so the design doc lays out an age/epoch-based head+tail+recall scheme for those as the next step. Per-project switch, default on; `ZIYA_DISABLE_TOOL_RESULT_ELISION=1` kills it.
- **Task runs: a second `ziya` server sharing `~/.ziya` no longer kills a sibling's in-flight run at startup, and a restart-killed run is `held` with a resume target instead of `failed`** (`app/storage/task_runs.py` (`mark_active`/`mark_inactive` take and release a per-run `flock`; `executor_alive`; `reconcile_stale_runs`; `_innermost_running_block`; `record_activity` desync warning), `app/api/task_runs.py` (cancel and both resume-from guards use `executor_alive`), `app/server.py`, `app/models/task_run.py`, `app/utils/run_status_index.py`, `frontend/src/components/TaskCard/holdChain.ts` (`server_restart` remedy), `tests/test_task_run_executor_lock.py` (new, 6, one fork-based), `tests/test_task_run_restart_recovery.py` (new, 6), `tests/test_task_run_cancel_liveness.py` (new, 3), `test_task_run_storage.py`, `test_task_card_ask_gate.py`, `holdChain.test.ts` (+1)). The startup reconciler swept every project's `running` rows to `failed` on the premise that a live row at startup must be a zombie — true for one server per `~/.ziya`, false the moment a second one starts on another port. A `--port 7575` server started at 16:33:27 reconciled the `:6969` server's healthy GFX Stage 2 run (iteration 11 in flight) sixteen seconds later; the executor kept working for an hour, blind, because heartbeats are refused on a terminal run, and the tile read "completed — failed" with no resume offered. Liveness is now a kernel-owned advisory lock on `task_runs/<run_id>.lock` held for the life of the executor coroutine: a sibling's lock is held, a crashed owner's is not, and the reconciler skips the former. What it writes for the latter is `held` / `server_restart` with `held_at_block_id` = the innermost running block — the same shape `mark_held` writes for a credential fault — so the recovery banner, Retry / Continue and the banked-iteration prefix apply with no special case; reconciling to `failed` had left `block_states` at `running`, so `firstFailedBlock` found nothing and the only control was Restart. The cancel endpoint's `is_active` check was always false (per-request storage instance, process-local set), so every cancel force-wrote `cancelled` over a live run; it and the two resume guards now probe the lock. Windows has no `flock` and degrades to the previous behaviour.
- **Task-card self-improvement: a block that keeps stopping for the same non-text reason is one finding in the Lessons panel, not N rows** (`app/utils/self_improve.py` (`stop_streaks`, `STOP_STREAK_MIN`; `summary_by_card` gains `stop_streak`), `app/api/task_cards.py` (`stop_streaks` on the card-lessons payload), `frontend/src/components/TaskCard/lessonStreaks.ts` (new), `LessonsPanel.tsx`, `TaskCardsLibrary.tsx`, `task-card-editor.css`, `frontend/src/services/taskCardApi.ts`, `tests/test_self_improve_stop_streaks.py` (new, 14), `frontend/src/components/TaskCard/__tests__/lessonStreaks.test.ts` (new, 6), `lessonsPanel.test.tsx` (+1)). A `stop` verdict is the judge saying the run failed for a reason editing the card cannot fix — the one ledger outcome that needs a *person*. GFX Stage 2's repair block stopped six runs in a row over two weeks (the headless render server does not pick up a rebuilt bundle), each rationale worded differently, and the panel showed six orange rows with nothing saying "this has been blocked for two weeks". The ledger now computes, per block, the trailing run of consecutive stops (a judge `error` is transparent — the judge failed, the run did not pass; any `accept`/`revise` resets) and returns streaks of two or more as an overlay on the card payload. The grouping is positional, not textual: no string compare groups those six wordings, and clustering ledger rows with a model call is not worth its cost or non-determinism — "stopped N runs in a row and nothing has passed since" is the finding regardless of wording. The panel folds a streak's members into one expandable row headed `stopped N runs in a row` with the newest rationale leading; every member row is still rendered inside it, so nothing readable or revertable is hidden. The deck badge turns orange with `⛔N` for a card in a streak. Tests pin the real GFX2 sequence (stop, accept, error, accept, six stops → a streak of six, not seven), that a single stop is a row not a streak, and that the overlay reaches the HTTP payload with all rows intact.
- **Task-card self-improvement: a revise no longer has to rewrite whole fields, and the judge reply can no longer be truncated by its own patch** (`app/utils/self_improve.py` (`resolve_improve_patch`, `PATCH_OPS`, `JUDGE_MAX_TOKENS`), `app/agents/improve_evaluator.py`, `app/agents/block_executor.py`, `design/task-card-self-improvement.md`, `tests/test_self_improve_targeted_patch.py` (new, 14)). A `revise` verdict had to carry the FULL replacement text of every field it touched, and the judge's reply was capped at 2,000 tokens. Measured against the GFX Stage 1 card, the largest editable field is ~1,100 raw tokens before JSON escaping, so a reply that touched two fields plus a rationale and lesson could not fit — the replies most likely to arrive cut off and be recorded as a judge failure were exactly the ones that tried to revise, which is consistent with a ledger holding 20 verdicts, 4 judge failures and 0 revisions. A patched field's value may now be a list of targeted ops — `{"op":"replace","find":…,"with":…}` (`find` must occur exactly once in the current text; zero or many matches refuse the patch rather than guess) and `{"op":"append","text":…}` — which the executor resolves to full text against the current subtree BEFORE validation, so the field whitelist, the oscillation hash, pre-image capture, apply, persist and the ledger record all see one format: the same edit written as ops or as a string has the same `patch_hash`, and the revert path is untouched because it never sees an op. A patch with any unresolvable op is refused whole (`invalid_patch`). The prompt tells the judge to prefer ops, and the reply ceiling is now 8,000 tokens so a full replacement still fits when most of a field genuinely must change. 4 of the 14 tests fail against the unpatched executor/evaluator; the seam test drives an op-form patch from a stubbed judge through `_maybe_self_improve` and asserts the level restarts with the resolved text and the ledger carries full text, its pre-image and the matching hash.
- **Task-card self-improvement: the judge now sees every stage of the level it grades, not the last iteration's self-report** (`app/models/task_card.py` (`Artifact.stages`), `app/agents/block_executor.py` (`_execute_sequence`, `_execute_parallel`, `_execute_repeat`, `_execute_until`, `_maybe_self_improve`), `app/agents/improve_evaluator.py`, `app/utils/self_improve.py` (`stage_evidence`, `stage_counts`, `render_stages_for_judge`, `render_outputs_for_judge`), `tests/test_self_improve_judge_evidence.py` (new, 16), `design/task-card-self-improvement.md`). Second finding of the GFX Stage 1 audit (the first was the judge-failure-as-accept fix in 0.8.8.6): a repeat block's artifact carries `summary` and `failed` from its LAST iteration only — last-wins is load-bearing for `{{previous}}` and `on_failure`, so it stays — and a group inherits the same single summary from its last child. The judge for the card's 20-engine sweep was therefore asked whether *every* engine met the criterion while being shown one engine's self-authored prose, `self_assessment: (none)` (the field is leaf-only) and no outputs at all; it accepted every time. Container artifacts now carry `stages`: one `{index, label, status, summary (capped 600), self_assessment, outputs}` entry per iteration (repeat/until), branch (parallel) or child (group), written at every site an iteration concludes — executed, banked, synthesized-failure, replayed — alongside the loop's own `iter_outcomes` so the two cannot disagree, and set explicitly on a group so a nested loop's iterations never leak upward through the last-wins `model_copy`. The judge prompt lists every non-passed stage in full (passed stages cap at 24 then count, so a wide fan-out cannot push its own failures out of the prompt), names the file parts in `outputs`, and is told that `failed` is last-stage-only, that a stage summary is the agent's claim rather than a measurement, and that an objective naming deliverables is unmet when `OUTPUTS` shows none. The ledger record carries the stage counts so a reader can tell an accept over 20/20 from one over 17/20. Tests: 11 of 16 fail against the unpatched executor/evaluator (a 3-iteration repeat whose middle iteration failed must reach the judge as "1 failed"; a group's stages must be its two children, not the nested repeat's three iterations; `on_failure=stop` marks the skipped children); the end-to-end seam test drives `_maybe_self_improve` and asserts the failed stage's summary appears in the judge's user message and the counts land in the ledger record.

- **GFX Vega / Vega-Lite rendering fixes** (`frontend/src/plugins/d3/vegaPlugin.ts`, `vegaLitePlugin.ts`, `vegaRecovery.ts`, `vegaGraphSanitizer.ts`, `vegaLayerDefaults.ts`; `tests/gfx_corpus/vega`, `tests/gfx_corpus/vega-lite` + regression tests). Categorical palette reach, group/facet title layout, low-opacity scatter parity, long-label bar transpose, rangeless ordinal legend, side-axis title gap, band-axis colour, and sizing recovery (defects Ge2d5a2, Gb594ca, Ge0f221, Ge8d304, G546ae4, G5f6d4a, Ga1ef6a, Gebb648, G9ee008, G6d6c01, G67dceb, D258, D508, D511).
- **GFX Mermaid rendering fixes** (`frontend/src/plugins/d3/mermaidPlugin.ts`, `mermaidEnhancer.ts`; `tests/gfx_corpus/mermaid` + tests). ER empty-fill markers, pie layout/scale, pie title+legend text, dark link stroke, autonumber/stress, border edge markers, quadrant & mindmap legibility, and sequence box title / subgraph end (G_c415f5, G40, G14a672, G961e2c, G4e869b, Gd34f80, D420, D421).
- **GFX DrawIO rendering fixes** (`frontend/src/plugins/d3/drawioPlugin.ts`, `drawioEnhancer.ts`, `drawioShapes.ts`; `tests/gfx_corpus/drawio` + tests). Stranded canvas labels, deduped cell ids, default edge markers, viewport fit, marker floor, quoted-geometry waypoints, rotation labels, and route-fix repair (G5798aa, G3a3c03, G796ea6, Gb7a537, Gbb997d, G436a36, Ga59f77, D091).
- **GFX JointJS rendering fixes** (`frontend/src/plugins/d3/jointPlugin.ts`, `jointLinkRouting.ts`, `jointShapeResolver.ts`; `tests/gfx_corpus/joint` + tests). Label contrast, label fit height, paper collision, self-loop and label handling, v1 body attrs, and shape resolution / link routing (G159927, G17, G814644, Gba30e7, Gec73fa, D130, D411, D419).
- **GFX network-diagram rendering fixes** (`frontend/src/plugins/d3/networkDiagram.ts`; `tests/gfx_corpus/network` + tests). Layout, self-loop mesh handling, and regression coverage (G8b89de, G5e174d, G_bce22e).
- **GFX force-directed rendering fixes** (`frontend/src/plugins/d3/forceDirectedPlugin.ts`; `tests/gfx_corpus/force-directed` + tests). Grouping, responsive SVG sizing, and regression coverage (G05, G181c6f, G497189, G2ca2a0).
- **GFX chord rendering fixes** (`frontend/src/plugins/d3/chordPlugin.ts`; `tests/gfx_corpus/chord` + tests). Arc stroke and empty-matrix handling (G11, G0c278d).
- **GFX D2 rendering fixes** (`frontend/src/plugins/d3/d2Plugin.ts`; `tests/gfx_corpus/d2` + tests). ELK flat node sizing, fence render path, flat-layout arrowheads, label-delimiter edge background, and card-marker rejection (G09b125, G94e293, G16470a, Ga05f90, Ge8868b, G7b82d2).
- **GFX Graphviz rendering fixes** (`frontend/src/plugins/d3/graphvizPlugin.ts`; `tests/gfx_corpus/graphviz` + tests). Forced-undersize handling and cluster contrast (G48, D118, D402).
- **GFX Plotly rendering fixes** (`frontend/src/plugins/d3/plotlyPlugin.ts`, `plotlyPreprocessor.ts`; `tests/gfx_corpus/plotly` + tests). Author text on fill, theme author colours, sankey density title, frames/animation, headless WebGL GPU-crash survival, and spatial-charts recipes (G29, Ge462ae, D457, D458, D459).
- **GFX packet-diagram rendering fixes** (`frontend/src/plugins/d3/packetPlugin.ts`, `frontend/src/utils/d3Plugins/packetPlugin.ts`; `tests/gfx_corpus/packet` + tests, G6f8b51).
- **GFX music-notation rendering fixes** (`frontend/src/plugins/d3/musicPlugin.ts`, `frontend/src/utils/d3Plugins/musicPlugin.ts`; `tests/gfx_corpus/music` + tests). Cross-staff beam dedupe, inline light theme, strict-JSON guard, and ledger / above-band handling (D428, D173, D174, G99d33f).
- **GFX core rendering fixes** (`frontend/src/utils/d3EnvelopeUnwrap.ts` (new), `frontend/src/plugins/d3/basicChart.ts`, `chartTheme.ts`, `orthogonalRouter.ts`, `frontend/src/utils/colorUtils.ts`, `pluginDimensions.ts`, `frontend/src/components/D3Renderer.tsx`; `tests/gfx_corpus/basic-chart`, `tests/gfx_corpus/d3`, `tests/gfx_corpus/chat-message` + tests). Diagram-envelope unwrap, basic-chart margins / y-tick count / envelope dispatch, chart theme and colour contrast, orthogonal routing, plugin dimensions, and chat-message table / Prism-token / footnote legibility (G45, GChat, D320, D321, D373, D323, D328, D043).
- **LaTeX / PDF rendering fixes** (`app/services/latex_renderer.py`, `latex_profiles.py`, `diagram_renderer.py`, `app/utils/latex_color.py`, `chemfig_lint.py`, `circuitikz_lint.py`, `chat_screenshot.py`; `tests/gfx_corpus/{tikz,tikz-cd,chemfig,circuitikz}` + `tests/test_latex_*.py`). Fill-aware contrast, light text-ink clamp, chemfig adjacent molecules, siunitx TS1 units, circuitikz bipole alias, mediabox objstm, tikz regressions, and diagram capture-extent units (G7127a1, Gbf5475, Geccfe9, Gf070d1, D033, D338, D348, D472, D125, D327).
- **Chat rendering fixes** (`frontend/src/components/fenceScanner.ts`, `MarkdownRenderer.tsx`, `MUIChatHistory.tsx`; `frontend/src/components/__tests__/reabsorbLeakedTailContent.test.ts`). Fence scanning, markdown rendering, and reabsorption of tail content that had leaked out of the chat message.
- **Diff view: a rejected diff is marked, and the diff-validation hook is hardened** (`frontend/src/utils/diffUtils.ts`, `app/utils/diff_validation_hook.py` + tests).
- **Markdown-dialect handling fix** (`frontend/src/utils/markdownDialects.ts`; D017).
- **Scroll-velocity handling, including programmatic scrolls** (`frontend/src/utils/scrollVelocity.ts` + tests).

### Changed
- **Task-run records: every timestamp is epoch milliseconds (schema 2); old records upgrade on read** (`app/utils/run_time.py` (new: `EpochMs`, `now_ms`, `to_ms`, `normalize_run_record`, `normalize_artifact`, `RUN_RECORD_SCHEMA_VERSION`), `app/models/task_run.py`, `app/models/task_card.py` (`Artifact.created_at`), `app/storage/task_runs.py` (every stamp through `now_ms()`; reads normalize), `app/api/task_runs.py`, `app/agents/block_executor.py`, `app/agents/task_executor.py`, `frontend/src/components/TaskCard/liveActivity.ts` (`formatLastActivity` takes ms), `DeckRunList.tsx`, `TaskCardInlineTile.tsx`, `frontend/src/hooks/useTaskRunStream.ts` (wire seconds converted once at the boundary), `frontend/src/types/task_run.ts`, `design/task-cards.md` (§Timestamps, §Restarts and sibling servers), `tests/test_run_time_normalize.py` (new, 15), `liveActivity.test.ts`). `TaskRun.created_at`/`updated_at` were ms (the `BaseStorage` convention shared with cards, bindings and beads) while `started_at`, `completed_at`, `last_activity_at`, block/iteration states, progress notes, asks and `Artifact.created_at` were `time.time()` seconds — the reconciler itself wrote both units three lines apart. Nothing yet subtracted across the boundary, but a record was unreadable by eye (`fromtimestamp(started_at)` → year 58680, which is how the 2026-09-16 investigation started) and the hazard was one `completed_at - created_at` away. All fields are now `EpochMs`, an `int` whose validator upgrades any populated value below `1e11` from seconds, so a stray `time.time()` still lands correctly. There is deliberately no mass rewrite of the 279 encrypted records on disk: another live server may be mid-write on the same directory, so `TaskRunStorage.get`/`list`/`read_iteration_artifact` upgrade v1 records in memory, keyed on a missing `schema_version`, and a record becomes v2 the next time any writer persists it. The WebSocket stream still carries `ts`/`at` in seconds; `useTaskRunStream` converts once so the tile's REST-vs-live freshness comparison sees one unit. **Rollout: new code reads old records, old code cannot read new ones — restart every `ziya` process that shares the `~/.ziya` together.**

- **MCP registry routes slimmed; the client, config validation and registry interface firmed up** (`app/routes/mcp_registry_routes.py`, `app/mcp/client.py`, `app/mcp/config_validation.py`, `app/mcp/registry/interface.py`, `frontend/src/components/__tests__/mcpRegistryInstallFailureUX.test.ts`). Registry route handlers were reduced (shared logic moved into helpers) and the client, config-validation and registry-interface paths were hardened, with an install-failure UX regression test.

## [0.8.8.6] - 2026-09-16

### Added
- **Golden tier for the graphics render suite: pixel-hash fast lane, tolerance-banded diff, drift report, and goldens that are only ever validated by the judge who saw them** (`scripts/gfx_ledger.py` (`golden_capture`, `golden_get`, `golden_set_trust`, `golden_trust_from_hash`, `golden_rebaseline`, `golden_status`, `pixel_hash`; `record --light-png/--dark-png`; `promote` carries validated hashes; new `golden` subcommand), `tests/gfx_render/golden.py` (new), `tests/gfx_render/test_render_golden.py` (new), `tests/gfx_render/conftest.py` (`--render-golden-tolerance`, `--render-golden-strict`, drift report + terminal summary), `tests/test_gfx_golden.py` (new, 22), `tests/test_render_diagram_save_path.py` (new, 12), `app/mcp/tools/diagram_render.py` (`save_path` on `render_diagram`; new `view_image` tool), `app/mcp/builtin_tools.py`, task cards GFX Stage 1 / Stage 2 (updated), "GFX drift judge" (new), `tests/gfx_corpus/README.md`). The smoke tier asserted only that a spec still *renders*; nothing asserted it still renders *the same*, so a layout regression that kept ink on the canvas passed. And the Stage 1 sweep, which judged 2,400 renders, kept verdicts and discarded every image, so there was no baseline to compare against and no way to say what "validated" pixels looked like. Now: a golden is the PNG a spec rendered to in one theme plus a sidecar naming its trust — `provisional` (the runner captured it because none existed; asserts only that later renders can be compared) or `validated` (a judge recorded `ok` on THOSE bytes via `record --light-png/--dark-png`, or the pixels hash-match a validated render committed in the corpus, or a human ran `golden rebaseline --reason`). Goldens are machine-local under `.ziya/gfx-golden/` (gitignored; Chromium-build-specific); what is committed is each validated golden's pixel hash in `expectations.json`, so a fresh clone can recognise the judged image and `golden trust-from-hash` accepts a local render that hashes identically. The runner compares cheapest-first — hash of the decoded RGBA buffer (PNG re-encoding cannot break it), then per-pixel diff at capture resolution with a 0.5 % tolerance — and reports `exact` / `noise` / `baselined` / `drift` (validated golden, beyond tolerance) / `changed` (provisional) / `error`. Drift is recorded to `.ziya/gfx-sweep/drift/<run>/` with the new render and a heatmap, not failed (`--render-golden-strict` to fail): the judge is a model, and wiring it into pytest would make the suite need credentials and be able to flip on identical code. Measured on the packet engine, two runs on one commit: 34/58 hash-identical, 24 differ by 4–554 px (worst 0.028 %, 18× under tolerance), 1 pre-existing intermittent screenshot timeout — so a CRC alone would have sent 41 % of a stable engine to the expensive path, and the tolerance is nowhere near a dropped element (6,400 px). Rebaselining is never automatic: forty "equivalent" verdicts in a row could walk a baseline somewhere nobody accepted in one step, so the judge card records and prints the `golden rebaseline` command for a person. `render_diagram(save_path=...)` writes the exact returned bytes (confined to `.ziya/` and `/tmp`, at the tool's common return point so all three render paths are covered) and `view_image` reads them back — the judge needs to see the golden, the drift and the heatmap, and nothing could show an on-disk PNG to a model before. Stage 1/2 cards now render with `save_path` and pass the files to `record`, so the 2,400 renders the sweep already pays for stop being discarded and a verified fix re-baselines by construction. The comparison's unit tests pass a 1-px glyph shift and fail a dropped 100×80 node — the two cases the tolerance exists to separate — and the ledger tests pin that a provisional capture can never replace a validated golden.
- **A committed, self-maintaining render regression suite for the graphics engines** (`tests/gfx_corpus/` (new, 733 specs across 20 engines, 4.3 MB), `tests/gfx_render/` (new: `conftest.py`, `test_render_smoke.py`), `scripts/gfx_ledger.py` (`promote`, `promote_defect`, `promote_regression_sets`, `promote_verified`, `invariants_for_signature`; `reconcile` now promotes on the verified transition), `tests/test_gfx_ledger.py` (+6)). Every GFX fix so far added a unit test at the *transform* level — 279 jest + 65 pytest on fixup functions — and none at the *render* level, which is the level the Stage 1 sweep catches regressions at; the residue notes record the consequence repeatedly ("guard test green, render disagrees" on D-215/D-223/D-267/D-270). The only render-level regression check was the sweep itself: 2,400 model-judged renders, run when someone launches a card, whose evidence lives under the gitignored `.ziya/`. The corpus now lives in the repo and grows itself: when `gfx_ledger.py reconcile` flips a defect to `verified` — the moment "this used to be broken, now it renders" is known — its specs are copied into `tests/gfx_corpus/<engine>/` with an `expectations.json` entry carrying the invariants derived from the defect's signature and the defect id as provenance. Spec bytes are frozen on first promotion (a later sweep rewriting the on-disk spec does not follow into the corpus; the spec *is* the thing under test). Seeded once from the Stage 1 regression sets (199 both-theme-passing specs) and the 235 verified defects (615 specs, deduped to 733). `tests/gfx_render` is opt-in (`--render`; skipped in 0.6 s otherwise) and renders each (spec, theme) through the SAME dispatch `render_diagram` uses — plugin types via the live `/render` harness, LaTeX types via `latex_renderer`, chat-message via the real chat UI — asserting the smoke-tier invariants: `renders` (terminal complete, no timeout/error), `no_console_errors` (warnings are the fixup layer working and are allowed), `ink_present` (≥ 0.2 % of pixels differ from the dominant colour — a lone 50 px artifact on a blank canvas, the D-151 shape, fails; a single short label passes). `--render-scope=smoke` (default) is the 199-spec regression set, ~400 renders at ~2 s each; `full` is the whole corpus. Session fails up front, with the reason, when no server answers on `--render-port`; the root conftest's `ZIYA_HOME` sandbox is overridden to a no-op here because the chat-message renderer must seed into the project the server actually reads, and chat-message cases skip with the remedy when the test process lacks the server's at-rest key material. `visual:*` tags (contrast / layout / min_text_size, mapped from the signature) are recorded for a later measured tier and ignored by this one, so a smoke run never fails on an invariant it cannot measure. A test pins that every invariant name the ledger writes is one the suite reads. Verified live: packet 20/20, chemfig 20/20 (LaTeX path), mermaid 24/24, chat-message 20 skipped with reason. GFX Stage 1 remains the exploratory, model-judged audit for the classes this tier cannot reduce to a number.
- **Shadow line control: a `supervised` tier, and the control ceiling is now settable on a live session** (`app/shadow/lease.py`, `app/shadow/policy.py`, `app/shadow/sock_server.py`, `app/shadow/registry.py`, `app/shadow/pty_host.py`, `app/mcp/tools/shadow_tools.py`, `app/cli.py`, `tests/test_shadow_lease.py` (+2), `tests/test_shadow_host_e2e.py`). Trying to take control of a session started without `--allow-control` gave `control_disabled` with no way to fix it short of restarting the terminal; the only runtime knob was the `[x] exec` toggle, a Phase-2 flag nothing reads. `[x]` is gone; `[c]` cycles the session's control ceiling `none → supervised → gated → unrestricted → none`, persists it to the registry (the next `shadow_control` sees it, no restart), journals it, and dropping to `none` also revokes any live lease. `supervised` is a new restriction rung: every command — allowed or not — is confirmed by the human at the terminal (`y`/`n`), the per-command approval tier the original Phase-2 exec handshake described. Rank order for the min-wins clamp is `supervised < strict < gated < unrestricted`, so a chat asking for `strict` against a `supervised` ceiling gets `supervised`. `strict` (policy-only, no prompts) is dropped from the human-facing menu and stays as the headless/spawn tier and a chat-requestable restriction. `--allow-exec` / `allow_exec` are now inert and slated for removal in the audit pass.
- **Competitive-landscape study: cli-surface under-claims documented (WS3)** (`Docs/Capabilities.md` ("In-Session Commands" section: new `Also called:` alias line describing the palette architecture), `Docs/FeatureInventory.md` (§11 Developer Experience (CLI-specific): new "Declarative slash-command palette" index row), `.ziya/docs-remediation/closed-cli-surface.json`). Closed the single `cli-slash-command-palette` under-claim row from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`. The in-session slash-command *surface* was already well documented (the `Docs/Capabilities.md` "In-Session Commands" table lists ~14 commands and the `DOC-DEFECTS` discrepancy text itself found "no material over/under-claim"), so the row was closed `already-present`: no command listing was duplicated. What was missing was any reader-vocabulary entry point and any statement of the underlying architecture, so a `<!-- cap: cli-slash-command-palette -->` `Also called:` line was added to the Capabilities section — projected from the ledger `summary`/`depth_notes`/`limits` (maturity 4, not in Critic A's `inflated_scores`) — naming the ~20-command palette as a single `COMMAND_SPEC` source of truth that keeps completion, the inline `?` help and dispatch in sync, with the trailing-`?` next-level help and `//` literal-message escape, and stating the limit (not user/plugin-extensible at runtime — commands are added in source). A matching index row was added to `FeatureInventory.md` §11, which previously listed individual commands but never the palette as one capability. Aliases drawn from the ledger `aliases` field; each insertion tagged `<!-- cap: cli-slash-command-palette -->`.
- **Competitive-landscape study: ast-and-code-intelligence under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§3 Code Intelligence: new multi-language / 25+-language tree-sitter row after the AST reference-tracing row), `Docs/Capabilities.md` (new "Code Intelligence (AST)" section before "Code Application"), `.ziya/docs-remediation/closed-ast-and-code-intelligence.json`). Closed the single `ast-and-code-intelligence` under-claim row from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`: the docs described code intelligence as "AST-based" and mentioned only Python/TypeScript/HTML-CSS, leaving the 25-language tree-sitter parser entirely undocumented. Added one FeatureInventory index line and one Capabilities paragraph — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity (3), stating the limit — for the generic `TreeSitterParser`/`TreeSitterConverter` layer (`ast-treesitter-multilang`), which registers per-language parsers for C/C++, Rust, Go, Java, C#, Kotlin, Swift, Ruby, PHP, Scala, Lua, Perl, R, Elixir, Haskell, Dart, Zig, OCaml, Julia, Bash, HCL, SQL, TOML and YAML and maps their CSTs onto Ziya's unified node types. The maturity-3 limit is stated, not hidden: for tree-sitter languages the parser surfaces only definitions and imports (no call-graph or reference edges, so `ast_references` callers/importers are weak for them), attribute extraction is text-slice based rather than typed, depth is shallower than the native Python parser, and the whole layer depends on an optional package that degrades to disabled if absent. Not in Critic A's `inflated_scores`, so documented at ledger maturity. Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: ast-treesitter-multilang -->`.
- **Competitive-landscape study: plugins-governance-and-web-security under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§9 Enterprise & Internal Deployment: envelope encryption, passphrase encryption, directory-scan governance and prompt-extension rows plus an open-core plugin/provider-registry row; new §11b "Web & Transport Security" section: CSP + security headers and origin/CSRF guard rows), `Docs/Capabilities.md` (new "Web security, encryption & extensibility" section before "Skills"), `.ziya/docs-remediation/closed-plugins-governance-and-web-security.json`). Closed the 7 `plugins-governance-and-web-security` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — web-facing HTTP hardening, opt-in at-rest encryption, and the open-core plugin surface that the docs did not describe. The FeatureInventory index lines were already added by an earlier pass in this study; this pass added the matching `Capabilities.md` paragraphs — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for envelope encryption at rest (`application-level-encryption-at-rest`, maturity 4, stated off-by-default / whole-blob / needs `cryptography`), single-env-var passphrase encryption for community installs (`passphrase-encryption-community`), CSP + security response headers with relaxed/strict modes (`content-security-policy-and-headers`), the Origin/Referer CSRF guard (`origin-guard-csrf`), `.ziya/scan.yaml` directory-scan governance (`directory-scan-governance`), and the per-model prompt-extension framework (`prompt-extension-framework`). `plugin-provider-framework` is documented via the §9 open-core plugin/provider-registry row (which lists the previously-omitted `EncryptionProvider`/`DirectoryScanProvider`/`ToolResultFilterProvider`/`ExtractionPatternProvider` interfaces) and cross-referenced from Capabilities' Tool Enhancements alias; it was marked `already-present`. None of the 7 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`application-level-encryption-at-rest` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`. (Duplicate §9 rows a concurrent pass produced for the same seven ids were removed so each capability has exactly one FeatureInventory index line and one Capabilities paragraph.)
- **Competitive-landscape study: mcp-integration under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§8 MCP: five new rows — built-in in-process tools, config validation, registry marketplace, resources & prompts, startup diagnostics — plus the strengthened rug-pull-quarantine row, and a corrected §8 "Runtime MCP hot-reload" gap-table entry that had falsely claimed config changes require an app restart), `Docs/Capabilities.md` (new "Built-in (in-process) tools" subsection and MCP server-management paragraphs under "MCP Tools", plus a new "Rug-pull fingerprinting & quarantine" paragraph in the tool-result integrity section), `.ziya/docs-remediation/closed-mcp-integration.json`). Closed the 7 `mcp-integration` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the 13-category built-in tool registry and its per-category toggling (`mcp-builtin-direct-tools`), the line-anchored config validator/findings panel (`mcp-config-validation`), the multi-provider aggregation/dedup/ranking marketplace beneath the "registry browser" (`mcp-registry-marketplace`), MCP resources & prompts support beyond tools (`mcp-resources-and-prompts`), runtime server enable/disable/restart and manager reinitialize without an app restart (`mcp-server-lifecycle`, which also corrected the false FeatureInventory gap line), the SHA-256 rug-pull hard-quarantine and human re-authorization flow (`mcp-rugpull-quarantine`, previously under-mentioned as "trigger[s] security warnings"), and staged startup diagnostics with failure attribution (`mcp-startup-diagnostics`). None of the 7 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`mcp-builtin-direct-tools`, `mcp-config-validation`, `mcp-registry-marketplace`, `mcp-rugpull-quarantine`, `mcp-server-lifecycle` at 4; `mcp-resources-and-prompts`, `mcp-startup-diagnostics` at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: frontend-conversation-management under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§4 Context & Conversation Management: five new conversation-lifecycle rows + `<!-- cap: -->` markers on the existing search and persistent-model-pin rows), `Docs/Capabilities.md` (new "Conversation management" subsection under "Context & Projects"), `.ziya/docs-remediation/closed-frontend-conversation-management.json`). Closed the 7 `frontend-conversation-management` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — conversation-lifecycle capabilities the docs did not describe or described only as a keyboard shortcut. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity (all 7 are maturity 3, none in Critic A's `inflated_scores`), stating the limit — for ephemeral (unsaved) conversations and promotion (`fcm-ephemeral-conversations`), move/copy conversations & folders across projects (`fcm-move-copy-project`), the read-only conversation info panel (`fcm-conversation-info-modal`), the durable per-conversation raw/pretty display mode (`fcm-display-mode-toggle`, previously documented only as the `Ctrl+Shift+U` shortcut), and the chat shadow-copy integrity scan/reconcile tooling (`chat-shadow-copy-integrity`). The two already-present capabilities — server-side conversation full-text search (`conversation-full-text-search`, already the "Conversation search with selectable ordering" row) and the durable multi-scope model pin (`per-conversation-model-pin`, already the model-pinning rows in §5) — were marked with `<!-- cap: -->` and given `Also called:` alias lines plus (for parity) Capabilities paragraphs. Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: document-ingestion under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§6 Multimodal & File Formats: `PDF input` row expanded, four new rows — document upload endpoint, upload safety guards, fetched-PDF re-extraction, PCAP TCP health analysis), `Docs/Capabilities.md` (Large-PDF RAG section extended for the object-density routing heuristic and the light/full two-tier index, plus a new "Document Upload & Ingestion Pipeline" subsection), `.ziya/docs-remediation/closed-document-ingestion.json`). Closed the 7 `document-ingestion` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`. The upload path was materially under-documented: the only file-format mention was "PDF input — Native reading" and the PDF RAG section omitted the object-density heuristic and the two-tier index. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit — for the dual-backend text extractor (`pdf-text-extraction`), the multipart upload endpoint with its pluggable handler registry (`document-upload-route`), the oversize/zip-bomb guards (`document-safety-guards`), fetched-PDF re-extraction behind an SSRF guard (`fetched-pdf-http-reextraction`), and per-flow TCP health analysis for packet captures (`pcap-tcp-health`); the two already-present RAG capabilities were enhanced in place — the large-PDF routing ladder's object-density heuristic (`pdf-large-rag-routing`) and the content-addressed light/full index with lazy promotion (`pdf-rag-index`). None of the 7 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`pdf-large-rag-routing`, `pdf-rag-index` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: patch-diff-pipeline under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§3 Code Intelligence: seven new patch-pipeline rows + a corrected/`<!-- cap: -->`-marked regression-suite row), `Docs/Capabilities.md` (new "Inside the patch pipeline" subsection under "Code Application"), `.ziya/docs-remediation/closed-patch-diff-pipeline.json`). Closed the 8 `patch-diff-pipeline` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — the applier's robustness layer (the docs showed only the multi-strategy cascade and per-hunk status). Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for pre-apply repair (`diff-preprocessing-normalization`), idempotent re-apply (`diff-already-applied-detection`), fence-escape recovery (`diff-backtick-fence-recovery`), language-aware post-apply validation (`diff-per-language-validation`), env-tunable thresholds (`diff-config-tunables`), CWE-tagged security hardening (`diff-security-hardening`), and apply-time superseded-diff dedup (`diff-frontend-superseded-dedup`). `diff-test-harness-and-regression-suite` was already documented (the "Diff regression suite" FeatureInventory row); its stale "137 edge-case patch tests" count was corrected to the actual 159 test-case directories, it was marked with a `<!-- cap: -->`, and a matching `Capabilities.md` paragraph was added for parity with the other seven. None of the 8 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`diff-already-applied-detection`, `diff-backtick-fence-recovery`, `diff-preprocessing-normalization`, `diff-security-hardening`, `diff-test-harness-and-regression-suite` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: task-cards-and-blocks under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§2a Task Cards: seven new block-grammar rows + a hierarchical-scope row, and `<!-- cap: -->` markers on the existing self-assessment row), `Docs/Capabilities.md` (new "Task Cards & Blocks" section before "Scheduling & Run Lifecycle"), `.ziya/docs-remediation/closed-task-cards-and-blocks.json`). Closed the 9 `task-cards-and-blocks` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — the block grammar's breadth was materially under-stated (README and FeatureInventory headlined only "Task / Repeat / Parallel" for a grammar with eight block types). Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the grammar (`taskcard-block-tree-grammar`), the loop primitives (`taskcard-repeat-loop-block`, `taskcard-until-model-evaluator`), the givens/sequence primitives (`taskcard-state-block`, `taskcard-group-sequence-and-failure-policy`), data passing (`taskcard-template-substitution`), the additive permission tree (`taskcard-hierarchical-scope-model`), and the isolation invariant (`taskcard-conversation-isolation-sandbox`). `taskcard-self-assessment-capture` already had a FeatureInventory row (marked with a `<!-- cap: -->` and given its alias line) and received a new Capabilities paragraph. None of the 9 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`taskcard-block-tree-grammar`, `taskcard-repeat-loop-block`, `taskcard-template-substitution`, `taskcard-hierarchical-scope-model`, `taskcard-conversation-isolation-sandbox` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: mcp-security-and-hallucination under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§8 MCP: nine new tool-result-security / hallucination-defense rows), `Docs/Capabilities.md` (new "MCP tool-result integrity & hallucination defenses" subsection under "Tools"), `.ziya/docs-remediation/closed-mcp-security-and-hallucination.json`). Closed the 10 `mcp-security-and-hallucination` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — a layered set of tool-result trust and anti-hallucination controls the docs did not describe. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for tool-result signing and validation (`mcp-tool-result-signing`, `mcp-response-schema-validation`, `mcp-input-constraint-validation`), tool-description defenses (`homoglyph-confusable-detection`; `mcp-tool-poisoning-scan` got the new Capabilities paragraph, its FeatureInventory row already added by WS1), tool-result hygiene and framing (`hidden-char-sanitization`, `encoded-payload-scanning`, `tool-result-trust-envelope`), and hallucinated-output detection (`shingle-parroting-detection`, `fake-tool-result-echo-detection`). None of the 10 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`hidden-char-sanitization`, `mcp-tool-poisoning-scan`, `mcp-tool-result-signing`, `shingle-parroting-detection` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: context-and-file-tree under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§3 Code Intelligence: expanded the File browser and Token-count rows + three new file-tree rows; §4 Context & Conversation Management: five new rows), `Docs/Capabilities.md` (two new subsections in "Context & Projects" — "File tree & context sources" and "Global, auto-included and reusable context"), `.ziya/docs-remediation/closed-context-and-file-tree.json`). Closed the 10 `context-and-file-tree` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the live file tree and its plumbing (`ctx-live-file-tree-websocket`, `ctx-filesystem-watcher`, `ctx-gitignore-ignore-engine`), context sources (`ctx-external-path-inclusion`, `ctx-auto-included-docs`, `ctx-named-context-sets`), model-side context curation (`ctx-model-self-curated-files`), the anti-hallucination file-state tracker (`ctx-file-state-line-tracking`), the per-file token *estimate* (`ctx-per-file-token-estimation`), and cross-project global chats/folders (`cross-project-global-items`). The pre-existing terse "File browser" and "Token count per file" rows were expanded in place (external-path persistence/`[external]` branch; the estimate-vs-exact clarification) rather than duplicated. None of the 10 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity (`ctx-file-state-line-tracking`, `ctx-gitignore-ignore-engine`, `ctx-model-self-curated-files` at 4; the rest at 3). Each insertion carries an `Also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: memory-beads-and-skills under-claims documented (WS3)** (`Docs/FeatureInventory.md` (new "Memory & beads — capability index" table in §2b, four new rows in §10 Skills), `Docs/Capabilities.md` (new bead-branching-fork paragraph in the "Memory" section), `.ziya/docs-remediation/closed-memory-beads-and-skills.json`). Closed the 11 `memory-beads-and-skills` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`: the persistent memory subsystem, the built-in/file-discovered skill library, and seam-based conversation branching. Added one FeatureInventory index line each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the memory store and lifecycle (`memory-flat-store`, `memory-post-conversation-extraction`, `memory-proposal-lifecycle`, `memory-browser-ui-api`, `memory-mindmap`, `memory-organizer`), conversation branching (`bead-branching-fork`), and the skills library (`skills-builtin-library`, `skills-catalog-injection-ondemand`, `skills-enhanced-dimensions`, `skills-file-discovery-agentskills`). The matching `Capabilities.md` "Memory" and "Skills" paragraphs for 10 of the 11 were already present from an earlier pass and were left in place; `bead-branching-fork` received a new Capabilities paragraph. None of the 11 appear in Critic A's `inflated_scores`, so all are documented at ledger maturity. Each insertion carries an `also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`; `skills-builtin-library`'s "few example skills" under-claim and `skills-enhanced-dimensions`'s "in future releases" over-caution are corrected to what ships today.
- **Competitive-landscape study: visualization-renderers under-claims documented (WS3)** (`Docs/FeatureInventory.md` (§7 Visualizations: six new renderer rows + an internals note in the section intro), `Docs/Capabilities.md` ("Diagram Rendering": six new format rows, an "Also known as" alias block, and a new "Renderer coverage and internals" subsection), `.ziya/docs-remediation/closed-visualization-renderers.json`). Closed the 12 `visualization-renderers` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — registered renderers and shared rendering infrastructure the doc format tables did not list. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the format renderers (`viz-plotly-render`, `viz-jointjs-render`, `viz-chord-render`, `viz-force-directed-render`, `viz-network-diagram-render`, `viz-vega-render`) and the rendering internals (`viz-plugin-registry-and-selection`, `viz-mermaid-spec-repair` — the ~77-rule auto-repair pipeline, `viz-malformed-spec-recovery`, `viz-shared-layout-engine`). `viz-html-mockup-renderer` and `viz-music-render` were already documented and received only an `Also known as:` alias line and a `<!-- cap: -->` marker. Each insertion carries an alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: scheduling-and-automation under-claims documented (WS3)** (`Docs/FeatureInventory.md` (new §2c "Scheduling & Run Lifecycle" + `<!-- cap: -->` markers on the existing gear and history-replay rows), `Docs/Capabilities.md` (new "Scheduling & Run Lifecycle" section), `.ziya/docs-remediation/closed-scheduling-and-automation.json`). Closed the 14 `scheduling-and-automation` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json` — a fully-implemented, tested, UI-exposed scheduling engine and run-lifecycle machinery that the docs did not mention at all (`grep schedul|cron|recurring` returned zero hits). Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the scheduler (`sched-task-card-scheduler`, `sched-catch-up-coalesce`, `sched-max-runs-cap`, `sched-schedule-state-endpoint`, `sched-system-jobs`), the run lifecycle (`sched-run-lifecycle-status-model`, `sched-held-fault-classification`, `sched-zombie-reconciliation`), run observability (`sched-run-activity-heartbeat`, `sched-task-run-stream-relay`), resume/retention (`sched-resume-through-call`, `sched-iteration-artifact-retention`), and artifact serving (`sched-artifact-blob-serving`). `run-status-index` was already documented (the conversation-list gear + its cached projection) and received only an `Also called:` alias line and a `<!-- cap: -->` marker. Each insertion carries an `also called:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`. Scheduler `cron`/timezone caveats and internal-only `system-jobs` status are stated, not hidden.
- **Competitive-landscape study: execution-sandbox-and-approvals under-claims documented (WS3)** (`Docs/FeatureInventory.md` (new §11a "Execution Safety, Sandboxing & Approvals"), `Docs/Capabilities.md` (new "Execution Safety & Approvals" section), `.ziya/docs-remediation/closed-execution-sandbox-and-approvals.json`). Closed the 16 `execution-sandbox-and-approvals` under-claim rows from the synthesis run (`s1-20260910`) `DOC-DEFECTS.json`: shipped, competitively-relevant capabilities that were undocumented or under-described. Added one FeatureInventory index line and one Capabilities paragraph each — projected from the ledger `summary`/`limits`/`user_visible` at the ledger maturity, stating the limit where maturity is 3 — for the approval gate (`escalation-config-signature-gate`, `approval-keypair-provisioning`, `ephemeral-session-grant`, `approval-ttl-expiry`, `escalation-audit-ledger`), the shell command guards (`shell-command-allowlist-engine`, `shell-write-policy-enforcement`, `shell-credential-exfiltration-guard`, `shell-iac-deploy-guard`, `shell-interpreter-escape-guard`, `shell-execution-runtime-hardening`), and the write-path / provenance / diagnostics layer (`parent-authoritative-project-write-paths`, `task-run-permissions-snapshot`, `task-scope-permissions-editor`, `scope-clamp-diagnostics`). `approval-root-signer-cli` was documented at Critic-A's corrected maturity 4 (claimed 5) with a new Capabilities paragraph; its FeatureInventory presence (§2a) was already added by WS1 and left in place. Each insertion carries an `also known as:` alias line drawn from the ledger `aliases` and is tagged `<!-- cap: <id> -->`.
- **Competitive-landscape study: "What Ziya deliberately does not do" (WS4)** (`Docs/DesignPhilosophy.md` (new section before "What I'd Do Differently"), `.ziya/docs-remediation/ws4-nongoals.json`, `.ziya/complandscape/60-synthesis/s1-20260910/DOC-DEFECTS.json`). Documented the 28 capabilities the synthesis run (`s1-20260910`) confirmed absent-by-mechanism and classified `DELIBERATE_NON_GOAL`, grouped under the five reason themes from the reintegration summary (local-first single-owner trust model; served web-SPA + CLI harness, not an editor; no infrastructure provisioning; BYO-credentials model-agnostic broker; streaming-text core, no generative media). Each entry states what the capability is (with the competitor whose term it is), why Ziya declines it as a decision, and what the decision costs — sourced from each `-disposition.json` `recommendation`/`architectural_conflicts`/`residual_quality_gap`, cross-checked against Critic B's `gap_queue_entries_to_discount`/`category_errors` and REPORT.md §9, and tagged `<!-- cap: <id> -->`. All 28 survived the critic cross-check (0 excluded); the 28 `non-goal` rows in `DOC-DEFECTS.json` are marked `closed_by=WS4`.
- **Competitive-landscape study: competitor-vocabulary map for the 24 terminology artifacts (WS2)** (`Docs/CapabilityGlossary.md` ("Terms competitors use" table), `Docs/Capabilities.md` (11 inline alias lines), `.ziya/complandscape/60-synthesis/s1-20260910/DOC-DEFECTS.json`). Filled the hand-maintained "Terms competitors use" section of the glossary with a 24-row table mapping each competitor's term (and tool) to the Ziya mechanism (`ziya_internal_name`) and its **critic-corrected** status — FOUND at the ledger maturity, or PARTIAL with what is missing for the 6 rows Critic A downgraded (`automatic-mindmap-generation`, `custom-personas-assistants`, `diff-llm-resolver-stage`, `lint-test-fix-repair-loop`, `visual-agent-flow-builder`, `user-authored-project-rules`), with dead-code paths (`post-instruction-framework-dead`, the `app/shadow/journal.py` writer/reader, the `ContinuationMiddleware`/`ToolResultFilterProvider` stubs) marked _experimental, not wired at runtime_. Where the mechanism is already described in `Capabilities.md`, added an inline "Also known as: … ; competitors call this …" line beneath its first mention (11 of 24), each tagged `<!-- cap: <id> -->`; the other 13 have no `Capabilities.md` mention and are recorded for the WS3 under-claim pass.
- **Competitive-landscape study: generated capability glossary (WS2)** (`scripts/complandscape_docdefects.py` (`glossary` subcommand, `--stdout`/`--write`), `Docs/CapabilityGlossary.md` (new), `tests/test_complandscape_docdefects.py` (+7)). A reader-vocabulary → Ziya-name cross reference generated from the ledger `aliases` and matrix domains: one entry per capability at maturity ≥2 (`ctx-live-file-tree-websocket`, `ctx-gitignore-ignore-engine`, …), grouped by matrix domain then alphabetically, carrying the capability name (linked to the `Capabilities.md` heading slug that names it, else to the file), the Ziya id in code font, the maturity, and an `also:` alias list deduplicated case-insensitively and capped at 8 with reader-facing phrases sorted ahead of code identifiers. Maturity ≤1 capabilities (`post-instruction-framework-dead`, the `app/shadow/journal.py` pieces, `sched-timezone-declared-not-implemented`, …) are parked, alias-free, in an "Experimental or vestigial" subsection. Output is deterministic (same corpus → identical bytes, no timestamp in the generated region); `--write` rewrites only the region between the `GENERATED` markers, preserving the hand-authored preamble and the "Terms competitors use" placeholder. The 430 capabilities with no matching `Capabilities.md` heading are reported to stderr as the WS3 under-claim backlog. 467 entries across 14 domains.
- **Competitive-landscape study: documentation-defect projection** (`scripts/complandscape_docdefects.py` (new), `tests/test_complandscape_docdefects.py` (new, 10)). Extracts a `DOC-DEFECTS.json`/`.md` row set from the CL synthesis-run corpus (ledger, matrix, reintegration summary, per-capability Stage A/disposition, Critic A) classifying each doc discrepancy as terminology / non-goal / inflated / partial (explicit-id, `auto=false`) or under / over / other (keyword-matched free text, `auto=true`) without judging or rewriting the underlying findings.
- **Competitive-landscape study: manual spot-check of `scripts/complandscape_docdefects.py`'s auto-classified rows** (`.ziya/complandscape/60-synthesis/s1-20260910/DOC-DEFECTS.json`). Reviewed every `class=other` row (41) plus a deterministic every-kth sample of `auto=true` rows in `under` (20/120) and `over` (10/19), reading `discrepancy_text` and correcting `class` where the keyword match was wrong; 24 of 71 rows were reclassified (`other`→mostly `under`, ~51%; `over`→`under` 2/10, 20%; `under`→`over` 1/20, 5%) and all 71 are now `reviewed=true`; `auto=false` rows were left untouched.
- **Shadow sessions — the handoff's open items closed, plus a stale-registry bug the reboot exposed** (`app/shadow/registry.py`, `app/shadow/redaction.py`, `app/shadow/journal.py`, `app/shadow/pty_host.py`, `app/shadow/client.py`, `app/cli.py`, `app/main.py`, `app/cli_commands.py` (new), `tests/test_shadow_handoff_opens.py` (new, 20), `tests/test_shadow_security_control.py` (+6), `Docs/design/shadow-sessions.md` §3, §5, §7, §8, §10). **Registry liveness was `os.kill(pid, 0)`**, so after a reboot every entry a SIGKILLed or power-cut host left behind named a pid that may now be an unrelated process — and listed as a live session with a dead socket (the user's pre-reboot `06fb51` was this). A foreign pid must now also answer `ping` on the entry's socket with the matching `session_id`; missing/refused/wrong-id is stale and reaped, a timeout is indeterminate and left alone (reaping unlinks the journal, which must never happen to a busy host), an entry younger than 5 s with no socket is a host that has saved but not yet bound, and the current process's own entries are trivially live. Writing that test surfaced a second defect: `remove()` unlinked whatever socket/journal paths the entry named as long as they were inside a shadow directory, so reaping a corrupt entry pointing at a *sibling's* socket took the live session down — unlink is now confined to the entry's own `<id>.sock`/`<id>.journal`. **Registry at rest:** the entry (label, argv, cwd, meta — user- and model-authored text that carries hostnames and sometimes credentials) is now one ALE line under the journal's envelope when encryption is active; readers accept both forms and skip, rather than reap, an entry encrypted under a key they lack. **User redaction sets** for the lab-banner class of leak only a user can name: `~/.ziya/shadow/policies/redact/<set>.json` (`{"patterns": [{"kind", "regex"}]}`, shareable like a policy), applied with `ziya shadow --redact <set>` (repeatable) or `--redact-regex RX`; a user regex runs on every output byte inside the terminal wrapper, so it passes the same ReDoS guard as a model `search` pattern, may not match the empty string, is capped at 64, and a missing or malformed set refuses to start the session (a seam test proves the flag reaches `ShadowCore` as compiled patterns — a flag parsed and dropped would pass every unit test). **`search` now matches meta records** — the event name and string values of its data, so an `ask` question, a chat `comment`, or a control verdict's command/reason is findable; keys and nested provenance are not searched. **The `[i]` instrument snippet** is delivered as a bracketed paste (`ESC[200~…ESC[201~`) when the child's line editor has DECSET 2004 on, tracked from its output: a bare burst of `;`, `{` and quotes tripped ZLE widgets under p10k and the snippet arrived mangled; a plain shell still gets the bare bytes, and the journaled `cmd` is the snippet either way since the markers are input escapes the segmenter strips. **Transcript cosmetics:** output blocks no longer open with the blank line left by the echoed command's newline, and tabs are expanded before the 5-column prefix shifts the tab stops (which collapsed `ls`/`ps` columns). **One subcommand set:** the `{'chat','ask','review','explain','task','shadow'}` literal lived in `app/main.py` twice and `app/cli.py` once; it is now `app.cli_commands.TOP_LEVEL_COMMANDS` (stdlib-only, importable before the heavy imports), with a test that the argparse subparsers equal it and neither module carries the literal any more. Observed once under load (avg ~14) and not reproduced in three subsequent runs: `test_socket_rejects_connections_from_another_uid` failed in a full-suite run — flagged, not chased. Not done: `$((6*7))` still classifies as a substitution (conservative, accepted); the enterprise policy-gate hook on `shadow_read`/`shadow_send` stays parked with the plugin work.
- **Competitive-landscape study: comparative appendices and PDF build** (`scripts/complandscape_appendix.py` (new), `tests/test_complandscape_appendix.py` (new, 8)). The CL6 synthesis report keeps only rollups; the intermediate comparative data — 108 head-to-head depth tables, 27 per-tool scorecards, the 128-row gap register — was reachable only by reading JSON. One script now projects those back into print form as Appendices A–C of `REPORT.pdf` (A4) and a separate `MATRIX.pdf` companion (555 × 28, A3 landscape), adding no judgment: every number is copied from the corpus, and Critic A/B corrections are shown as `raw→**defensible**` beside the recorded value rather than replacing it (CL4 ledger corrections as `old→new`). Rendering is markdown-it-py → Chromium via Playwright, all inside the shell allowlist. One rendering defect fixed on the way: the report's prose quotes a literal `<script>` tag, which with HTML passthrough on became an unclosed script element and silently dropped 22 of 33 pages; the renderer now runs with `html=False` and the machine-only provenance comment is stripped from the print copy (the human-readable corpus line follows it).
- **Shadow line control — §6.3 security audit: 17 findings, each closed by a test that fails against the unpatched tree** (`app/shadow/policy.py`, `app/shadow/sock_server.py`, `app/shadow/pty_host.py`, `app/shadow/client.py`, `app/mcp/tools/shadow_tools.py`, `tests/test_shadow_security_control.py` (new, 72), `tests/test_shadow_control_ops.py`, `tests/test_shadow_send_line.py`, `tests/test_shadow_spawn.py`, `Docs/design/shadow-sessions.md` §6.2, §6.3, new §10.2). Same method as the Phase 1 audit. The policy findings came from feeding `RemotePolicy("builtin","strict").classify` the obvious shell tricks and reading the verdicts — eight classes of command the read-only remote profile marked *allowed*: `FOO=x rm -rf /` (env prefix hid the head), `xargs rm` / `time rm` / `timeout 5 rm` (wrapper allowlisted, argument unseen), `ls & rm -rf /` (nothing split on a lone `&`, and the engine was run on the whole line, so a benign head sheltered anything after it), `ls\nrm -rf /` (two commands at the PTY), `echo hi>f` / `&>f` / `cat <(rm x)` (redirection regex demanded leading whitespace), `sed -ni` (in-place flag inside a cluster), `python3 -c …` / `ruby -e` / `node -e` (computation locally, arbitrary code remotely), `find -delete` / `awk 'BEGIN{system(…)}'`. The classifier now splits quote-aware per segment, peels env prefixes, runs the destructive check *and* the allowlist engine per segment, treats any unquoted `>`/`<(` as a redirection, and refuses wrappers, interpreters, mutating `find` actions, `awk` with `system(`/`>`/`|`, and any control character — with a positive-control test that `ls -la`, `cat f | sort | uniq -c`, `awk -F: '{print $1}'`, `grep 'x&y' f` still flow. Host-side: `send_line` refuses text with a CR/LF/ESC/^C/^D/TAB as `bad_request` **before** the policy, so even `unrestricted`+`none` sends exactly one printable line. Lease findings: the socket caller chose the policy axis and its default was **`none`**, so `strict`+`none` and `gated`+`none` switched the mode table off behind a banner still saying strict/gated — and on a **spawned** session (clamped strict, implicit grant) `policy=none` was total control with no human anywhere, which `test_shadow_spawn` had encoded as "the explicit escape hatch"; now absent ⇒ `builtin`, `none` ⇒ `policy_requires_unrestricted` unless the post-clamp restriction is `unrestricted` (never on a spawned session), and the policy is resolved at acquire so a `named:` typo is refused before the human grants rather than failing closed after. `control_status` no longer echoes the lease_id (the bearer token for `send_line`/`release`) to every socket peer. The gated confirm keystroke is bound to the banner it answers (`confirm_id`): command A times out, B takes the slot, the human's late `y` for A no longer runs B; and the lease is re-validated after the wait, so a `y` arriving after release/expiry/supersede is `no_lease`, nothing typed. The terminal `g` grants the lease its banner named, not a looser same-conversation re-acquire that superseded it in between. The yield-to-human race is closed: the decisive `terminal_busy()` check and the PTY write are now one critical section under `_write_lock` (which `handle_input` also takes), so a human byte lands wholly before (⇒ busy, wait) or wholly after our `\r`, never as a prefix of the model's command. `kill_session` with no conversation context skipped the owner check and could kill any headless session, and it SIGTERMed a plaintext, reusable registry pid — now ownership requires the id and the host must answer `ping` on its socket with the matching `session_id` first (test: a stale entry pointing at an unrelated `sleep` is refused and the sleep survives). The largest: **`shadow_spawn` ran any local argv** (`bash -c …`) with no allowlist and no keystroke — §6.2 assumed it "passes through normal tool approval", and no such step exists for builtin tools; `argv[0]` must now be a bare name on a spawn allowlist shipped as `["ssh"]` (`~/.ziya/shadow/policies/spawn.json` to extend), and an `ssh` argv may carry only options and a destination — a remote command, `--`, `-F`/`-O`/`-W`, or `-o ProxyCommand|LocalCommand|PermitLocalCommand|KnownHostsCommand|Include|Match|…` is `spawn_denied`, since the send_line policy never sees the spawn command itself. Heartbeat now normalises `conversation_id` the way acquire does (a >64-char id could never keep its own lease alive). Six existing tests followed the change (`gated`/`strict`+`none` → `builtin`; status asserts no lease_id; spawn tests write a test `spawn.json`). Three follow-ups noticed in passing were closed the same way (C18–C20, +3 tests): a gated confirm that timed out left the terminal frontend in confirm mode, so the human's next keystroke was eaten as "nothing awaiting confirmation" — the server now calls `on_confirm_timeout(confirm_id)` and the frontend leaves confirm mode only if that banner is still the one on screen (`test_confirm_times_out_to_deny`, which had asserted the swallowed key, follows the change); `detach` with no `conversation_id` force-cleared any attachment over a socket every same-UID process can speak, and the tool sent exactly that when it had no conversation context — the id is now required and only the attached conversation may detach; and the headless daemon's `PYTHONPATH` was the chat process's whole `sys.path`, cwd-derived entries included — pinned to the directory containing the `app` package plus the user's own `PYTHONPATH`. The whole shadow suite is 231 + 2 slow.
- **Ephemeral chats now announce themselves inside the chat window** (`frontend/src/components/EphemeralChatBanner.tsx` (new), `frontend/src/components/App.tsx`, `frontend/src/index.css`, `frontend/src/components/__tests__/ephemeralChatBanner.test.tsx` (new, 6)). Previously the only cue that the active conversation was ephemeral — held in React state only, lost on reload or project switch — was an italic "· ephemeral" suffix on its row in the chat list, which is invisible with the sidebar collapsed and easy to miss when it isn't. A banner now sits pinned at the top of the chat container ("Ephemeral chat — not saved. Lost on reload or project switch.") with a one-click **Keep this chat** button that calls the existing `promoteEphemeralToRetained`, and the chat container's border switches to dashed for as long as an ephemeral chat is active, echoing the dashed "new ephemeral chat" button so the two cues read as the same concept. The banner keys off the *active* record's `isEphemeral` flag, not the presence of any ephemeral in the list, and unmounts on the render after promotion. Distinct from the header's `🔒 Ephemeral` badge, which marks the server-wide ephemeral *mode*.
- **Shadow sessions: the chat input box now shows a chip for every `ziya shadow` terminal attached to the conversation** (`frontend/src/components/ShadowSessionChip.tsx` (new), `frontend/src/components/SendChatContainer.tsx`, `app/routes/shadow_routes.py` (new, `GET /api/shadow/attached?conversation_id=`), `app/server.py`, `app/shadow/context.py` (`attached_sessions()` helper), `tests/test_api_shadow_attached.py` (new, 3), `frontend/src/components/__tests__/shadowSessionChip.test.tsx` (new, 3)). The user asked for a clear indicator on the input box of which terminals this chat can see, so attachment is never a surprise. The chip shows label + id, an amber `● control` marker when the conversation holds a *granted* line-control lease (the model can type there, not just read), `? ask` when the human at the terminal has a pending question, and dims when the host stops responding. The route reads the same helper that builds the model's per-turn `<AttachedShadowSessions>` tag, so chip and model can never disagree; it returns only sessions attached to the requesting conversation — another chat's terminal is invisible here, matching the context-tag rule. Polled every 5 s (a registry scan plus one short socket round-trip per attached session).
- **Shadow line control, step 5 — yield-to-human, replacing the soft-pause / precedence design** (`app/shadow/pty_host.py`, `app/shadow/sock_server.py`, `app/shadow/client.py`, `tests/test_shadow_yield_to_human.py` (new, 4), `Docs/design/shadow-sessions.md` §6.1). The design called for a buffered keystroke handoff: the human's keys would render dim and be held until Enter, which committed the line and *paused* the lease; interrupt keys paused immediately; resume was a menu key or a fresh grant. Prototyped on paper and rejected — it asked the human to learn a locking model to use a feature whose point is not having to think. What ships instead closes the same hazard (two typists interleaving inside one line) entirely on the controller's side: `send_line_to_child` now waits its turn, up to `SEND_WAIT_S` (10 s), until the human has typed nothing for `HUMAN_QUIET_S` (1.5 s), has no partial line in the segmenter's typed buffer, and the child's output has been quiet for `OUTPUT_QUIET_S` (0.3 s); otherwise it refuses with `terminal_busy` (and a `detail` naming which condition), surfaced to the model as "the human is using the terminal; try again shortly". The human's keystrokes are never delayed, buffered, or gated, and there is no pause state, no resume key, and nothing on screen. The client's `send_line` socket timeout rises to 85 s to cover confirm + yield. Tests, against a real headless child, prove the model waits in each of the three busy cases and goes through once the terminal is free, that the human's partial line reaches the shell untouched with nothing interleaved, and that `terminal_busy` surfaces over the socket; they fail against the previous `send_line_to_child` (no `wait_s`, no yield). Also on the tree: the four step-4 diffs (grant/confirm/revoke) were confirmed applied.
- **Shadow line control, step 4 — the human grant at the terminal, gated per-command confirm, and menu revoke** (`app/shadow/pty_host.py`, `app/shadow/sock_server.py`, `app/shadow/policy.py`, `app/shadow/client.py`, `tests/test_shadow_grant_confirm.py` (new, 4), `tests/test_shadow_policy.py` (+1)). This is the first step of line control that touches the interactive terminal. A `control_acquire` on an interactive session now renders `⏺ chat <id> requests line control · gated · policy builtin · [g] grant · [n] deny` and the lease stays *pending* (every `send_line` → `lease_pending`) until the human presses `g` there — the grant is a method on the socket server, never a socket op, so it cannot be issued by the requesting chat (§6.1 authority). Under a `gated` lease a command the policy does not allow no longer fails with `confirm_required`; the host parks it, renders `⏺ chat <id> wants to run: <cmd> (<reason>) · [y] run · [n] deny`, and blocks the `send_line` for up to `CONFIRM_TIMEOUT_S` (60 s): `y` writes it, `n` or a timeout returns `command_denied` with nothing written, and only one confirm can be outstanding (`confirm_pending`). Headless sessions still refuse (`confirm_required`) — there is nobody to ask. The `C-x C-z` menu header shows the lease (`control: chat <id> (gated, builtin, active)`) and `[r]` revokes it. Every request/grant/deny/confirm/revoke is a journal `meta` record with provenance. Verified on a real PTY (`sh -i` under `run_interactive`): banner rendered, keystroke landed, allowed command ran in 60 ms, gated command denied, menu revoke, clean teardown. That smoke test exposed a defect the pytest harness could not: `ShellServer.is_command_allowed` narrates every verdict with `print(file=sys.stderr)`, and inside a shadow host stderr *is* the human's terminal — a verdict sprayed "Available commands: …" / "Segment … matched pattern" onto the screen, and with the PTY buffer undrained the write blocked and `send_line` never answered. Engine construction and every verdict now run under a lock with stderr redirected to `/dev/null`; a `capfd` test asserts a verdict writes nothing (fails against the unfixed module). Also: `client.send_line` waits 70 s rather than the 5 s default so a gated command can sit at the banner. Design deviations from §6 noted: a single `y` confirms (the doc's "y twice for mutating" is not implemented — under gated every confirm is by definition a not-allowed command); the grant cannot yet tighten the requested restriction (g/n only).
- **Shadow line control, step 6b — headless spawn/kill, so a chat can drive the tested control loop end to end without an interactive terminal** (`app/shadow/headless.py` (new), `app/shadow/client.py` (`spawn_headless`, `kill_session`), `app/mcp/tools/shadow_tools.py` (`shadow_spawn`, `shadow_kill`), `app/shadow/sock_server.py`, `app/shadow/policy.py`, `app/mcp/builtin_tools.py`, `tests/test_shadow_spawn.py` (new, 5), `tests/test_shadow_policy.py` (+1)). `shadow_spawn(argv)` forks a detached `python -m app.shadow.headless` host (§6.2): daemonized by double-fork so it is reparented to init — without that the host lingered as a zombie of the chat process, which never waits, and every liveness check reported it alive forever — announces its session id back over an inherited pipe, hangs up its child on SIGTERM, and ends itself after an idle timeout (no lease heartbeat, no subscriber, no journal activity; default 24 h). The spawning conversation is the authority: it receives an implicit lease at spawn, and the socket **clamps every lease on a spawned session to `strict`** (§6.3) regardless of what is requested — nobody is watching a banner, so the policy is the whole control. `shadow_kill` is owner-only (`not_owner`) and refuses non-spawned sessions (`not_headless`). Two policy corrections surfaced by the tests: a `none` policy allows everything by definition, so `gated`+`none` never prompts and `strict`+`none` refuses nothing — the earlier tool default of `none` therefore made the modes degenerate; both `shadow_control` and `shadow_spawn` now default to a new **`builtin`** set (the shipped default shell allowlist under the remote profile — read-only diagnostics run; redirection, `rm`/`mv`/`cp`, `sudo`, `ssh`, `systemctl` deny), and `none` is an explicit opt-in. And `tee`/`touch`/`dd`/`install`/`truncate`/`patch` are allowlisted locally (their targets are path-checked there) but write files, so they are mutating under the remote profile. A missing binary is not a spawn error — `fork` succeeds before `exec` fails — so the test asserts the empty session reaps itself within seconds rather than that spawn raises.
- **Shadow line control, step 6a — the chat-side control tools, so a conversation can drive a shadow session** (`app/mcp/tools/shadow_tools.py` (`shadow_control`, `shadow_send`, `shadow_release`), `app/shadow/client.py` (`control_acquire`/`control_heartbeat`/`control_release`/`control_status`/`send_line`/`wait_idle` wrappers + `LeaseKeepalive`), `app/mcp/builtin_tools.py`, `tests/test_shadow_control_tools.py` (new, 5)). `shadow_control` acquires a line-control lease for the current conversation (headless sessions grant implicitly; interactive sessions report `pending_grant` until the human grants at the terminal — phase 4); `shadow_send` runs one command, waits for journal quiescence, and returns the resulting transcript in a single call; `shadow_release` drops the lease. The lease_id is held server-side keyed by `(conversation_id, session_id)` — the model never handles it — and a background `LeaseKeepalive` thread in the tool-running process pings the ~10 s dead-man heartbeat so a lease survives think-time between tool calls, while still expiring promptly if that process dies (the dead-man property, verified by test: lease held past the window with keepalive running, expired within a window after it stops). Enforcement stays in the shadow host: `shadow_send` surfaces the host's verdict verbatim — `command_denied` (strict, off-policy), `confirm_required` (gated, mutating — the terminal-grant path is phase 4, so it refuses rather than runs), `control_paused`, `altscreen_active`, `echo_off`, `lease_pending`. A named policy that allows `echo` but not `whoami` denies `whoami` under strict and prompts under gated; a redirection is "mutating" under any real policy. Transcripts pass the SDO-183 sanitizer. Note: `gated + policy none` is degenerate (`none` allows everything, so nothing is ever classified mutating) — gated is only meaningful with a named/inherit policy, as §6.3 states. Still to come: phase 4 (terminal grant banner + menu revoke, which flips `confirm_required` and `pending_grant` from refusal to an actual one-keystroke prompt), phase 5 (soft-pause), `shadow_spawn` (a controllable headless session reachable from chat), and the §6.3 security audit pass.
- **Shadow line control, step 3 — `send_line`, the first PTY write under a lease** (`app/shadow/pty_host.py`, `app/shadow/sock_server.py`, `tests/test_shadow_send_line.py` (new, 7), `tests/test_shadow_control_ops.py`). The `send_line` socket op is now implemented: it validates the lease (live, granted, not paused, matching id), classifies the command through the lease's `RemotePolicy.decide()` (step 1), and applies the §6.3 mode table — `command_denied` under strict, `confirm_required` under gated (the one-keystroke terminal grant is phase 4, so a mutating command is *refused* rather than run until then), run under unrestricted or when read-only-allowed. Only then does it hand the write to a new core callback, `ShadowCore.send_line_to_child`, which enforces the two guards that need live PTY state and cannot be judged statically: it refuses while the child is in the alternate screen (`altscreen_active` — a TUI we cannot see) or has echo off (`echo_off` — a password prompt), then writes `text\r` to the master fd after feeding the segmenter so the command record precedes its output. Enforcement lives in the shadow host, not the caller, because any same-UID process can speak the socket. Every verdict is journaled with lease/conversation provenance (`control_send`, `control_denied`, `control_confirm_required`, `control_send_blocked`). `send_line` returns `sent_at_seq` (the journal tail before the write) as the correlation marker rather than a synchronous `cmd_seq`, since the segmenter assigns the command's seq asynchronously when it sees the echo+prompt; the control loop is `send_line` → `wait_idle` → `tail`/`read` from `sent_at_seq`. `send_keys`/`screen` (screen-mode control) remain truthful `not_implemented` refusals for phase 4. Still headless: the interactive grant banner, menu revoke, and soft-pause are the next (live-verified) step. 130/130 shadow tests, the 7 new ones exercising the full acquire→send→wait_idle→read loop against a real child plus the deny/confirm verdicts and both guards.ops** (`app/shadow/lease.py` (new), `app/shadow/sock_server.py`, `tests/test_shadow_lease.py` (new, 15), `tests/test_shadow_control_ops.py` (new, 9)). Turns the §6.1 control socket ops from `not_implemented` stubs into a working single-controller lease: `control_acquire` (line mode; `mode=screen` refused until phase 4) clamps the requested restriction to the session ceiling (minimum-wins), grants immediately for a headless session (the spawning conversation is the authority, §6.2) or creates a *pending* lease for an interactive one (the terminal keystroke that grants it lands in step 4); `control_heartbeat` is the dead-man switch (a lease whose heartbeat goes stale past `HEARTBEAT_TIMEOUT_S`=10s is reaped, freeing the session for another conversation); `control_release`/`control_status`; and `wait_idle`, which resolves on journal quiescence (`quiet_ms`, bounded by `timeout_ms`) so a control loop can pace `send_line → wait_idle → tail` without a screen mirror. Every transition is journaled with provenance. The lease itself (`lease.py`) is a pure timestamp-driven state machine — no socket, no PTY, no threads — so it is exhaustively unit-tested with a synthetic clock; the socket layer holds one `LeaseSlot` behind a lock. Grant is a host method (`grant_active_lease`), never a socket op, so a lease can only be granted from the physical terminal, never over the wire — the §6.1 authority rule. `send_line`/`send_keys`/`screen` remain stubbed for step 3, now returning `control_disabled` on a ceiling-`none` session (fundamentally uncontrollable) rather than `not_implemented` (not yet built) — a distinction a caller can act on. No bytes are written to any PTY yet; this is the lease skeleton only.
- **Shadow line control, step 2 — the lease state machine and control ops** (`app/shadow/lease.py` (new), `app/shadow/sock_server.py`, `tests/test_shadow_lease.py` (new), `tests/test_shadow_control_ops.py` (new)). A pure single-controller lease state machine (`app/shadow/lease.py`) — acquire (clamped to the session ceiling, minimum-wins), human grant (implicit for headless per §6.2), heartbeat with a ~10 s dead-man timeout, release/revoke — driven with a synthetic clock so it is exhaustively unit-testable. The socket server wires `control_acquire`/`control_heartbeat`/`control_release`/`control_status`, a `grant_active_lease` method kept off the socket (a lease can only be granted from the physical terminal, never over the wire — the §6.1 authority rule), `revoke_lease`, and `wait_idle` (resolves on journal quiescence, the basis of the `send_line`→`wait_idle`→`tail` control loop). No PTY write yet — that is step 3.
- **Shadow line control, step 1 — the command policy (`app/shadow/policy.py` (new), `tests/test_shadow_policy.py` (new, 33 tests)).** The first, fully-headless piece of §6.3 line control: a pure classifier that decides whether a command a lease wants to `send_line` to a remote shell is allowed, with no PTY, socket, or lease yet. Two axes, minimum-wins: a *policy set* (`none`; `named:<set>` reading a shareable `~/.ziya/shadow/policies/<set>.json` in the shell-config schema; `inherit` deferred with a precise `NotImplementedError` rather than a silent fall-open) and a *mode* (`strict` refuses / `gated` prompts at the terminal / `unrestricted` runs, journaled). The allowlist engine is `ShellServer.is_command_allowed` reused under a **remote profile** with two deliberate departures, because the local pipeline phases it defers to cannot run against a remote host: yolo is forced off (never inherits onto a remote lease), and paths are opaque — any redirection, any destructive command (rm/mv/cp/…), and any command substitution is "not allowed" and falls to the mode table, since `is_command_allowed` admits `rm`/`echo >f` precisely expecting a later local write-checker to vet the target and there is none remotely. So "mutating" (the design's word) just means "not allowed by the read-only remote profile", and the existing verdict×mode table does the rest — no separate axis. Redirection detection strips quoted spans first (`grep '>' f` is not a redirection; `x > f` is); substitution detection reuses the shell server's own extractor so it matches what the local validator recurses into. Tests are adversarial (quoted-redirection, substitution smuggling, destructive mid-pipeline, name traversal on `named:`, full mode-table coverage) and fail as an ImportError against the pre-module tree. Enforcement lives in the shadow host (the chat may pre-check, but any same-UID process can speak the socket), which the lease wiring in the next step will call on every `send_line`.
- **LaTeX fretboard chord-diagram rendering** (`app/utils/fretboard_lint.py` (new), `app/services/latex_profiles.py`, `app/services/latex_renderer.py`, `frontend/src/constants/latexProfiles.ts`, `tests/test_latex_fretboard.py` (new)). Guitar/ukulele/bass chord-box rendering wired into the LaTeX render profiles and renderer, with a lint/geometry helper and the frontend profile constant.
- **Chat-tree folder badges in the conversation sidebar** (`frontend/src/utils/chatTreeFolderBadge.ts` (new, +test), `frontend/src/components/MUIChatHistory.tsx`). Folder rows in the conversation tree now carry a badge summarising their contents.

### Fixed
- **Task-card self-improvement: a failed judge is no longer recorded as an "accept", the next judge is no longer primed by its predecessors' self-praise, and the 🌱 badge counts edits rather than verdicts** (`app/agents/improve_evaluator.py`, `app/agents/block_executor.py`, `app/utils/self_improve.py`, `app/api/task_cards.py`, `frontend/src/components/TaskCard/{LessonsPanel,TaskCardsLibrary}.tsx`, `frontend/src/services/taskCardApi.ts`, `tests/test_self_improve_judge_honesty.py` (new, 11), `frontend/src/components/TaskCard/__tests__/lessonsPanel.test.tsx` (+1), `design/task-card-self-improvement.md`). Audit of the GFX Stage 1 card: 10 ledger records, badge showing 🌱 10, zero revisions ever applied — 7 accepts and 3 evaluator fail-safes that had been written as `verdict: accept` with rationale "judge unavailable or unparseable", rendered as green lesson rows and counted. Across the whole ledger (20 records, 3 cards) there were 4 such disguised failures and 0 revisions. Judge failures now carry their own verdict, `error`, with a cause (`transport` / `unparseable` / `bad_verdict`) and a bounded head+tail excerpt of the raw reply plus its length — enough to tell a reply cut off mid-patch (a token-ceiling problem) from a genuinely broken judge, which the old fallback discarded. The executor ledgers them as such and stops the level with `self-improve: judge_error`. Pre-fix fallback records are relabelled `error` (`legacy_fallback`) on read, so historical outages stop counting as approvals. The judge prompt's PRIOR LESSONS now include only `revise` and `stop` records: accept lessons were the judge praising its predecessor ("this structure reliably produces the sweep") and later lessons in the ledger literally echoed them ("continues to reliably produce"). The deck badge shows `edits_applied` always — a card that never revised shows a dimmed 🌱 0 rather than its verdict count — with verdicts and judge errors in the tooltip; the panel header and per-card endpoint report `judge_errors`, and an error row renders red with its excerpt and no Revert. 6 of the 11 new tests confirmed failing against the unpatched evaluator/executor/API; the storage-layer half passes because that half is applied directly.
- **`/api/shadow/attached` no longer blocks the event loop, which could stall the whole UI on a busy machine** (`app/routes/shadow_routes.py`, `tests/test_shadow_attached_not_event_loop_blocking.py` (new)). The endpoint is `async def` but called `attached_sessions()` — a registry glob plus up to two 0.3s AF_UNIX round-trips (`info`, `control_status`) *per attached session* — inline on the asyncio loop. Every open conversation window polls it every 5s (`ShadowSessionChip`, `POLL_MS=5000`), so under any window count, and badly when a shadow host is hung (each `connect`/`recv` burns its full 0.3s timeout), that blocking I/O ran on the loop and stalled every other request in the process — including the project-switch fetches that populate the sidebar, producing the "click a project, sidebar never renders, nothing is clickable" freeze. The diagnostic signature was a Network waterfall in which BOTH loop-scheduled (`attached`) and threadpool-scheduled (`current-model`, `config`, plain-`def`) requests stalled together for minutes, which is only possible when the loop cannot turn (threadpool handlers are dispatched by the loop). The blocking helper is now offloaded via `asyncio.to_thread`. A regression test asserts the helper runs on a worker thread, not the loop thread.
- **Test suite: request-scoped ContextVars no longer leak between tests** (`tests/conftest.py` (`_restore_request_contextvars`, autouse), `tests/test_chat_history_tools.py`, `tests/test_request_contextvar_isolation.py` (new, 4)). `app.context` holds the per-request project root, conversation id, and task-scope grants in module-level ContextVars. In production the request that sets them ends and the context dies with it; in pytest a *sync* fixture runs in the main thread's one long-lived context, so a `set_project_root(...)` with no token reset survived the test and every later root-addressed reader saw a tmp_path from a module that finished minutes ago. Bisected live: `test_chat_history_tools.py`'s `env` fixture left its root set; `test_write_policy_prompt_consistency`'s shell-block summary then described *that* (unregistered) project and reported bare defaults, and `test_file_deletion` / `test_new_file_diff_bugs` / `test_shadow_security` fell over the same way — seven order-dependent failures reproducible only with both modules in one run, in that order, which is why they read as flaky. The fixture now resets its tokens, and an autouse guard beside the existing `_restore_environ` snapshots every ContextVar on `app.context` before each test and restores it after (plain `set(prior)` — every var declares `default=None`, and a `Token` would restore the *leaked* value, not the clean one). The new tests run an isolated two-test session with and without the guard and require that only the guarded run passes, so a guard that stops working fails here rather than resurfacing as intermittent failures elsewhere. Four unrelated standalone failures remain in the same broad selection (`backend_system_tests` ×2, `test_diff_pipeline_language_validation`, `test_tool_batch_seam`); they fail in isolation and were previously misattributed as order-dependent.
- **Docs: corrected 15 over-claims found by the competitive-landscape study** (`Docs/FeatureInventory.md`, `Docs/Capabilities.md`, `README.md`). 5 were literally false (the MCP config-file lookup uses the first existing file, not a "later entries win" merge; the diff pipeline is 3 functional stages, not 4 with an "LLM resolver"; the "13 prompt-injection patterns" count was stale; conversation export opens a GitHub Gist compose page rather than pushing "directly to paste services") and 10 were missing a material limit (the swarm/delegate system requires an AWS Bedrock endpoint; pcap parsing needs the optional scapy/dpkt extras and its MCP tools are experimental; MCP remote auth is static bearer/header injection, not OAuth 2.0; no web-tier login ships and access control is a closed-plugin extension point; `/goal` completion is a model-judged check, not a deterministic gate; plugin export targets ship none in-tree; signed escalation only verifies after `ziya root --provision`; DOCX/PPTX ingestion reads the modern XML formats only — legacy binary `.doc`/`.ppt` are not parsed; and the CLI `/goal` launches its synthesized card directly against a running server, with no staged-review badge — that is the web-UI flow). Each edit names the capability id in a `<!-- cap: ... -->` marker.
- **Shell allowlist: the git-ops floor has a single source** (`app/mcp_servers/shell_server.py`, `tests/test_shell_git_ops_floor_single_source.py` (new, 10)). When `SAFE_GIT_OPERATIONS` was absent from the environment the server fell back to an inline literal that had drifted from `DEFAULT_SHELL_CONFIG["safeGitOperations"]` — `tag`, `ls-tree`, `rev-parse`, `rev-list`, `describe`, `shortlog`, `whatchanged`, `reflog`, `stash list` and `config --get` were all missing — while `scope_canonical` derives the floor from the config, so the two halves of one gate disagreed: `git rev-parse HEAD` was admitted through the canonical channel and refused without it. The fallback now reads the config the server already loads (`_effective_config`); an explicit value still narrows or widens as before. The `test_shell_git_tag_read_only` fixture, which had to set the variable by hand to work around this, is unchanged and still valid. 7 of the 10 tests fail against the unpatched tree.
- **Shell tool: command-substitution bodies are now vetted by the write policy and executed as full command lists** (`app/mcp_servers/write_policy.py`, `app/mcp_servers/shell_server.py`, `tests/test_shell_substitution_bodies.py` (new, 33)). Two defects on one seam. *Policy:* `is_command_allowed` recursed into `$(...)`/backtick bodies but `ShellWriteChecker.check` did not — and the allowlist deliberately admits `cp`/`rm`/`mv`/`sed` on the premise that the write gate vets their targets — so `echo $(cp /etc/hosts app/main.py)`, ``echo `rm -rf app` `` and `x=$(sed -i s/a/b/ app/main.py)` passed both gates and ran. The checker now carries a substitution extractor (`subst_fn`, wired by the server to the same quote- and nesting-aware finder the allowlist uses) and validates every body as a command, seeded with the enclosing segment's cwd candidates so `cd /tmp && echo $(cp a b)` is allowed while `echo $(cd ~ && cp x .ziya/y)` is refused; denials are prefixed `(in command substitution)`. A bare checker with no extractor keeps its old (body-opaque) behaviour, which the tests pin so the server-wiring seam test is what proves coverage. *Execution:* `_run_substitution` tokenized the body as a single argv, so `$(false || echo fallback)` ran `false` with `||`, `echo`, `fallback` as arguments (yielding empty output), `$(echo a; echo b)` produced `a; echo b`, and `$(cd sub && pwd)` produced nothing. Bodies now run through `_execute_pipeline` — operators, in-process `cd`, redirections, nested substitutions — with a copy of the enclosing pipeline-local variables (minus `PWD`, which each segment derives from its own directory), so a body behaves as the subshell it is: `X=1; echo $(X=2; echo $X) $X` prints `2 1`. 19 of the 33 tests fail against the unpatched tree; the rest are positive controls.
- **Shell allowlist: the read-only `git tag` grant no longer admits tag creation** (`app/mcp_servers/shell_server.py`, `tests/test_shell_git_tag_read_only.py` (pre-existing spec, 51 tests, 24 of which failed against the unfixed pattern)). The `safeGitOperations` floor is described to the model as `git (read-only operations)`, but the pattern installed for `tag` guarded only `-d`/`-D`/`--delete`, so every creating form — `git tag v1.0` (no flag at all), `-a`, `-s`, `-u`, `-m`, `-F`, `-e`, `-f`, bundled `-am`, `--create-reflog` — was admitted unescalated. A bare operand *is* the mutation, so no flag blacklist can express "read-only"; the new `GIT_TAG_READ_ONLY_PATTERN` admits only the listing/verification grammar: display-only options alone (`--column`, `-i`), or any mix of options and operands when an option that implies `--list` per git-tag(1) (`-l`, `-n`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`, …) or `-v`/`--verify` is present. Every form in the spec's read-only set (`git tag -l 'v0.8*'`, `--sort=-v:refname`, `--format=…`, `-v <tag>`, `--contains HEAD`, …) still matches; the mutating set is refused in full. Known cost: `--sort <value>` with a *separate* argument is refused because the value starts with `-`; use `--sort=<value>`.
- **Write policy: `allowed_write_patterns` (and task-scope glob grants) now apply only inside the project root** (`app/config/write_policy.py`, `app/mcp_servers/write_policy.py`, `tests/test_write_patterns_project_scoped.py` (new, 19)). Both matchers computed the target's project-relative path and then, when the target did *not* resolve under the root, fell back to matching the raw string — and its basename — against the glob. A `*.md` pattern configured for the project's docs therefore also approved `cp x /etc/foo.md`, `~/foo.md`, and (once the shell check became cwd-aware) `cd /etc && cp x foo.md`; a `*/__tests__/**` pattern approved `/etc/__tests__/x.py`. In the same lines the containment test was `resolved.startswith(project_root)` with no separator, so a sibling directory `<root>2/` counted as inside the project. Patterns are presented to the model as *project* policy, and now behave that way: a glob matches only a target that resolves to the root or a descendant of it; anything else falls through to a refusal. Explicit task-scope *path* grants are unchanged and may still point outside the project. Eleven of the nineteen new tests fail against the unpatched tree; the eight positive controls (relative and absolute targets inside the root, root given with a trailing slash) pass on both.
- **Shell write policy: in-place edits (`sed -i`, `perl -i`, `awk -i`) are now judged against the files they actually edit** (`app/mcp_servers/write_policy.py`, `tests/test_shell_inplace_edit_targets.py` (new, 35)). Two defects in how `_inplace_edit` derived its target list. (1) Redirection tokens were not stripped, so `sed -i s/a/b/ tests/x 2>/dev/null` was refused for "targeting '2>/dev/null'" — `_destructive` already ran `_strip_redirections`; this check did not. (2) The first non-flag token was unconditionally treated as the script, which is wrong whenever the script arrives through an option: with `sed -i --expression=s/a/b/ app/main.py tests/x` or the glued `-es/a/b/` form, `app/main.py` was taken as the script and skipped, so an unapproved file was edited in place as long as any *later* argument was approved (confirmed against a policy approving only `tests/`). A small per-program option table (`_inplace_targets`) now recognises separate, glued and `--opt=` script forms plus value-taking non-script letters (gawk `-i inplace`, `-v`), consumes their values, and treats every remaining operand as a file; `--` ends option parsing. Misreading a suffix cluster as "script given" can only widen the target list, so ambiguity errs toward refusal. 17 of the 35 new tests fail against the unpatched checker, including both hole forms.
- **Shell tool: `$PWD`, the child environment's `PWD`, and command substitutions now track the segment's working directory** (`app/mcp_servers/shell_server.py`, `tests/test_shell_executor_pwd.py` (new, 13)). Three things reflected the *server process's* launch directory rather than the per-request cwd: `$PWD` expanded from `os.environ` (so `pwd` and `echo $PWD` in the same command disagreed — observed live, with `$PWD` naming an unrelated project); children inherited that stale `PWD` (visible to `printenv PWD` or `os.environ['PWD']`; bash's own startup correction masked it on the `sh -c` route); and `$(...)`/backtick substitutions ran in the *request* cwd rather than the segment's `effective_cwd`, so `cd sub && echo $(pwd)` printed the parent. `_execute_pipeline` now derives a per-segment `PWD` from `effective_cwd` for expansion, substitution, and the child env, with pipeline-local assignments and an explicit `PWD=x cmd` prefix still layered on top; `_popen_group`'s default env sets `PWD` to the child's actual cwd. 11 of the 13 new tests fail against the unpatched executor.
- **Shell executor: `cd` now follows shell semantics for conditional chains and pipelines** (`app/mcp_servers/shell_server.py`, `app/mcp_servers/write_policy.py`, `tests/test_shell_executor_cd_semantics.py` (new, 13), `tests/test_shell_cwd_aware_write_policy.py`). The in-process `cd` handler in `_execute_pipeline` ran *before* the `&&`/`||` skip check, so `false && cd DIR && cp a b` changed directory and then ran `cp` — a shell skips both; the skip now happens at the top of the loop, so a skipped segment does no work at all (including its command substitutions, which a shell also never evaluates) and `$?` keeps the predecessor's status. Separately, a `cd` that is a member of a pipeline changed the directory for later segments, while the `sh -c` route used for heredoc/compound commands ran it in a subshell with no effect — two live routes disagreed about where a following relative write would land. Pipelined `cd` now reports its status but leaves the cwd alone on both routes, and the write-policy cwd simulation drops its "either directory" hedge for that case to match. Seven of the thirteen new tests fail against the unpatched executor.
- **An Until repair loop no longer stops itself because a step honestly reported that work remains** (`app/utils/completion_check.py` (`SELF_ASSESSMENT_INSTRUCTION`), `app/agents/block_executor.py` (`_execute_until` stall breaker), `tests/test_completion_check.py`, `tests/test_until_stall_breaker.py`). GFX Stage 2 failed its build/verify/residue loop on four consecutive runs (066f898f, 2a13b4f5, 78907040, 56cc9e6b) while the ledger it was working advanced from 206 to 235 verified defects. Every one of the twelve iterations carried `self_assessment_partial`. Two causes, one shape. First, the self-assessment prompt asked the model to judge "the task's objective as stated in the instructions", and by the time it reached the model those instructions included the auto-prepended iteration context summarising the whole pipeline — so the small-tier rebuild step ran its one `npm run build` successfully, adopted "rebuild AND verify" from the prior iteration's summary, ran out of budget, and rated itself `partial` because 28 defects elsewhere still needed verification; `partial` is treated as failure by design, and `on_failure=stop` then skipped the verify and residue steps. The instruction now says to judge only the deliverables the block's own instructions ask of it, and that outstanding work described by surrounding context is not its shortfall. Second, the stall breaker counted a lone `objective_met="partial"` as a non-progressing iteration while its own comment refused to count a lone failure ("the NORMAL intermediate state of a fix-until-green loop") and required failure *and* an unchanged summary. In a repair loop `partial` is the honest step-level answer whenever work remains — the residue step is instructed to leave attempts<3 defects for the next cycle — so the only iteration in those four runs where all four steps completed was still recorded failed, and a fully successful iteration was unreachable by construction. `partial` now uses the same conjunction as `failed`: it counts only when the summary is also unchanged. `test_three_partial_reports_stop_the_loop`, which asserted the old rule against *changing* summaries, was rewritten to use identical summaries (the design it pinned was the one rejected), and a new test asserts three `partial`s with changing summaries run to `until_max`. The GFX Stage 2 card's four loop-step blocks also gained an explicit self-assessment-scope clause (text only, no scope change).
- **Late-conversation fence corruption: the model no longer sees its own rendered tool-result blocks as a format to imitate, and a fabricated tool result can no longer be laundered into an innocent code block** (`app/utils/tool_history_rewrite.py` (new), `app/server.py`, `app/text_delta_processor.py`, `tests/test_tool_history_rewrite.py` (new, 20), `tests/test_fake_tool_result_fence_gate.py` (new, 7), `tests/test_fake_tool_passthrough_glue.py` (new, 3)). Diagnosed from conversation df488630 turn 47, where two headerless five-backtick blocks reporting `bytes_written: 46520` and `17 pending` preceded the real `🔐`-headed results (`47014`, `16 pending`) — chronologically impossible for genuine output. Three defects compounded. (1) The four-backtick `tool:NAME|🔐 header|syntax` fences and `<!-- TOOL_BLOCK_START -->` JSON that the frontend persists into `message.content` are a *display* encoding, but `build_messages_for_streaming` replayed them verbatim in assistant history every turn; after 40+ tool-heavy turns the model's context held dozens of in-situ examples of "what my output looks like after a tool call", a far stronger signal than the one system-prompt line saying not to, and it began *writing* results instead of calling tools. Assistant history is now passed through `rewrite_tool_history` at the single choke point (web, CLI, delegates): each fence becomes a `‹tool_result trust=… tool=… label=…›` envelope around the **identical, untruncated body** (nothing is dropped — preservation of the model's recall of prior output was the explicit constraint), the search-tool JSON payload is unpacked to its summary and result text, and the `🔐` badge is stripped from labels because the literal `|🔐` sequence is exactly what Layer B's parrot pattern keys on. The rewrite is a pure function of each message, so prompt-cache prefixes are stable after a one-time miss; kill switch `ZIYA_DISABLE_TOOL_HISTORY_REWRITE=1`. (2) `_dispatch_fake_tool_block` routed every non-shell fake fence to passthrough, rewriting a fabricated `{'success': True, 'message': 'Patched … successfully', …}` into a plain code block — hiding it from Layer C, whose result-shape check deliberately does not look inside fences. Fake fences whose body matches the Layer C result shape (`detect_fake_tool_result`, high or medium confidence) now emit `hallucination_recovery` / `fabricated_tool_result_fence` and abort the iteration for retry, including any trailing text in the same chunk; a fake fence with a prose or code body still passes through, so the format can be quoted when discussing it. (3) The passthrough rewrite carried no trailing newline while the closer regex consumed the blank line after it, so two back-to-back passthroughs concatenated into an eight-backtick run the renderer treats as one unclosed fence. Tests: the server seam is asserted end to end through `build_messages_for_streaming`; the fence-gate and glue tests fail against the unpatched processor.
- **Cold server start no longer blocks on a full chat scan, then repeats it on the first list request and blows the client deadline** (`app/utils/startup_warmup.py` (new), `app/server.py`, `tests/test_startup_warmup.py` (new, 9)). Both summary caches (`ChatStorage.list_summaries` and `collect_global_chat_summaries`) are process-local and empty at boot; on a real workspace (1,689 encrypted chats / 459 MB across 34 projects) the first `GET /projects/{pid}/chats` had to read+decrypt+parse every file in every project and exceeded the client's 25 s `LIST_TIMEOUT_MS`, which throws (correctly — an empty list would read as "deleted elsewhere") and leaves an empty sidebar until the next poll. Worse, the lifespan had ALREADY walked every one of those files synchronously before `yield` — `chat_integrity.run_startup_check` — and discarded the parse, so a cold boot accepted no connections until one full scan finished and then paid a second on the first request. Now a daemon thread started from the lifespan warms the per-project cache by calling the real `list_summaries` on every project, warms the cross-project cache with one `collect_global_chat_summaries` pass, and only then runs the integrity check; the server accepts connections immediately. Cache entries are per-file and mtime-keyed, so a request that races the warm-up simply does the remaining work itself. Task-run reconciliation deliberately stays synchronous (clients must never see a stranded "running" row). The end-to-end test warms, then calls the real list paths with the read primitives patched to raise, asserting zero reads — with a positive control proving the same instrumentation DOES fire cold. Side effect: retention expiry now runs a few seconds after boot rather than on the first list request (same code path).
- **Shell write policy judges relative targets against the segment's actual working directory, not blindly against the project root** (`app/mcp_servers/write_policy.py`, `tests/test_shell_cwd_aware_write_policy.py` (new, 44), `Docs/MCPSecurityControls.md`). `_execute_pipeline` applies `cd` in-process, so a relative target after one resolves against the new directory — but `ShellWriteChecker.check` ignored `cd` and resolved every relative target against the project root. That failed in both directions: `cd /tmp && cp a b` (writes `/tmp/b`) was refused with `'cp b' blocked — use git diffs`, while `cd ~ && cp /tmp/payload .ziya/settings.json` was **allowed** because `.ziya/…` matched the project's safe path even though the executor wrote the *global* `~/.ziya/settings.json` (the same hole reached via `cd $HOME`). The checker now runs a static cwd simulation over the operator-split segments (`_simulate_cwds`) and validates each segment — destructive commands, in-place edits, interpreter literal writes, and redirections, the last of which moved from a whole-command pass to per-segment — against the directory it will run in. The model is a sound over-approximation: a segment reached purely via `&&` from a literal `cd` sees only the new directory; once a `||` appears, or across `;`/newlines, later segments see the union of every directory the chain has visited and the target must be permitted under all of them; `cd $VAR`, `cd -`, and a `cd` outside command position (`do cd $d`, `{ cd x`, `(cd x`) make the cwd unknown, which refuses every relative target with a message naming the segment responsible. A `cd` inside a pipeline yields *both* directories, because the seam test exposed that the two execution routes disagree: the in-process orchestrator applies it, the `sh -c` route (heredoc/compound commands) runs it in a subshell where it does not. Denials now name the resolved path (`… (resolves to /abs/path)`, `(may resolve to any of: …)`, or `(cwd after 'cd $HOME' is not statically known — use an absolute path)`) so a block can be reasoned about instead of guessed at. Direct callers of `_is_write_allowed` outside `check` keep the historical project-root resolution. 37 of the 44 new tests fail against the unpatched module, including the `~/.ziya` escape; the executor-parity tests assert that the file `_execute_pipeline` actually creates is one the validator judged.
- **The sidebar no longer floods the console with `HISTORY_CORRUPTION` for a blank chat, and a stranded active conversation is actually recovered** (`frontend/src/components/MUIChatHistory.tsx`, `frontend/src/context/ChatContext.tsx`, `frontend/src/components/__tests__/currentConversationConsistencyLog.test.ts` (new, 4), `frontend/src/components/__tests__/currentConversationConsistencyLogic.test.ts` (new, 5), `frontend/src/context/__tests__/recoveryGraceStampWiring.test.ts` (new, 3)). Two defects surfaced together. (1) ChatContext deliberately mints a bare conversation id for an empty project and only creates the Conversation object on the first message, so a selected id with no record is the designed "blank chat" state — and it is equally expected while a project-switch preload or server sync is still landing. `treeDataRaw` classified that state as corruption and `console.error`ed twice on *every* render, which in a tab with a real fault (a stuck remote streaming id gating sync) produced an unbroken stream of red that buried the actual signal. The check now distinguishes the two cases: an id that is **present but `isActive === false`** (the user is looking at a soft-deleted chat) is the genuine inconsistency and is reported as an error, once per id; an absent id is a single `console.debug`. Both memories clear when the id is consistent again so a recurrence is reported rather than swallowed. (2) The RECOVERY effect's 60 s grace stamp (`__ziyaLastConvSetAt`, "when the active conversation was last set") was written inside the ref-mirror effect whose deps include `conversations`, so every list commit — each 30 s sync, each preload — re-stamped "now" and a stranded id could sit under a grace that renewed itself indefinitely (observed: `RECOVERY_GRACE: Suppressing switch — conversation 117df9e2 set 0s ago` immediately after a conversations commit, with that id absent from the list). The stamp is now its own effect keyed on `currentConversationId` alone. Six of the twelve new assertions fail against the unpatched tree; the behavioural test models the retired shape to demonstrate the per-render error spam.
- **A conversation stopped and resent (or errored and retried) within 2 seconds no longer sticks in the "streaming" state for the life of the page** (`frontend/src/context/ChatContext.tsx`, `frontend/src/context/__tests__/removeStreamingNoTimeDedup.test.ts` (new, 4), `removeStreamingSequence.test.ts` (new, 3)). `removeStreamingConversation` kept a `removedStreamingIds` ref and early-returned if the same id had been removed within the previous 2s — a guard against "broadcast loops" that predates `projectSync` filtering a tab's own messages by sender, and which the `wasStreaming` check immediately below already made redundant. Because that ref was checked synchronously while `addStreamingConversation` writes async React state, a remove → add → remove of one conversation inside the window (Stop → resend → Stop; stream error → auth retry → fast failure; a feedback-recovery send that throws at once) swallowed the second remove: sidebar spinner pinned, `isStreamingAny` true, and — the id being locally owned — `syncWithServer` suppressed on every tick. The ref and its timer are removed; `wasStreaming` is the sole idempotency guard. The only behavioural delta is a possible duplicate `streaming-ended` broadcast when two removes land in the same tick, which receivers already no-op. The sequence test reproduces the retired guard shape and shows it leaves the id stuck; the static test fails against the unpatched file.
- **A stream owned by another tab can no longer silently freeze this tab's conversation list** (`frontend/src/context/ChatContext.tsx`, `frontend/src/context/__tests__/remoteStreamingSyncGate.test.ts` (new, 9)). `syncWithServer` bails while `streamingConversations` is non-empty, on the premise that the tab's own state is authoritative mid-turn — but the set also receives ids from *other* tabs' `streaming-chunk`/`streaming-state` broadcasts, and the only thing that removes those is a `streaming-ended` on the same BroadcastChannel. A project switch calls `projectSync.join()` and leaves that channel, so a tab opened while a sibling was mid-stream and then switched projects held the sibling's id forever; the guard was a bare `return` with no log, so every sync in every project exited silently for the life of the page, the sidebar showed only folder names, and the one conversation that did appear arrived via shared IndexedDB rather than sync. Observed live and confirmed by a fiber probe showing the set holding exactly the sibling's id. Fix: ids learned over the channel are tracked in `remoteStreamingIdsRef`; the sync guard skips only for *locally owned* streams and logs when it does; and the cross-tab effect's cleanup drops remote ids from the streaming/content/processing state directly on channel leave — deliberately not via `removeStreamingConversation`, which would re-broadcast `streaming-ended` on the channel being left and terminate the owning tab's live stream. Eight of the nine wiring assertions fail against the unpatched file.
- **Shadow sessions: a terminal attached to one conversation no longer appears in every other conversation's context as an invitation to attach** (`app/shadow/context.py`, `tests/test_shadow_context.py`). The per-turn `<AttachedShadowSessions>` tag counted every live session not bound to the current conversation as "other … shadow_list to see them" — including sessions actively bound to a *different* chat — so unrelated conversations kept asking the user whether to attach to a terminal that was already owned. Sessions attached elsewhere now produce nothing (they remain visible to an explicit `shadow_list`); only genuinely unattached sessions are counted, and the wording tells the model not to offer attachment unprompted. The earlier test that asserted the "other conversation sees a count" behaviour was updated to follow the design.
- **A re-anchored diff correction no longer leaves the retracted first attempt live** (`frontend/src/utils/diffUtils.ts`, `frontend/src/utils/__tests__/diffUtilsRedoDetection.test.ts` (new, 11 tests)). `findSupersededDiffIndices` was purely positional: an earlier same-file diff was greyed only when a later hunk overlapped its original-file line range by >50%. The most common correction an LLM makes to its own failed hunk is to re-emit the same body under a *different* `@@` header — it re-anchors after a context mismatch — so the ranges come out adjacent-but-disjoint (observed live: `@@ -296,3` then `@@ -290,6`, ranges 296–298 vs 290–295), and both diffs stayed live with the user offered a hunk the model itself had retracted. Detection now falls through to content when position says "disjoint": the sign-prefixed, trimmed change lines of each diff are compared with the overlap coefficient (|A∩B|/min|A|,|B|, so a correction that *extends* the body still scores 1.0 where Jaccard would be diluted), structural-noise lines (`}`, `);`, blank — under 4 chars or no alphanumeric) are excluded so unrelated edits cannot match on braces, and a body of ≥6 distinct substantive lines is treated as self-identifying wherever it was anchored while smaller bodies additionally require the hunks to sit within 10 original-file lines of each other, so the same one-liner added at two distant call sites remains two edits. The existing `isSequentialPair` escape (subtractive prep then additive follow-up) still applies. Also catches the sub-threshold overlap case (same body, 27% positional overlap) that the 50% rule alone rejected. The five redo tests fail against the unpatched detector; the five independence guards pass on both, so the change is a strict widening. A superseded diff is faded, not forbidden — applicable again once the message settles — so the cost asymmetry favours detection.
- **Every web-chat request was silently falling back to a 66-character system prompt** (`app/agents/prompts.py`, `app/utils/precision_prompt_system.py`, `tests/test_prompt_assembly_no_silent_fallback.py` (new, 4 tests, all four failing against the unfixed tree)). `PrecisionPromptSystem.build_messages` wraps its entire body in `except Exception` and returns `_fallback_build_messages` on any failure — a two-message prompt whose system content is the 66-char stub `"You are an excellent coder. Help the user with their coding tasks."`. Tool definitions are passed to the provider separately, so the model still called tools and streaming looked completely normal; what silently vanished was AGENTS.md, the `## Session Context` block (project root, cwd, write policy, effective shell privileges), the skills catalog, the memory section, `CurrentDateTime`/`ConversationStartTime`, and the bead directive — measured live at **66 chars instead of 31,631**. Two independent causes, both of which had to be fixed for assembly to complete: (1) the prompt template carried a raw `{"type":"chord","matrix":[[...]],"names":[...]}` JSON example for chord fences; LangChain formats the template with `str.format` semantics, so `{` opens a placeholder and every single request raised `KeyError: '"type"'` — literal braces must be doubled, which the neighbouring `{{ shapeId: ... }}` drawio example already did correctly, so the two examples sat four lines apart with only one of them escaped. (2) The outer handler did `import logging; logger = logging.getLogger(__name__)`, and because Python scopes an assignment to the whole function body that rebinding shadowed the module-level `logger` **throughout** `build_messages` — leaving all eight inner `except` handlers (`logger.debug("... unavailable")` for memory activation, trust directive, session context, skills, memory, beads, and the shadow-session tag) raising `UnboundLocalError` *while handling an error*, which promoted any recoverable sub-failure into the total fallback. The redundant rebinding is removed; the module logger at line 11 was always correct. Found by reproducing `build_messages` directly after a shadow-session context tag failed to appear in the prompt — the tag was never the problem, it was the eighth booby-trapped handler behind an already-failing template. Regression tests cover all three angles: the assembled prompt must exceed a floor and contain `## Session Context`; every `{...}` in the template must be a known placeholder or doubled; and no function in the module may both use and rebind `logger` (AST check, so reintroduction fails in CI rather than silently degrading every prompt).
- **Shadow sessions: an answered shadow-initiated ask (§8) no longer stays "PENDING QUESTION" forever** (`app/shadow/sock_server.py`, `tests/test_shadow_pending_ask_clear.py` (new, 2)). `pending_ask` was set when the human composed an ask at the terminal (`C-x C-z` → `a`) but never cleared, so `shadow_list` and the per-turn `<AttachedShadowSessions>` context tag kept reporting a question the chat had already answered via `shadow_comment`. A comment is the chat speaking to the terminal — exactly the reply the ask was waiting for — so `_op_comment` now clears the flag, persists it to the registry entry, and fires the meta-change callback so the terminal title/menu refresh; the response carries `cleared_ask`. Formal ask↔answer correlation lands with the inject loop; this is the phase-2 fix for the stale flag. The new tests fail against the unpatched op (`KeyError: 'cleared_ask'`).
- **Frontend renderer regressions across the diagram engines** (`frontend/src/plugins/d3/{graphvizPlugin,mermaidPlugin,mermaidEnhancer,plotlyPreprocessor,vegaLitePlugin,vegaPlugin,vegaTextFit}.ts`, `frontend/src/utils/{colorUtils,pluginDimensions}.ts`, `frontend/src/utils/d3Plugins/musicPlugin.ts`, `frontend/src/components/D3Renderer.tsx`, + defect-id regression tests). Layout and rendering fixes for graphviz, mermaid, plotly, vega/vega-lite and music, plus the shared plugin-dimensions / owns-spec-container work, each pinned by a render-level regression test.
- **Scroll-velocity tracking in the chat scroll manager** (`frontend/src/hooks/useScrollManager.ts`, `frontend/src/utils/scrollVelocity.ts` (new, +test), `frontend/src/components/Conversation.tsx`). Auto-scroll now tracks scroll velocity for smoother follow behaviour.
- **Task-card run bindings survive a conversation switch** (`frontend/src/hooks/useTaskBindings.ts`, `frontend/src/hooks/__tests__/useTaskBindings.chatSwitch.test.ts` (new)). Task-card run bindings are re-resolved when the active conversation changes.

### Changed
- **Task cards: `MAX_CALL_DEPTH` raised 5 → 8, and the task-card skill now teaches the flat-orchestrator pattern** (`app/agents/block_executor.py`, `app/data/built_in_skills.py`, `tests/test_block_executor_call.py` (+1)). A seven-card documentation-remediation chain authored as "each card's last block Calls the next" ran every stage's work to completion, then the sixth card's Call to the seventh was refused with `call depth limit reached (5)` — and because that failure propagates up through every parent Call under `on_failure: "stop"`, all six upstream cards reported their terminal block failed although nothing they did had. A self-chaining sequence now fits up to 9 cards. The skill's Call section gains a fourth constraint naming the cap and that failure signature, states that a sequence longer than 9 must be split, and recommends one orchestrator card whose Calls are siblings (every Call at depth 1, a failure attributed to the one step that failed, each stage still launchable alone) over cascading per-card Calls for plain sequences. New boundary test: exactly `MAX_CALL_DEPTH` nested callees succeed and one more is refused, so the constant and the skill's "9 cards" cannot drift apart silently. `app/utils/resume_targets.MAX_CALL_UNWIND` (already 8) now matches exactly.
- **Competitive-landscape study: `Docs/competitive-analysis.md` rewritten from the verified landscape report (WS5)** (`Docs/competitive-analysis.md`). The May-2026 paper-based document (preserved at `Docs/old-competitive-analysis-2026-05.md`) is replaced by a reader-facing projection of synthesis run `s1-20260910` (corpus: cells `m3-20260827`, reintegration `r1-20260901`, depth `r2-20260830`): every leadership claim carries its absent/not_applicable/unknown split and none rests mainly on not_applicable; the unfalsified claims are labelled as such; one claim killed by counterexample (`storage-surrogate-sanitization`) is reported as killed; gaps carry live-rival prevalence (Continue excluded, Cognition counted once) and critic-corrected effort classes; Critic A's count corrections (88 unique → ~15–20 differentiators, five maturity-5s → two subsystems, 24 terminology artifacts → ~18 solid + 6 partial) are stated as read-N-as-M corrections since capability ids are frozen. This file is now the only doc allowed to compare Ziya with rivals.
- **Docs: documented 42 previously undocumented shipped capabilities across 14 subsystems (competitive-landscape study WS3); see Docs/CapabilityGlossary.md**
- **Docs: refreshed ArchitectureOverview and EnvironmentVariables** (`Docs/ArchitectureOverview.md`, `Docs/EnvironmentVariables.md`). Brought the architecture overview and environment-variable reference up to date with the shipped features.

## [0.8.8.5] - 2026-09-11

### Added

- **Shadow sessions: attached terminals are now in the model's per-turn context, and the overlay is legible in ghostty** (`app/shadow/context.py` (new), `app/utils/precision_prompt_system.py`, `app/shadow/pty_host.py`, `app/shadow/client.py`, `tests/test_shadow_context.py` (new, 6)). Attaching a session bound it to a conversation but the model still had to be told, each turn, that the terminal existed. `attached_sessions_tag(conversation_id)` now renders an `<AttachedShadowSessions>` tag — id/label, segmentation, record count, last activity, a loud `PENDING QUESTION` marker when the human asked from the terminal, and a bare count of other live sessions — and it is injected onto the **current user message** via the existing `CurrentDateTime` relocation, not the system prompt: the tag changes every turn and the system-prompt session block is a cached prefix (relocating the timestamps measured 0% → 96.6% cross-conversation cache reuse; this tag would have undone that). Unattached sessions contribute only a count, never content. One `info` round-trip per attached session with a 0.3 s timeout; failures degrade to registry-only text. The `C-x C-z` menu header now shows the bound chat and `ziya shadow --list` gained a `CHAT` column. Overlay colour switched from `SGR 2` (dim) to an explicit mid-grey (`38;5;245`): ghostty fades dim text almost to the background while the ⏺ emoji ignores the attribute, which read as "a dot with nothing after it" in live testing. The security test that strips the overlay's own colour codes before asserting no ESC survives now reads the constants instead of hardcoding `\x1b[2m`.
- **Shadow sessions, attachment (observe): bind a live `ziya shadow` terminal to a chat conversation** (`app/shadow/registry.py` (new `attached` field), `app/shadow/sock_server.py` (`attach`/`detach` socket ops + `on_attach` callback), `app/shadow/pty_host.py` (`_on_attach` banner, `_title_prefix`/`refresh_title` with a `· chat <id>` window-title slot), `app/shadow/client.py` (`attach`/`detach` wrappers + `attached` in `shadow_list` rows), `app/mcp/tools/shadow_tools.py` (new `shadow_attach`/`shadow_detach` tools; `attached` surfaced in `shadow_read`), `app/mcp/builtin_tools.py` (registration), `tests/test_shadow_attach.py` (new, 5), `Docs/design/shadow-sessions.md` §9). Turns the observe-only feature from pull-blind ("call `shadow_list` and hope") into an explicit association: `shadow_attach(session)` binds the *current* conversation — the binding identity is the injected `conversation_id`, never a tool argument, so a conversation can only attach/detach on its own behalf — and the human at the terminal immediately sees a `⏺ attached to chat <id>` banner and a `· chat <id>` tag in their window title (via the always-on title rewriter's prefix slot). Observation is shareable per the design's resolved open question, so attach is last-writer-wins on the single *display* slot and journals each takeover with the conversation it replaced; both conversations can still read. Detach clears only the displayed association (reads still work) and a conversation may only detach its own binding (`not_attached` otherwise). Every attach/detach is a journaled `meta` record with provenance. Exec/control attachment modes are refused here (`unsupported attach mode`) — this phase grants no actuation. The remaining phase-2 piece is the per-turn system-context block that tells the model, unprompted, which terminals are attached; it needs `conversation_id` plumbed into `build_session_context_section` and is best verified live.

- **The model can query past conversation transcripts: `chat_search`, `chat_read`, `chat_list`** (`app/mcp/tools/chat_history_tools.py` (new), `app/mcp/builtin_tools.py` (`chat_history` category, on by default), `tests/test_chat_history_tools.py` (new, 35), `Docs/FeatureInventory.md`). The only cross-session recall available to the model was `memory_search` over distilled facts; the transcripts themselves — ~1,650 chats on a long-lived install — were reachable only by the sidebar search box through `GET .../chats/search`. Heritage questions ("which conversation produced this test?", "what did the user actually ask for before this file existed?") could not be answered from the primary source. `chat_search` wraps `app.storage.chat_search.search_chats`, so ranking, ALE decryption and the inactive-chat exclusion are identical to the UI, and adds a `side` filter (`user` / `assistant` / `both`; a title-only hit is dropped under a side filter because it says nothing about who spoke), project-or-all scope, and exclusion of the calling conversation by default. `chat_read` selects by **turn** — 1-based, a user message plus the assistant/system messages that follow it, negative counting from the end — by 0-based message index (the `messageIndex` values `chat_search` reports, so a hit is handed straight across), or `around` an index with a radius; `side='user'` returns only the prompting, which is how to find where a line of work began. Per-message and total character caps with `next_message_index` for paging; muted messages are included and flagged (or excluded on request); multimodal content is reduced to its text blocks with an image count. The header returns the files the conversation carried (`contextIds`, `additionalFiles`) and its fork lineage (`branchedFrom`, `branchedAtMessageIndex`, `lineageRootId`) — the heritage hooks. Records are located through the cross-project `chat_index`, with one rescan on a miss so a chat written by another process since the index was built is still found. `chat_list` returns id, title, message count, timestamps, folder and lineage, newest first, with `since_days` and `title_contains` filters. All three are read-only and deliberately absent from the high-trust tool set, so their results wrap at `trust="medium"`: past model output and past tool results are data, not instructions. One seam hazard shaped the API: `app/tool_execution.py` overwrites `args['conversation_id']` with the *calling* conversation's id before any builtin runs, so a `chat_read(conversation_id=…)` would silently have read the current chat every time — the parameter is `chat_id`, a test injects the foreign key and asserts the requested chat wins, and a schema test guards the name. Not yet (tracked): decorating chat records with the files a conversation wrote or patched, and having the memory sweep attach a summary to each record, both to make search more specific.

- **`--endpoint local`: local models (Ollama, LM Studio, llama.cpp) are a first-class endpoint, phase 0** (`app/utils/local_models.py` (new), `app/config/models_config.py` (`DEFAULT_MODELS`, `DEFAULT_SERVICE_MODELS`, `MODEL_FAMILIES["local"]`, `ENDPOINT_DEFAULTS["local"]`, `MODEL_CONFIGS["local"]`), `app/utils/provider_detection.py` (registry entry), `app/providers/factory.py`, `app/agents/models.py` (`_initialize_local_model`), `app/services/model_resolver.py`, `app/config/common_args.py`, `app/config/env_registry.py`, `app/config/pricing.py`, `app/services/pdf_exporter.py`, `app/providers/openai_direct.py` (`request_extra_body` merge), `app/agents/wrappers/openai_direct.py` (`extra_body` ctor arg), `tests/test_local_provider_routing.py` (new, 44), `Docs/NewUser.md`, `Docs/EnvironmentVariables.md`, `Docs/ArchitectureOverview.md`, `Docs/CostAccounting.md`, `design/local-models.md` (new)). The README has said "local models via Ollama" while the code offered only the `openai` endpoint with `OPENAI_BASE_URL` — which worked on the wire but lied everywhere else: the picker showed the OpenAI catalog, every local model needed a hand-written `~/.ziya/models.json` entry under `"openai"`, and the pricing catalog and PDF exporter both named an `ollama` provider that no endpoint ever reported, so local usage priced as *unknown* rather than $0. Local servers all speak OpenAI chat completions, so `local` reuses `OpenAIDirectProvider` / `DirectOpenAIModel` exactly as `zai` and `meta` do, pointed at `ZIYA_LOCAL_MODEL_URL` (default Ollama's `http://localhost:11434`; the `/v1` path is derived so a URL given either way works) with the placeholder key the SDK insists on. One model entry is synthesized at import from `ZIYA_LOCAL_MODEL` (default `qwen2.5-coder:7b`, tool-capable) with no network access, and then **the server is asked what the model actually supports** before the first request: `discover_and_apply()` (called from `ModelManager.initialize_model`, `create_provider` and the service-model resolver, memoised per process) reads the context length and capability list from Ollama `POST /api/show` or LM Studio `GET /api/v0/models/{name}`, writes `token_limit` / `native_function_calling` / `supports_vision` / `supports_thinking` onto the entry, and on Ollama sets a new `request_extra_body` model-config key — `{"options": {"num_ctx": <ctx>}}` — which `OpenAIDirectProvider._build_request` and `DirectOpenAIModel` merge into every request through the SDK's `extra_body`. This matters because Ollama sizes its default `num_ctx` by VRAM: 4k tokens on any machine under 24 GiB, i.e. every MacBook, regardless of the model's window, and Ziya's builtin tool schemas alone are ~4k tokens — so without it a session is over budget before the first message and the overflow is silent truncation. The budget Ziya counts against and the KV cache the server allocates are now the same number, the model's full window, with nothing to configure; `ZIYA_LOCAL_TOKEN_LIMIT` is an optional ceiling (unset by default) for machines where the full window does not fit in memory, and never raises the limit above the server's answer. A window under 16k, or a model the server reports as lacking tool calling, is logged as a warning rather than guessed around. LM Studio fixes context at load time, so it gets the budget but no `options` field; a generic `/v1`-only server (llama-server, vLLM) gets a 32k placeholder and a log line naming the override. `~/.ziya/models.json` `"local"` entries still merge on top at import. `--endpoint local` needs no variable set; `ZIYA_LOCAL_MODEL_URL` doubles as the "credential" so setup help, `/api/endpoints` availability and first-run auto-select all cover the endpoint, and a machine with only a local server lands on it. Service-model calls (memory extraction, intent judge) route to the same server since a local install has one model. The `ollama` pricing rule and display name are renamed to `local` so zero-cost accounting actually applies. **One endpoint per running server** (2026-09-09; `scan_local_servers` / `register_local_endpoints` / `resolve_local_alias` / `is_local_endpoint` in `local_models.py`, `app/utils/provider_detection.py`, `app/plugins/{__init__,interfaces}.py`, `frontend/src/components/ModelConfigModal.tsx` (`label`), `tests/test_local_catalog_sync.py` (rewritten against fake ds4 + Ollama servers, 27), `tests/test_provider_credential_registry.py`, `tests/test_chat_endpoint_routing.py`). The first live run found Ollama on `:11434` AND DwarfStar `ds4-server` on `:8000` on the same Mac; a scan that takes the first answer had to guess, and guessed Ollama. Every answering server — the well-known loopback ports plus `ZIYA_LOCAL_MODEL_URL` — is now registered at startup as its own endpoint, `local-<runtime>` (`local-dwarfstar`, `local-ollama`; port-suffixed on collision), with that server's `GET /v1/models` as its `MODEL_CONFIGS` table, its own `ENDPOINT_DEFAULTS` entry carrying `base_url`/`runtime`/`label`, and a label the picker renders (*Local · DwarfStar (:8000)*). Because `/api/set-model` locates a model by searching every endpoint's table, picking a model picks its server with no further plumbing. Runtime identification: Ollama `/api/tags`, LM Studio `/api/v0/models`, then `owned_by` (`ds4.c`, `library` observed live; `vllm`, `llamacpp`, `lmstudio`, `sglang`, `localai` documented, unverified), else a port-labelled generic endpoint. DwarfStar's `/v1/models` entry carries the served `--ctx` as `context_length` (verified: a 300k launch reports 300000), `max_completion_tokens`, and `supported_parameters` naming `tools` and `reasoning_effort`, so its `token_limit`, `native_function_calling`, `supports_reasoning_effort` and thinking flags come from that one call; its `deepseek-v4-pro` compatibility alias (same `name`, same loaded GGUF) is collapsed so the picker shows one model. The nine `== "local"` checks became `is_local_endpoint()`; the base URL comes from the endpoint, not a global. Reachability is the credential: `detect_available_providers` marks each scanned server available and the `local` alias available when any exists, so first-run auto-select lands on a machine with only a local server; `ZIYA_LOCAL_MODEL_URL` now only *adds* a server the scan cannot see. The enterprise loopback exemption is per endpoint — a LAN server added by URL stays subject to the allowlist. Endpoints this module registered whose server has gone are removed on the next sync, except the one in use. Not yet (design doc phases 1b–1d, 2–4): large enterprise MCP servers off and a trimmed builtin tool set for local endpoints (live measurement: `tools: 122,370` tokens, over ds4's `--ctx 100000` before the first message), `reasoning_content` replay on assistant history for ds4/GLM prefix caching, service-model routing on single-slot servers, LM Studio loaded/not-loaded state, and degraded-mode messaging for models without tool calling.

- **`local` is exempt from the enterprise endpoint allowlist while its server is on loopback** (`app/plugins/__init__.py` (`get_allowed_endpoints` adds `local`; `_loopback_local_exempt`), `app/plugins/interfaces.py` (`ConfigProvider.allows_loopback_local`, default True), `app/utils/local_models.py` (`is_loopback_url`, `local_server_is_loopback`), `app/main.py` (refusal message names the URL), `tests/test_local_endpoint_policy_exemption.py` (new, 24), `Docs/Enterprise.md`). A Bedrock-only enterprise policy refused `--endpoint local` with "restricted by your enterprise policy", but the allowlist exists to control where source code is sent, and a server on `localhost` / `127.0.0.0/8` / `::1` sends it nowhere — so the refusal protected nothing. The exemption is deliberately narrower than "local is never restricted": `ZIYA_LOCAL_MODEL_URL` accepts any host, and an unconditional exemption would have turned `--endpoint local` plus a remote URL into a bypass of every provider the policy excluded. Instead the single resolver every consumer uses (startup gate, `/api/endpoints`, `set-model`, `--help`, setup help, auto-select) adds `local` to a restricted list only when the configured URL's host is loopback, an unparseable or hostless URL failing closed; a LAN server stays restricted unless the policy names `local` explicitly. An enterprise that wants to forbid unvetted weights and not just unvetted destinations overrides `allows_loopback_local()` to return False — any active provider returning False wins, and a hook that raises is treated as False. Community builds (no config provider) are unchanged: `None` in, `None` out.

- **The submitted-context debug panel can now retain the full request, exactly as sent** (`app/utils/context_debug.py` (`capture_payload`, `list_payload_frames`, `get_payload_frame`, `is/set_payload_enabled`), `app/streaming_tool_executor.py` (capture at dispatch), `app/api/chats.py` (`/context-debug/payload`), `tests/unit/test_context_debug_requests.py` (new, 26), `Docs/EnvironmentVariables.md`). The panel could only ever say how *big* a request was — `system_chars` and `conversation_chars` were `len()` calls whose operand was discarded — so a turn that behaved inexplicably could be measured but not read. A third, separately-gated capture tier now writes the **pre-serialisation** `system_content` and `conversation` structures handed to the provider, verbatim, one frame per iteration, together with the model id and the names of the tools that were offered. Frames are written at *dispatch* rather than on the provider's usage event, so a request rejected for input size — which never produces a usage event at all — still leaves its exact payload on disk. Retention is deliberately a short rolling window and not an archive: the most recent 3 rounds per conversation, the most recent 4 conversations, and a total-bytes backstop, except that a round which recorded a failure is exempt from the window because it is the one whose bytes are worth keeping. Values that are not JSON-serialisable are coerced with `repr` rather than dropped, so a frame is never lost to an exotic object. Off by default (`ZIYA_CONTEXT_PAYLOAD_DEBUG`, or the panel toggle) because each frame is megabytes; frames hold the entire submitted context including source, so they are written `0600` inside a `0700` directory under the Ziya home.

- **Cost accounting, phase 1: usage ledger, list-price catalog, rate-plan seam, rollup API** (`app/config/pricing.py` (new), `app/cost/{meter,pricer,rate_plans}.py` (new), `app/storage/usage_ledger.py` (new), `app/routes/usage_routes.py` (new), `app/streaming_tool_executor.py` (two-line hook per iteration; `self.region` retained), `app/context.py` (`set/get/reset_usage_attribution`), `app/agents/task_executor.py`, `app/cli.py`, `app/plugins/__init__.py` + `interfaces.py` (`RatePlanProvider`, `register_rate_plan_provider`), `app/server.py`, `tests/backend/test_cost_model.py` (new, 17), `Docs/CostAccounting.md` (new), `Docs/UserConfigurationFiles.md`, `design/CostModel.md`). Ziya counted tokens but had no notion of what they cost, and the only record of a call's usage was an in-memory ring buffer that evaporated on restart. Every provider call — chat, Task Card, delegate, CLI — now writes one row to `~/.ziya/usage.db` (SQLite, WAL, global because the rollups cross projects) keyed by billing endpoint + **resolved** model id + region, with service tier, user / project / conversation / run / block attribution and a `source`. The row is written at dispatch as an `estimate` (chars/4 pre-flight; renders *italic*) and actualized in place when the provider's usage block arrives (renders **bold**); a throttled or aborted call therefore leaves an italic trace instead of vanishing. Tokens are the durable fact and cost is derived at read time: `price_record` looks up the catalog entry **in force at the row's timestamp** (entries are dated, so a revised card reprices history correctly and a corrected entry heals it), applies the active rate plan, the tier factor and any long-context step-up, and stamps the figure with the catalog version and plan id. Nothing is ever guessed: no entry → `unknown`/null, never `$0`; a dimension with tokens but no rate makes the line `is_partial` with the dimension named; a tier with no factor is unknown, not standard; `reasoning` is not billed again on top of `output`. Built-in public list prices ship for the Claude 3.x–4.5 family (Anthropic and Bedrock), Nova, DeepSeek R1, gpt-oss, Llama, Qwen3-Coder, GPT-4o/4.1/5 and o-series, Gemini 2.x/3 Pro, GLM-4.5/4.6 and Ollama at zero; the newer generations Ziya lists are deliberately unpriced (see the doc) rather than invented. `~/.ziya/pricing.json` overlays entries (win over built-ins; re-read on mtime change) and selects a plan — `public`, `zero`, `override` (absolute rates), or `discount_table` (`list × (1 − d)` with fallback rules; cache and batch inherit the model's discount) — and a closed plugin can register a `RatePlanProvider` to supply a proprietary table without publishing it. `GET /api/usage/{summary,rollup,records,catalog}` expose it; rollups group by user / project / conversation / run / model / provider / tier / source / day / month / status with estimate/actual splits, provenance counts and unpriced dimensions. Not yet: UI surfaces, per-turn cost in the stream, memory-extraction calls. The dispatch hook reads `endpoint` / `model_id` / `region` with `getattr` defaults: the executor is also built via `__new__` (the stream-loop test harnesses, and callers that set `model_id` directly), and a bare attribute read there raised before the first text delta, which surfaced as eight "no text events" failures across `test_stream_loop_recovery_seam`, `test_stream_text_delta_wiring` and `test_repeat_block_recovery`; the meter already coerces a missing provider to `unknown`.

- **Task Card runs request Bedrock's discounted Flex service tier** (`app/utils/service_tier.py` (new), `app/context.py` (`set/get/reset_task_service_tier`), `app/agents/block_executor.py` (`execute_block` sets the tier once at the run root; body moved to `_execute_block_dispatch`), `app/providers/bedrock.py`, `app/providers/nova_bedrock.py`, `app/providers/openai_bedrock.py`, `app/services/model_resolver.py` (`_call_bedrock`), `tests/test_task_service_tier.py` (new, 40), `Docs/EnvironmentVariables.md`). Bedrock bills on-demand inference in tiers — `priority` (+75%), `default`, `flex` (−50%, may queue, same quota) — and a task run is the batch-like workload Flex is priced for, while interactive chat is not. Every Bedrock call made inside a run (API launch, scheduler fire, CLI runner, `Call`ed cards, the Until/Improve evaluators) now carries `serviceTier` — the `X-Amzn-Bedrock-Service-Tier` header on `invoke_model*`, the `{"type": ...}` structure on `converse*`; both shapes verified against botocore 1.42.81 and live endpoints. Support is per model and AWS changes it without notice, so instead of a support matrix the tier is sent optimistically: the specific `ValidationException: The provided service tier is not supported for this model` triggers one retry at standard and a process-lifetime per-model negative cache, and every other `ValidationException` surfaces exactly as before. The resolved tier from the response is logged so a silently ignored request cannot masquerade as a discount. Probed 2026-09-04: Flex is live for gpt-oss, DeepSeek v3.2, Qwen3 and MiniMax on `bedrock-runtime`; Claude, Nova and Llama reject it (they pay one probe round-trip per process, then run at standard); `bedrock-mantle` (Fable 5, GPT-5.6) accepts only `default`/`auto` on both wire formats and silently ignores the header, so the mantle providers are deliberately untouched. `ZIYA_TASK_SERVICE_TIER=default|off` disables; `priority` selects the premium tier. A card can also pin one block's subtree with `scope.service_tier` (`flex` | `default` | `priority`; `app/models/task_card.py` `TaskScope` + `merge_scopes`, `app/agents/task_executor.py`, `frontend/src/types/task_card.ts`, +6 tests) — most-specific value wins, like `model_tier` — so a gating Task the rest of the run waits on can run at `default` while its siblings stay on Flex, or a bulk Task can opt back into Flex when the run has it disabled. `default` is sent as no tier, which is how the providers already express standard billing.

- **Task deck: collapsible block outline beside a single-block editor** (`frontend/src/components/TaskCard/BlockOutline.tsx` (new), `runMapModel.ts` (`foldRows`, `firstSelectableRow`, `STATUS_GLYPHS` moved here from `TaskRunMap`), `TaskCardEditor.tsx` (`layout="outline"`), `TaskCardsLibrary.tsx`, `task-card-editor.css`, tests: `__tests__/foldRows.test.ts` (new), `__tests__/blockOutline.test.tsx` (new), `__tests__/outlineLayoutWiring.test.ts` (new)). The deck previously rendered every block's editor nested and fully expanded, so on a deep card the tree and the text competed for the same height and both lost. The deck now shows the card as a foldable spine — grown from `TaskRunMap`'s row model, so it is the same `flattenBlocks` rows the run map draws — with one selected block's editor beside it. Fold carets and the status-glyph column are rendered on every row in both `edit` and `run` mode at fixed width (the "no-reflow" rule: folding, selecting, or switching mode changes what a row says, never where its label sits), tested structurally by mount. Edits route through the shared `updateBlockById`/`removeBlockById`, the drag provider still wraps the pane so a container's children reorder as before, and an unsaved block (id `''`) is visible but not selectable rather than ambiguous with the root group. The chat proposal panel keeps the expanded tree, which suits a card read once. Run mode is implemented and tested but not yet mounted; the deck's attempt selection wires it next. First step of the collapsible-deck design (outline spine + tabbed pane + receipt tile + Runs board + graph overview mode).

- **New `[DIRECT]` builtin `task_card_stage`: the in-list competitor to external "delegate / orchestrate sub-tasks" tools** (`app/mcp/tools/task_card_stage.py` (new), `app/mcp/builtin_tools.py`, `app/extensions/prompt_extensions/mcp_prompt_extensions.py`, `app/data/built_in_skills.py`, `frontend/src/apis/chatApi.ts`, `tests/test_task_card_stage_tool.py` (new, 10 tests), `frontend/src/apis/__tests__/taskCardStageSync.test.ts` (new)). The precedence fix above still left a structural asymmetry: an external MCP server can expose a *tool* whose description matches "run these in parallel" head-on, while Ziya's equivalent was a *skill* two hops away (`get_skill_details("task_cards")`, then a fenced `task-card` block) — and a direct tool wins that contest nearly every time. `task_card_stage` puts the mechanism in the tool list on equal footing. Called with no arguments it returns the `task_cards` grammar (the "execution loads the skill" step); called with `root` + `name` it runs the launch-time validator, saves the card to the deck (`source="agent"`) and **stages** it in the current conversation as an inline tile with a Run button — exactly the `/goal` slash-command path (`run_id=None`). It deliberately never launches: launch is where the user confirms spend and where escalation signatures are collected, and a model-initiated launch would bypass both; the tool's response tells the model so, and the skill prompt now says "Never state that the card is running; you staged it." Structural errors are returned with paths and nothing is written. `chatApi.ts` fires `task-binding-created` on the tool's result so `useTaskBindings` picks the new tile up mid-turn without a reload (a jest source-contract test asserts the dispatched event name is the one the hook actually listens for). Usage rule 4 now names the tool directly, and the `task_cards` skill prompt's "use `delegate-tasks` instead for role fan-outs" pointer is replaced with the Group-root-with-Parallel shape. Tests cover grammar mode, save+stage with `run_id=None`, invalid-root-nothing-saved, missing name, no-conversation (deck-only), no-project, first-position registration in the category, `[DIRECT]` surfacing through `create_secure_mcp_tools`, and the prompt/skill text.

- **New "Submitted context (debug)" panel in Conversation Info, backed by a new per-iteration token-usage recorder** (`app/utils/context_debug.py`, `app/api/chats.py` new `GET .../chats/{chat_id}/context-debug`, `frontend/src/components/ConversationInfoModal.tsx`, `tests/test_context_debug.py`). Root-caused a case where a conversation's token badge showed under 100k tokens while the provider rejected the request for exceeding 1M: the badge (and the backend's own `_track_estimation_accuracy`) only ever measure a turn's *starting* state — the persisted chat history plus checked files — because that's the only state that exists before any tool call runs. An agentic turn's `conversation` list can grow far past that across many tool-calling iterations (tool_use/tool_result accumulation), and none of that growth was ever persisted or surfaced anywhere once the turn ended, successfully or not. `context_debug.record_iteration()` is now called every iteration (not just iteration 0) from `_handle_usage_event` with the provider's own actual reported token usage, kept in a per-conversation ring buffer. Capture is two-tier so diagnostics can never slow a turn: the in-memory record of the provider-reported numbers is always on (they are already in hand from the usage event — appending them costs microseconds), while the genuinely-costly parts — per-iteration payload char counts (an O(total-chars) scan of the submitted conversation, megabytes at the sizes being debugged) and per-iteration mirroring to `.ziya/debug/context_snapshots/{conversation_id}.json` (which is what lets the history survive the crash/restart you most want to inspect) — run only when "Detailed capture" is enabled from the debug panel (new `POST /api/v1/context-debug/config`, persisted across restarts in `.ziya/debug/context_debug_config.json`) or forced via `ZIYA_CONTEXT_DEBUG=1`. Enabling mid-conversation persists the full in-memory ring buffer on the next iteration, so the growth curve recorded before the toggle isn't lost. The new endpoint and modal panel expose this as a table (per-iteration fresh/cached/total input tokens, % of the model's effective limit) so a "why did this fail when the badge looked fine" investigation no longer requires reading server logs.

- **Mid-turn token-budget warning, injected into the conversation the same way the existing iteration-count warning is** (`app/streaming_tool_executor.py`, `_handle_usage_event` + main loop). Previously the only signal a runaway turn was near the model's input ceiling was a `logger.warning` gated behind `cached > 0` — invisible to the user and to the model itself. Now, whenever actual total input tokens cross 75%/90%/95% of the model's effective limit (tracked in `throttle_state`, tier-gated so each fires once per turn), a notice is staged and injected into the conversation on the next iteration exactly like the existing iteration-budget notice, telling the model to wrap up before the next call risks a hard "prompt is too long" failure. This is a distinct and more direct signal than the iteration-count warning: a single large tool result can jump total input from 50k to 900k tokens without consuming many iterations at all.

- **Aggregate token budget for auto-added diff-referenced files, on top of the existing per-file cap** (`frontend/src/utils/autoAddTokenLimit.ts` new `filterByAggregateAutoAddBudget`/`DEFAULT_AUTO_ADD_AGGREGATE_BUDGET`, `frontend/src/context/FolderContext.tsx`, `app/models/project.py` new `ContextManagementSettings.auto_add_aggregate_budget`, `frontend/src/components/ProjectManagerModal.tsx`, `frontend/src/utils/__tests__/autoAddAggregateBudget.test.ts`). The per-file limit (`auto_add_token_limit`) only ever stopped one huge file from being auto-added in a single call; it did nothing to stop many individually-small files accumulating without bound across a long working session. `addFilesToContext` now also tracks the running total already spent by prior auto-adds (from `autoAddedFiles` heritage) and stops accepting new auto-adds once the aggregate budget (default 100,000 tokens, configurable per-project) is spent — manual selections remain unaffected.

- **The browser tab label is now "Ziya - AI Workbench" (was "Ziya - Code Assistant")** (`frontend/public/index.html`, `app/services/pdf_exporter.py`, `app/server.py`, `tests/export_fidelity/checks.py`, `tests/export_fidelity/test_pdf_metadata_stamping.py`, `tests/export_fidelity/test_checks_can_fail.py`). The rename is not cosmetic-only: Chromium's `page.pdf()` copies the app shell's `<title>` into the PDF `/Title`, and the exporter decides "this is the shell title, do not trust it" by matching against `_CHROMIUM_DEFAULT_TITLES`. Both that set (in the driver) and the audit check's own copy (in `checks.py`) now list the new label **alongside** the old one, so PDFs captured by older builds are still recognised. A new seam test reads the actual `<title>` out of `frontend/public/index.html` and asserts it is present in *both* sentinel sets and still refused as a conversation title — a future tab rename that forgets the sentinels fails loudly instead of silently shipping the shell title as every export's document title. The FastAPI OpenAPI description was updated to match. Note: the served page is the generated `templates/index.html`, so the tab label only changes after a frontend rebuild.

- **Claude Fable 5.1 is integrated on the anthropic-direct endpoint as the new `frontier` tier** (`app/config/models_config.py`, `Docs/Capabilities.md`, `tests/test_fable_5_1_integration.py` (new)). Adds `claude-fable-5-1` (`model_id: claude-fable-5-1`), which takes over the `frontier` tier from `claude-fable-5`; the fable-5 entry is retained (still selectable by explicit name) but drops its `tier` tag so tier resolution lands on 5.1. The entry mirrors the fable-5 spec exactly apart from the model id. Naming and capabilities were live-verified on 2026-09-01: `GET /v1/models` listed `claude-fable-5-1`, a retrieve returned display_name "Claude Fable 5.1" with max_input 1M / max_output 128k / efforts low..max / adaptive thinking / vision, and a smoke invocation returned `end_turn`. **Bedrock Mantle does not serve 5.1**: `GET /v1/models` (profile `kuiper-ziya`) on us-east-1/us-east-2/us-west-2 lists no 5.1 variant, and every name variant probed against mantle's `/v1/messages` — bare, dotted, and both CRIS prefixes — returns `not_found_error`. Mantle accepts only `anthropic.claude-fable-5`. The bedrock `fable` alias and `frontier` tier therefore stay on `fable5`. Note this is a statement about *mantle*, not about Bedrock as a whole: the follow-up entry below adds a `fable5.1` bedrock entry routed via classic `bedrock-runtime`, where the model is in fact available. 8 tests, confirmed red where they should be against the pre-change config (6 failed / 2 passed — the 2 passing being the bedrock tripwire and the no-new-validation-issues check), covering presence + correct id, single-frontier-holder invariant, tier resolution, capability parity with fable 5, the live-verified capability values, and that bedrock is unchanged.

- **Claude Fable 5.1 is now reachable on Bedrock too, via classic `bedrock-runtime`, and the data-retention gate it depends on is no longer a single hardcoded mode** (`app/config/models_config.py` new `get_required_retention_mode()` + `required_data_retention_mode` key + `fable5.1` entry, `app/utils/aws_utils.py` new `retention_mode_rank()`/`retention_mode_satisfies()` + `allow_downgrade` parameter, `app/main.py`, `app/providers/bedrock.py`, `Docs/EnvironmentVariables.md`, `tests/test_retention_mode_ordering.py` (new), `tests/test_retention_mode_declaration.py` (new)). Fable 5.1 is served by `bedrock-runtime`, not by the mantle gateway `fable5` uses — probing distinguished the two conclusively: mantle 404s every 5.1 name variant, while on `bedrock-runtime` the `us.`/`global.` CRIS profiles return a retention `ValidationException` rather than the `"The provided model identifier is invalid"` that a genuinely absent id produces (verified against control ids `claude-fable-9-9` and `claude-bogus-x`, and against `us.anthropic.claude-sonnet-5` returning 200). The model is therefore available and gated solely on the **classic** account retention switch, which is independent of the mantle switch and read `inherit` while mantle read `provider_data_share` — exactly why fable5 worked and 5.1 did not. Retention is an **ordered scale** (`none < default < aws_review < provider_data_share`), so a model states a minimum and any more permissive account setting satisfies it; Fable 5's live model card confirms this, reporting `allowed_modes: ["aws_review", "provider_data_share"]`. `_ensure_data_retention` previously compared modes with `==`, so a model requiring `aws_review` against an account already at the legacy `provider_data_share` would have issued a PUT **downgrading an account-wide switch** and broken Fable 5 for every other user of the account; it now skips the write when the current mode already suffices, and only forces an exact value under the new explicit `allow_downgrade=True`. `requires_provider_data_share` is retained as a legacy spelling that maps to `provider_data_share` (preserving the three gpt-5.6 entries and fable5 byte-for-byte) rather than being converted, so there is one read path and no missed call site. Relatedly, `main.py`'s reset of the classic switch to `inherit` — which ran for any model that declared no requirement — is now opt-in behind `ZIYA_RESET_BEDROCK_RETENTION=1`: AWS documents that a permissive account mode does **not** retain content for a model whose own `allowed_modes` include `none`, so the reset protected nothing while actively stranding an in-flight Fable 5.1 session the moment anyone started Ziya on a non-Covered model (the mantle branch already declined to downgrade for this reason, so the two branches are now consistent). The `fable5.1` entry is deliberately **untiered** and the `fable` alias unmoved: 5.1 depends on a switch an organisation's SCP can forbid outright, whereas fable5-on-mantle works under the switch Ziya already manages, so moving `frontier` would break tier resolution on locked-down accounts. `available_regions` is limited to the two regions actually invoke-probed (us-east-1, us-west-2) rather than the four the control plane lists, since a control-plane listing is not proof of invocability — the same trap documented on the `fable5` entry. Under `aws_review`, AWS retains prompts/completions for up to 30 days for human review **within** the AWS boundary; content is not shared with the model provider, and models permitting `none` are unaffected regardless of the account setting. 40 tests: 27 on the ordering primitives (including a negative control that goes red if `satisfies()` regresses to equality, and confirmation that the `inherit → aws_review` raise does fire) and 13 on the declaration layer and its wiring — all 5 wiring assertions were confirmed to fail against the unpatched `app/main.py` at HEAD, which is what distinguishes them from an accessor that is correct but never called. Verified end-to-end on 2026-09-03 against a test account: raising us-east-1 from `inherit` to `aws_review` through Ziya's own `ensure_bedrock_data_retention_mode` (not a hand-rolled request) made `global.`/`us.anthropic.claude-fable-5-1` return `stop_reason: end_turn` with real generated text, a second identical call issued no PUT at all (the ordering check short-circuits it), and the switch was unchanged after four subsequent non-Covered invocations. The regression subjects were deliberately plain `bedrock-runtime` models — sonnet5, opus4.8, haiku-4.5 and opus5, all of which invoked normally under `aws_review` and all of which declare `allowed_modes` including `none`, so their content is not retained regardless of the account setting. `fable5` is *not* a valid control here: it routes via bedrock-mantle under a separate switch, so it is unaffected by the classic switch no matter what happens to it, and testing it proves nothing. The switch is per-region — us-west-2 remains at `inherit` and a colleague launching there raises their own region's switch on first start.

- **Task cards now carry a monotonic `version`, so post-edit drift between a running/finished run and the live deck card is legible** (`app/models/task_card.py`, `app/storage/task_cards.py`, `app/api/task_cards.py`, `app/api/task_runs.py`, `frontend/src/types/task_card.ts`, `tests/test_task_card_versioning.py` (new)). `TaskCardStorage.update` bumps `version` only when the block tree or scope actually changes — a metadata-only edit (name/description/tags/is_template) and run bookkeeping (`record_run`) leave it untouched, so a scope change that invalidates signing is always a version increment while a rename never re-triggers a re-sign. The version is stamped into `TaskRun.card_snapshot` at launch; a resume carries the *source run's* recorded version forward (via `resume_card_version`) rather than the live card's, since a resumed run executes the source's snapshot tree, not the current definition. Cards written before this field load as `v1`, and a duplicated card starts fresh at `v1`. This resolves the ambiguity that editing a deck card silently mutated nothing already launched (runs execute an immutable snapshot) yet silently affected idle chat-attached bindings and every future launch — the binding being a pointer by id, not a copy. 10 tests, all confirmed red against the unpatched tree. See `design/task-card-versioning-and-resign.md`.

### Fixed

- **A run whose Ask went unanswered across a server restart now reads "Waiting on you", not "Held — fix & resume"** (`app/api/task_bindings.py`, `app/utils/run_status_index.py` (`open_ask_block`, `display_status`), `frontend/src/types/task_binding.ts`, `frontend/src/types/task_run.ts`, `frontend/src/components/TaskCard/runStatusVocabulary.ts` (`displayStatus`), `frontend/src/components/TaskCard/TaskCardInlineTile.tsx`, `tests/test_api_task_bindings.py`, `tests/test_run_status_index.py`, `frontend/src/components/TaskCard/__tests__/needsHumanIndicator.test.tsx`). `reconcile_stale_runs` flips an unanswered `awaiting_input` run to `held` while keeping `pending_ask` on the record, so the sidebar showed the violet infrastructure chip and the tile offered no answer panel — the user was told to fix the environment when the run needed an answer. Bindings now carry `has_open_ask` (pending_ask set AND no entry in `ask_answers` for its block), the status index and the client's bindings path both display such a run as `awaiting_input`, and the tile mounts `AskAnswerPanel` for `held` as well as `awaiting_input`, gated on the Ask still being open so an already-answered held run is not re-asked. Also adds `awaiting_input` to `LIVE_RUN_STATUSES` — the partition test written with those sets three weeks earlier caught its omission on first run.

- **`reasoning_additive` pricing entries billed reasoning tokens as unpriced** (`app/cost/pricer.py`, `tests/backend/test_cost_model.py`). An entry opting into additive reasoning with only an `output` rate had its reasoning tokens land in `unpriced_dims`, so the opt-in was a silent no-op; reasoning now falls back to the output rate (after any long-context step-up) unless a distinct `reasoning` rate is listed. The cost-model tests also never ran clean: their `isolated_home` fixture collided with the suite-wide `_isolate_ziya_home` sandbox (`FileExistsError` on 17 tests), and their baseline record used 1M input tokens — above the Anthropic card's 200K long-context threshold the same file tests — so the `$3/$15` arithmetic they asserted was never reachable. Baseline is now 100K/10K.

- **Test-suite hygiene from a full regression sweep** (`tests/`): `TERMINAL_RUN_STATUSES`, the git write tier and `has_open_ask` had complete implementations sitting unapplied in earlier conversations while their tests and changelog entries were already in-tree — all landed. Live provider-API suites (`test_all_models`, `test_model_api_calls`, `test_tool_token_overhead`) are now marked `integration` so the default run deselects them instead of failing on missing credentials. Stale-after-refactor tests updated to follow the code: `get_cached_folder_structure` moved to `folder_service` (and is async by default, so tests pass `synchronous=True`), `_should_continue_or_end_stream` was replaced by `_decide_no_tool_outcome` (its 9 obsolete tests removed in favour of the existing golden-master suite), `LatexRenderer._compile` gained `min_passes`, and `get_accurate_token_count` excludes files over its 50k-token cap. The `app.agents.nova_wrapper` module was removed as dead code in an earlier refactor but ~40 tests across 16 files still imported it (function-local, so they failed at runtime rather than collection); the 13 wholly-Nova files were deleted and the Nova-only classes excised from three mixed files (`test_llm_interaction_regression*.py`, `test_ziya_string_integration.py`), leaving those files' ZiyaString/error-handling coverage intact.

- **The bead indicator in the chat list and the one in the conversation header disagreed** (`app/storage/beads.py` (`_OPEN_BEAD_STATUSES`), `frontend/src/components/BeadTree.tsx`, `frontend/src/context/ChatContext.tsx` (`syncWithServerRef`, stream-end sync effect), `frontend/src/components/MUIChatHistory.tsx`, `frontend/src/components/ConversationInfoModal.tsx`, `app/cli.py`, `tests/test_open_work_counts.py`, `frontend/src/components/__tests__/beadTreeChipSemantics.test.tsx` (new), `frontend/src/context/__tests__/syncOnStreamEndWiring.test.ts` (new)). Same tree, two answers — e.g. "3 green" in the conversation, "2 amber" in the chat list — for three compounding reasons. **(1)** The two counted different things: the sidebar's `openBeadCount` was active+parked, while the conversation chip showed parked when any existed but otherwise the *total* including completed beads. Both now mean the same thing by amber: parked threads only. An active bead is just the current thread (every live tree has one), so counting it lit the sidebar on every conversation with beads; the summary count is now parked-only, and the chip shows amber=parked, else green=active, else a dimmed done-count so the history stays openable. **(2)** The sidebar count is refreshed only by the 30s server poll, and that poll is suppressed for the whole of any streaming turn in the tab — but bead status changes are made *by the model, mid-turn*, so the sidebar learned about them up to 30s after the turn ended. The sync now also fires on the streaming→idle edge. **(3)** The chip, having just fetched the authoritative tree, now writes its parked count onto that conversation's `openBeadCount` in context, so the two surfaces agree for the open conversation immediately, mid-stream included. Not addressed: a frontend-created fork's beads live on its lineage root's record, so its sidebar count still reads 0 (the gap already noted in `count_open_beads_for_conversation`); and a global chat viewed from a non-owner project fetches `/beads` through the viewing project's storage and finds nothing.

- **Shadow sessions whose socket path overflowed the AF_UNIX limit failed with `EACCES` on every start** (`app/shadow/sock_server.py` (`short_socket_dir`, `assert_private_dir`), `tests/test_shadow_short_socket_dir_umask.py` (new, 4)). `ShadowSocketServer.start()` binds inside an `os.umask(0o177)` window so the socket file is 0600. When the registry path exceeded the 104/108-byte AF_UNIX cap (a deep `$HOME`; pytest's `tmp_path` on macOS, always), it fell back to `short_socket_dir()` *inside that window*, and `os.mkdir(d, 0o700)` was masked to `0o600` — a directory with no owner search bit. `assert_private_dir` only ever tightened over-permissive modes, so it accepted the broken directory, which then persisted across runs and made every later fallback bind fail with `PermissionError`; `tests/test_shadow_host_e2e.py` had 3/5 failing for this reason. The directory is now `chmod`ed to `0o700` after creation (umask-independent), and `assert_private_dir` normalises any mode that is not exactly `0o700`, so an already-damaged directory is repaired on the next call rather than rejected or accepted. Two of the new tests were confirmed red against the unpatched code; two are positive controls for the tighten-only and symlink/plain-file rejections that must keep working.

- **Two shadow/tool-batch systems that had regression tests but no implementation now exist** (`app/shadow/title.py` (new), `app/shadow/pty_host.py`, `app/tool_batch.py` (new); covered by `tests/test_shadow_title.py` and `tests/test_tool_batch.py`, which previously failed at import/collection). The full test suite had exactly two collection errors, both `ModuleNotFoundError`/`ImportError` for modules the tests imported but that were never written. **(1)** `app.shadow.title.TitleRewriter` implements the design-doc §8 window-title rewrite on the shadow display stream: a child's OSC 0/2 title-set sequence is rewritten to `<shadow prefix> (<child title>)` (always re-emitted as `OSC 0 … BEL`), while icon-name OSC 1, OSC 133 prompt marks, CSI and plain text pass through byte-identical. Sequences split across reads are held until they terminate; a never-terminated OSC is released raw once it passes `MAX_CARRY` so the stream cannot stall; a non-ST `ESC` inside the body aborts the sequence the way xterm does; child titles are control-stripped and capped at 200 chars, and the prefix (which can carry model-supplied text) is sanitised of escape sequences and bidi/zero-width format controls so it cannot inject back into the terminal. `pty_host.py` routes child output through the rewriter on the *display* path only (new `InteractiveFrontend.write_child_output`, a `TitleRewriter` seeded from the session label and re-prefixed on relabel), so the journal still records the child's original bytes. **(2)** `app.tool_batch` runs the tool calls of one model turn with consecutive read-only calls overlapping and any other call acting as a barrier: `is_read_only_call` / `shell_command_is_read_only` classify conservatively (unknown tool, command substitution, file redirection to anything but `/dev/null`, and mutating flags such as `sed -i`, `find -exec/-delete`, `sort -o`, `git branch -D`/`--output` all make a call a barrier; `grep -i` stays read-only); `plan_groups` builds the run/barrier groups; `run_tool_batch` streams live events as they arrive but emits `_tool_result` events in the calls' arrival order, pays the adaptive inter-tool delay once per batch (then decays it one step), cancels in-flight siblings and emits no results on a stop directive, and stubs every not-yet-started call with `FEEDBACK_SKIP_MESSAGE` (plus a `tool_result_for_model`) when non-stop feedback arrives. The 9 `test_shadow_title` and 40 `test_tool_batch` cases now collect and pass.

- **`emit_artifact` rendering a `chat-message` diagram timed out and was recorded as failure evidence** (`app/mcp/tools/emit_artifact.py` (`_emit_diagram`), `tests/test_emit_artifact_chat_message.py` (new, 4)). `render_diagram` routes the `chat-message` / `chat-markdown` / `markdown` types to `render_chat_message` (which drives the real chat UI and photographs the `.message` element) *before* the plugin path, because a chat-message has no D3 plugin on the isolated `/render` harness. `emit_artifact`'s render-and-freeze path never had that branch: it posted every type to the harness, so a chat-message emit left the page waiting out its 30s safety timeout and every one came back as `Diagram render FAILED at emit time … Render timeout after 30000ms (type=chat-message). DOM snapshot: {"svg":0,"canvas":0,"img":0,"children":1}`. In the GFX Stage 2 verification run this lost all 36 chat-message `fixed-light`/`fixed-dark` artifacts (six defects × two themes × three cycles) while the verify agent's live `render_diagram` on the identical definition succeeded — so the ledger statuses were right but the frozen before/after PNGs were missing. The emit path now mirrors `render_diagram`'s dispatch off the same `CHAT_MESSAGE_TYPES` frozenset (reused, not re-declared, so the two cannot drift): a chat-message routes to `render_chat_message` with the definition, `role` (from the `diagram` dict, default `assistant`) and `theme`; every other type still goes to the diagram renderer. 4 tests: the three chat spellings reach `render_chat_message` and never touch the harness (red before the fix), and a plugin type (`mermaid`) does the reverse (the control that proves the branch is keyed on type, not unconditionally rerouted).

- **Tool arguments are coerced to their schema types before validation, for builtin tools too** (`app/streaming_tool_executor.py` (`coerce_tool_args_to_schema`, `_schema_expected_type`; applied in `stream_chunks` ahead of `validate_tool_args_against_schema`), `tests/test_tool_arg_schema_coercion.py` (new, 40)). qwen2.5-coder:7b on Ollama called `sequentialthinking` with `"nextThoughtNeeded": "false", "thoughtNumber": "1"` — every scalar quoted. External MCP tools were already repaired at dispatch by `MCPManager._coerce_argument_types`, but builtin tools (the dynamic loader runs `execute(**arguments)` verbatim) received the strings as-is, and the executor's own pre-dispatch validation checked `enum` membership on the raw value, so an integer enum rejected `"2"`. One schema-driven pass now runs for every tool at the executor's validation point: booleans from the usual spellings (`true/yes/1/on`, `false/no/0/off` — anything else is left for the tool to report rather than read as `False`, which is what the manager's coercer does), integers and numbers from numeric strings and integral floats, objects/arrays from JSON-encoded strings when the parsed shape matches, bare numbers to strings. `type: [X, "null"]` and Pydantic's `anyOf: [{type: X}, {type: null}]` resolve to X; a genuine union (the sequential-thinking server's own `boolean|string`) is left alone since both are legal. Unknown keys pass through and the input dict is not mutated. The hand-coded `raw`/`max_length` fixes just above the seam are now redundant but left in place.

- **A tool call an Ollama model writes as plain JSON text now runs instead of being printed** (`app/providers/text_tool_calls.py` (new, `TextToolCallSniffer`), `app/providers/openai_direct.py` (`_do_stream`), `app/utils/local_models.py` (`apply_discovery` sets `recover_text_tool_calls` for Ollama), `tests/test_text_tool_call_recovery.py` (new, 33), `Docs/NewUser.md`). Live on Ollama with qwen2.5-coder:7b, "testing, can you hear me?" came back as `{"name": "mcp_sequentialthinking", "arguments": {...}}` — the model omitted the `<tool_call>` wrapper its chat template expects, so Ollama's parser did not recognise the call and forwarded the object as `delta.content`. The provider turns `delta.content` into `TextDelta` events unconditionally, so the JSON streamed to the user verbatim, no tool executed, and the turn ended with `stop_reason='stop'`; the fabrication sanitizer looks for shell-prompt narration, not JSON. A sniffer now sits between `delta.content` and `TextDelta`: it holds back the *leading* characters of a response only while they are still consistent with `{"name": "<a tool that was offered>", "arguments":` (optionally wrapped in `<tool_call>` or a JSON fence) and releases them the moment they diverge, so ordinary prose is delayed by one delta at most and a JSON reply about anything else passes through byte-for-byte. Once the header names an offered tool the body is held and parsed at stream end into `ToolUseStart` / `ToolUseInput` / `ToolUseEnd` with a fresh id and an index past any native tool calls, and the turn is reported as `tool_calls` so the orchestrator loops back with the result; a body that does not parse (truncated on `length`, prose after the object) is released as the text it was. Because DwarfStar, vLLM, LM Studio and OpenAI parse their own templates and never leak a call this way, the recovery is **opt-in per model**: discovery sets `recover_text_tool_calls` on entries found on an Ollama server, and a `~/.ziya/models.json` entry may set it by hand; without the flag the provider's output is unchanged. The tests drive the pure sniffer (delta sizes 1 to whole-response, wrappers, two calls, alternate argument keys, double-encoded arguments, bounded hold, truncation, trailing prose, unoffered names), the `_do_stream` seam with SDK-shaped chunks including the exact live response and usage-ordering, the no-flag control, index non-collision with a native `tool_calls` delta, and the discovery seam that sets the flag for Ollama only. Also updated a stale static assertion in `tests/test_local_provider_routing.py` that still looked for the pre-refactor `local_base_url` name in `model_resolver`.

- **`ziya-install-extras` detects what is already installed and installs only the delta** (`app/scripts/install_extras.sh`, `tests/test_install_extras_idempotent.py` (new, 6), `tests/test_optional_features_install_path.py`). The plan always read "will install Chromium" and listed every TeX Live package even on a machine that already had all of them, and `--yes` blindly re-ran `playwright install chromium` and `sudo tlmgr install <all eleven>`. The script now asks the same Chromium probe the server uses (`app.utils.optional_features.chromium_browser_available`, so installer and startup banner cannot disagree; it falls back to the Playwright cache directory when the module is not importable, with distinct exit codes so an `ImportError` is not misread as "absent") and queries `tlmgr info --only-installed` (local tlpdb: no sudo, no network) for the package inventory. The plan says "already installed" per target, reports "N of 11 packages installed" and lists only the missing ones, and when nothing selected is missing it exits before the confirmation prompt with "Nothing to do"; the install phase skips a present Chromium and passes only the missing set to `tlmgr install`. If `tlmgr` cannot answer, every package is treated as missing (a no-op reinstall is the safe failure). The tests mask the machine's real state with a fake `tlmgr` shim on `PATH` and a fake interpreter as `ZIYA_PYTHON`, so they cannot download a browser or prompt for sudo.

- **Plotly charts release their WebGL contexts on re-render and unmount, and recover from a lost context instead of throwing once per frame** (`frontend/src/plugins/d3/plotlyPlugin.ts` (`teardownPlotlyContainer`, `installContextLossRecovery`, `PLOTLY_MAX_CONTEXT_LOSS_RECOVERIES`), `frontend/src/plugins/d3/plotlyPreprocessor.ts` (`demoteWebglTracesForCapture` gains `force`), `frontend/src/components/D3Renderer.tsx`, `frontend/src/plugins/d3/__tests__/plotlyWebglContextLoss.test.ts` (new, 8), `frontend/src/components/__tests__/d3RendererVizCleanup.test.tsx` (new)). The crash log showed four bursts of `gl-shader: Error compiling shader: null` from Plotly's own draw loop — `null` is what `getShaderInfoLog` returns on a *lost* WebGL context — and the fourth burst took the browser's GPU process down with it. Two defects compounded. **(1)** `plotlyPlugin.render` returns void and `D3Renderer`'s plugin path registered no cleanup, so `Plotly.purge` never ran: every WebGL-backed chart (`scattergl`, `heatmapgl`, the 3D family) kept its GL context alive after a re-render (`innerHTML = ''`) or unmount until GC happened to collect the detached canvas, and Chromium counts those against its per-page ceiling (~16), losing the oldest when exceeded. The `_plotlyResizeObserver`/`_plotlyDiv` handles stashed on the container were written and never read. **(2)** Nothing listened for `webglcontextlost`, so after a loss (ceiling, GPU reset, sleep/wake) Plotly's rAF/hover callbacks kept drawing on the dead context and threw outside any `await`/`try` in the plugin, once per frame, straight to `window.onerror`. Now a previous plot on the container is purged before every re-render; the plugin exposes `container.__vizCleanup`, which `D3Renderer` pushes onto its existing cleanup list so it runs on both re-render and unmount; and on `webglcontextlost` the dead plot is purged immediately (stopping the throw loop) and re-rendered once with `*gl` traces demoted to their SVG equivalents, so the chart comes back without a new GL context. A second loss on the same container leaves a static notice rather than another throw loop. The headless-capture demotion's `navigator.webdriver` gate is unchanged for the default path. Tests cover purge-before-replace, the `__vizCleanup` hook, the seam through `D3Renderer` unmount, loss → purge → demoted re-render, the give-up ceiling, and that SVG-only plots install no listener; 7 of 9 confirmed red against the pre-fix source. `__vizCleanup` is a generic channel: `vegaPlugin` stores a view "for cleanup by D3Renderer" that is likewise never collected and should adopt it.

- **Reasoning text that contains XML/HTML no longer flickers the screen while streaming, and is no longer injected into the DOM unsanitised** (`frontend/src/utils/thinkingHtml.ts` (new), `frontend/src/components/MarkdownRenderer.tsx` (`ThinkingBlock`), `frontend/src/utils/__tests__/thinkingHtml.test.ts` (new, 17)). `ThinkingBlock` renders a reasoning block with `marked.parse` straight into `dangerouslySetInnerHTML`, and marked passes raw HTML through untouched. A model that reasoned in DrawIO XML (an `<mxfile>` template with dozens of `<mxCell>`s) therefore produced, on every streamed delta, a tag that was *visible* as escaped text while still incomplete (`<mxCell id="t" value="Chap`) and *vanished* into an unknown DOM element the moment its `>` arrived — one layout-height jump per tag, fought by the auto-scroll, which read as continuous whole-screen flicker until the stream ended (16 grow-then-vanish events in a nine-line sample; a full diagram is hundreds). The same path also had none of the tag allowlist / DOMPurify pass the main renderer applies, so `<img onerror=…>` in reasoning text would have executed. `marked.parse` is now given a `walkTokens` hook that rewrites each `html` token in place: unknown tags and comments are escaped so they render as the literal markup the model was reasoning about — identically whether the tag is partial or complete, so the visible text is monotonic while streaming — and a small inline allowlist (`br`, `b`, `strong`, `code`, `sub`, …) is kept but rewritten as bare tags with attributes dropped. Working at the token level means fenced code and code spans, which marked already escapes, are never double-escaped. The tests drive the real marked lexer/parser through the exact options `ThinkingBlock` uses, replay the reasoning text prefix by prefix asserting zero visible-text shrink events, and include the pre-fix render as a negative control (must show >5) so the stability assertion cannot pass vacuously.

- **Task runs that wait on a person — an Ask, or an infrastructure hold — are now labelled chips in the conversation row, not one more gear colour** (`frontend/src/components/TaskCard/runStatusVocabulary.ts`, `frontend/src/components/TaskCard/RunStatusGears.tsx`, `frontend/src/components/TaskCard/__tests__/needsHumanIndicator.test.tsx` (new), `frontend/src/components/TaskCard/__tests__/runStatusVocabulary.test.ts`). Every run status rendered in the sidebar as a 12px gear in one of nine colours with a hover tooltip as its only text, so a row with four gear colours carried no reading at a glance — and two of those statuses are ones where nothing advances until the user notices: `awaiting_input` (a question the run put to the user) and `held` (an infrastructure fault such as a dead credential; the run sits where it stopped until the environment is fixed and someone resumes). The vocabulary gains `RUN_STATUS_NEEDS_HUMAN` (true for exactly those two), a per-status `RUN_STATUS_CHIP_LABEL` ("Waiting on you" / "Held — fix & resume") and per-status `RUN_STATUS_CHIP_TEXT` (dark on amber, white on violet — one constant cannot pass contrast on both fills); both cluster builders carry `needsHuman`. The row renders needs-human clusters as filled chips with visible text and a status-specific glyph (question / wrench), `role="status"`, count folded into the label ("2 × …"), placed ahead of the gears. The row's one structural distinction is now chip = "you must act" vs gear = "the machine is or was doing something"; `failed` deliberately stays a gear, since it reports an outcome rather than demanding one. Machine-state statuses are otherwise unchanged. 12 assertions on both data paths (open-chat bindings and the project-wide index counts), including a WCAG-AA contrast check on each chip; also fixed the pre-existing `runStatusVocabulary.test.ts` coverage list, which had not been extended when `awaiting_input` was added and was failing independently of this change.

- **`openai` is now a declared dependency; the OpenAI-compatible endpoints preflight it at startup** (`pyproject.toml`, `app/main.py`, `app/utils/local_models.py` (`openai_sdk_missing_message`), `tests/test_local_catalog_sync.py`). Four endpoints — `openai`, `zai`, `meta`, `local` — build an `AsyncOpenAI` client, but `pyproject.toml` never listed the package; any environment that did not pull it in transitively passed the startup "authentication check" and then failed every chat with `provider creation failed (The 'openai' package is required...)`. It is a hard dependency now, and startup on one of those endpoints exits with the install command instead of reporting success.

- **`--endpoint local` asks the server which models exist instead of assuming one** (`app/utils/local_models.py` (`find_local_server`, `list_server_models`, `sync_local_catalog`, `choose_default_model`, `_probe_v1_models`), `app/config/environment.py`, `app/agents/models.py`, `app/routes/model_routes.py`, `Docs/NewUser.md`, `Docs/EnvironmentVariables.md`, `tests/test_local_catalog_sync.py` (new, 18)). First live run against a DwarfStar `ds4-server` on `localhost:8000`: with nothing configured Ziya pointed at Ollama's `:11434`, `setup_environment` pinned `ZIYA_MODEL=qwen2.5-coder:7b` from the env-default table before anything had contacted a server, and the picker listed that phantom name although the only model the server had was `deepseek-v4-flash`. Discovery could decorate an entry but never replace its name. Now, with `ZIYA_LOCAL_MODEL_URL` unset, the loopback ports `:11434`/`:8000`/`:1234`/`:8080` are probed (1 s each, misses retried after 30 s) and the first answering `GET /v1/models` is the server; its model list *is* the local catalog — one `MODEL_CONFIGS["local"]` entry per server model, the env-default placeholder dropped, user `~/.ziya/models.json` entries kept — and the default is the server's only model when it has one, else `ZIYA_LOCAL_MODEL`, else the reference model if installed, else the first listed. The sync runs in `setup_environment` (so `ZIYA_MODEL` is a real model), again in `ModelManager.initialize_model` before the name is resolved, and from `/api/available-models` so the picker shows the server's list; memoised per URL. Servers with no runtime metadata API (DwarfStar, llama-server, vLLM) are recognised from their `/v1/models` entry, and a context length is taken from it only under a known key — otherwise the 32k placeholder stands and is logged, since inventing a window is worse than a knob.

- **A message the shell-recovery guard could not deliver was held out of sight, and a single failed `listChats` could empty the sidebar** (`frontend/src/utils/shellRecovery.ts`, `frontend/src/utils/syncMerge.ts`, `frontend/src/utils/composerInject.ts`, `frontend/src/components/SendChatContainer.tsx`, `frontend/src/context/ChatContext.tsx`, `frontend/src/utils/__tests__/heldMessagePartition.test.ts` (new), `frontend/src/utils/__tests__/deletionPassGuard.test.ts` (new), `frontend/src/context/__tests__/heldMessageRecoveryWiring.test.ts` (new)). Two follow-ups to the shell-recovery and sync-wedge fixes. **(1)** When neither IndexedDB nor the server could establish that a shell's history was complete, SHELL_GUARD correctly refused to append (a truncated local record with a fresher `_version` would be pushed over the server's fuller one) and *held* the queued messages — but `handleSend` had already cleared the composer, so the user's text was visible nowhere and, if recovery never succeeded that session, simply gone. The hold path now partitions the queue by ownership: human-authored text is handed back to the composer and **released from the queue** (exactly one owner, so it cannot be sent twice), while streamed assistant turns and system notes — which have no composer to return to — stay queued for a later retry. The composer-injection channel gained a `preserveExisting` option so the returned text is *appended* to whatever the user has typed since (the recovery can take up to the `getChat` deadline), in both delivery paths: immediate, and the stash applied after a conversation switch, where the existing "pending injection wins over saved draft" rule would otherwise have clobbered the draft. Existing dispatchers (resume, seam ribbon, branch pickup) are unaffected — replace remains the default. The error toast now says where the text went; the `'gone'` variant is preserved. **(2)** `listChats` returns `[]` on a non-2xx, and the sync's deletion pass treats every previously-seen conversation absent from that list as deleted elsewhere — so one 500 staged the whole project for local removal (transient, since IDB is untouched, but indistinguishable from data loss from where the user sits). A pure `shouldRunDeletionPass` gate now skips the pass when the server returned zero chats but this browser had previously seen some; a genuinely empty project (nothing ever known) still runs it as a no-op, and a shrunken-but-non-empty list is deliberately *not* suppressed, so a real cross-instance delete still propagates. Known limit, stated in-source: this catches the empty case — the one an error path actually produces — and a server returning a genuine subset would still over-delete. 23 + 8 pure tests and 16 static seam assertions; 12 of the seam assertions confirmed failing against the pre-patch tree via `git show HEAD:`, with the 4 that pass both ways being the labelled positive controls. One earlier `shellGuardWiring` assertion ("hold never calls `queue.delete`") was updated to follow the design — the delete is now legitimate after partitioning, and the assertion pins that ordering and the retention of the remainder instead.

- **A conversation whose first content is a task-card run stayed "New Conversation" forever** (`frontend/src/utils/conversationTitle.ts`, `frontend/src/components/TaskCard/TaskCardsLibrary.tsx`, `frontend/src/utils/__tests__/conversationTitle.test.ts`, `frontend/src/components/TaskCard/__tests__/launchTitleSeedWiring.test.ts` (new)). A conversation's title is derived only from its first *human* message (`shouldDeriveTitleFromMessage`), so a conversation whose first content is a task-card tile — the deck's "Launch/Copy in current conversation" against a fresh chat — never received the message the derivation keys on and kept the placeholder. The deck's "in new conversation" buttons were already correct (they seed `draft.name` through `startNewChat`). A new `shouldSeedTitleFromTaskCard` guard fires only when the title is still a placeholder AND no human dialog exists yet (content-stripped shells excluded, since their empty message list would fake "no dialog"); `launchToChat` — the single path all four launch/stage buttons funnel through — renames the bound conversation to the card name through `mutateConversationMeta`, the unified sidebar-rename contract, so the title persists to IDB/server and is not reverted by the next sync. The two "new" paths are no-ops there (title is already the card name, not a placeholder). A later user rename is never clobbered and the first human message won't overwrite the seed, both because the seeded title is no longer a placeholder; a rename failure cannot fail a bind that already succeeded. 15 assertions confirmed red pre-fix — 11 unit tests on the guard plus 4 static seam assertions (guard imported and called from `launchToChat`, rename routed through `mutateConversationMeta`, card name not a constant), following the `runStatusIndexWiring` convention. Deliberately out of scope: slash-command and chat-proposal launches (`TaskCardLaunchButton`), which occur in conversations that already have dialog and therefore derive their title normally.

- **Completion scanner no longer mistakes a closed multi-backtick code span for truncation, nor splits one into a fence** (`app/utils/inline_code_spans.py` (new), `app/streaming_tool_executor.py` (`_has_unclosed_inline_code`, `_normalize_fence_spacing` gains keyword-only `preceding`), `app/text_delta_processor.py` (passes accumulated text), `tests/test_inline_code_spans.py` (new, 22), stubs in `tests/test_stream_text_delta_wiring.py` and `tests/test_repeat_block_recovery.py`). A clean `end_turn` reply that mentioned a diff fence inside a 4-backtick inline span — a valid CommonMark span whose content is three literal backticks — triggered the "finish the code block" continuation three times in a row. First as `open_inline`: the inline-truncation check stripped fences with a lazy three-backtick regex that ate the wrong three of the four, leaving a lone backtick and an odd run count. Then as `open_fence`: the fence-spacing normaliser matched the three-backtick run *inside* the span and inserted a blank line there, which put the span's interior at line start where the tracker read it as an untyped opener that never closed. Each injected prompt produced a reply that mentioned the same span, so the loop re-armed itself until the stall cap; three paragraphs of the model explaining that nothing was cut off were appended to the answer. Both routines now use CommonMark pairing — a run of N backticks is closed only by the next run of exactly N, other runs in between are content, an unpaired run is literal — and the normaliser skips any candidate preceded in its paragraph by an unpaired run, seeing text from earlier chunks so a span split across token boundaries is still recognised. The truncation verdict is now confined to the final paragraph (a span cannot cross a blank line) and to the *last* run in it, so a stray literal backtick earlier in the reply no longer counts as evidence. The genuine case that motivated the check — ``The bug is clear: `DirectoryBrowser`` on a clean stop — is still detected.

- **Raw markdown view (Ctrl+Shift+U) now includes thinking blocks** (`frontend/src/utils/thinkingBlocks.ts` (`expandThinkingMarkers`), `frontend/src/components/RawMarkdownView.tsx` (new), `Conversation.tsx`, `StreamedContent.tsx`, `TaskCard/TaskMarkdown.tsx`, `frontend/src/utils/__tests__/expandThinkingMarkers.test.ts` (new, 5)). Reasoning text is held in the session-scoped `reasoningContentMap`; a message's content carries only a positional `⟨THINKING:turn:idx⟩` marker. The raw view printed `msg.content` verbatim, so every thinking block — collapsed or not — was missing from it. The three raw-mode sites now share one component that resolves each marker to its block text, delimited by plain `--- thinking ---` / `--- end thinking ---` lines with an in-progress note for a block still streaming. The delimiter is deliberately not an angle-bracket tag: those literals are what the backend inline-reasoning scanner keys on, and raw-view text gets copied back into prompts. Unresolvable markers (after a reload, or past the retention cap) are dropped, matching the pretty view where they render nothing.

- **Conversation export (copy to clipboard, download, HTML, PDF) no longer emits literal thinking markers and now carries the reasoning text** (`frontend/src/utils/thinkingBlocks.ts` (`expandThinkingMarkers` gains a `wrap` formatter; `wrapThinkingAsDetails`, `resolveThinkingMarkersInMessages` new), `frontend/src/components/ExportConversationModal.tsx`, `frontend/src/utils/__tests__/expandThinkingMarkers.test.ts` (+4), `frontend/src/components/__tests__/exportModalResolvesThinking.test.ts` (new seam guard)). Same root as the raw-view defect above: every export route sent `msg.content` to the server, whose exporter (`_clean_thinking_blocks`) knows only the legacy ```` ```thinking:step-N ```` fences, and the map that resolves positional markers is browser session state the server can never see. Exported markdown therefore contained bare `⟨THINKING:turn:idx⟩` strings and no reasoning at all. Markers are now resolved in the modal before any request is built, to the same `<details><summary>💭 Reasoning</summary>` shape the exporter already produces for legacy blocks — which means the existing "include collapsed" toggle (and the server's `includeCollapsed` filter on the HTML/PDF routes) governs positional thinking for free: on, the reasoning is in the export; off, it is stripped along with the other `<details>` content. Exporting a conversation that is not the live one (loaded from IDB, no blocks in memory) drops the markers rather than leaking them. Selection copy (`useCopyCleanup`) is unchanged: it serialises the selected DOM, so an expanded thinking block copies and a collapsed one does not, and the marker token is never in the DOM to leak.

- **Inline-reasoning scanner no longer swallows answer text — including diffs — into the thinking panel on a delta boundary** (`app/utils/inline_thinking.py` (`InlineThinkingState.prev_char`, `_find_fence_start`, `_pending_fence_prefix_len`), `tests/test_inline_thinking.py` (+10)). The scanner that converts literal `<thinking>`/`<reasoning>` tags in a model's text stream into discrete thinking events has two guards against false openers — a backtick immediately before the tag means the model is quoting it, and `in_code_block` means it is inside a fence — and both only ever saw the current delta. **(1)** A trailing partial tag is withheld as carry, so a boundary between a backtick and the `<` emitted the backtick in one delta and presented a complete opener at offset 0 in the next, with nothing before it for the guard to see. Observed in a real session: an assistant summary that mentioned the tag in inline code opened a block at that point and the rest of the response (several diffs) rendered inside a collapsed thinking panel. **(2)** The caller's fence tracker runs *after* the scan, so `in_code_block` lags one delta and a tag arriving in the same delta as the ```` ``` ```` opener — or in a diff line that came with it — was scanned as prose. The state now carries the last character consumed before the carry, which both guards fall back to across a boundary; the scanner detects a fence opening line in its own buffer and passes everything from it on through verbatim, deferring to the caller's tracker from the next delta; and a trailing `\n` + one or two backticks is withheld like a partial tag so a fence split across deltas is still recognised. Ten tests replay the splits; seven are red against the previous scanner. Two further changes bound the damage when a false opener still gets through. **(3) A wrongly opened block was greedy**: the closer search ignored fences and `flush` left the block open, so one misfire sent the rest of the response — typically the diff — to the thinking panel. A fenced-code opening line inside an open block now closes it (models that reason inline do not put fenced diffs in their thinking); the fence and everything after return to text, and `prev_char` is tracked inside the block so a fence at offset 0 of the next delta is recognised. Six tests, four red before. **(4) The scanner ran for every provider.** Every native reasoning path (Claude `thinking_delta`, DeepSeek `reasoning_content`, GLM) converges on one executor branch; that branch now sets `TextDeltaState.native_thinking_seen` (`app/text_delta_processor.py`, `app/streaming_tool_executor.py`, `tests/test_native_thinking_gates_inline_scanner.py` (new, 5)), and `process_text_delta` skips the inline scan for the rest of the iteration — a provider that has just delivered reasoning on its own channel is not also emitting it as literal tags, so any tag in its text is content. `supports_thinking` in the model config was rejected as the gate: it means the model bills for thinking, not that it never emits inline tags. An adaptive-thinking iteration that chooses not to think leaves the scanner active, which (3) now bounds.

- **One install command for every optional feature; `scapy` and `playwright` become hard dependencies; the dead `torch` extra is gone** (`pyproject.toml`, `app/scripts/install_extras.sh` (`--browser` / `--latex` / `--all`), `app/utils/install_extras.py`, `app/utils/optional_features.py` (new), `app/services/diagram_renderer.py`, `app/mcp/builtin_tools.py`, `app/mcp/tools/diagram_render.py`, `app/routes/diagram_routes.py`, `app/services/latex_renderer.py`, `app/server.py`, `README.md`, `Docs/NewUser.md`, `Docs/Capabilities.md`, `tests/test_optional_features_install_path.py` (new, 24)). Ziya's optional capabilities were reached by four unrelated recipes: a pip extra (`ziya[render]`), a separate `playwright install chromium`, a brew+tlmgr shell script that nothing in the docs, the first-run output or the product ever referenced, and a Poetry dependency *group* for scapy that has no pip spelling at all — and `ziya[all]` contained none of them. Every surface that noticed a gap quoted a different command. Now: `scapy` (~7 MB, pure Python; libpcap is only needed for live capture and Ziya reads capture files) and `playwright` (small; the 150 MB Chromium is the post-install step) are ordinary hard dependencies, and `ziya-install-extras` is the one command for the two things pip cannot do — it prints the plan, asks, installs Chromium (`--browser`) and/or a TeX distribution plus packages (`--latex`), and defaults to both. `app/utils/optional_features.py` is the single source of truth for what is missing and names that command; the startup banner lists the gaps once, the `render_diagram` gate, the LaTeX not-installed result and the HTTP routes all quote it, and the two-command raw recipe is gone from every surface. The gate also now checks for the **browser**, not just the pip package (mirroring Playwright's registry: `PLAYWRIGHT_BROWSERS_PATH`, the `0` in-package mode, per-OS cache dirs; `chromium-*` or the `chromium_headless_shell-*` that ≥1.49 launches headless on), closing the second first-run failure where `pip install playwright` alone registered the tool and it died at launch with "Executable doesn't exist". Extras: `torch` and `numba` are removed — nothing in `app/` imports either; torch only ever arrived transitively via `sentence-transformers` (the optional `pdf_search` embedding mode), which was never declared, so `ziya[local]` installed ~2 GB that enabled nothing. `sentence-transformers` is now declared under `embeddings` (and `local` as an alias), `transformers` stays as the Python 3.14 tokenizer fallback, and `all` is `tiktoken + transformers + sentence-transformers`.

- **Five first-run graphics failures: `render_diagram` offered without Playwright, circuitikz `to[amp, l=…]` fatal, pgfplots `shader=interp` fatal, chemfig version mismatch reported as a typo, d3 treemap unclaimed** (`app/mcp/builtin_tools.py`, `app/services/latex_profiles.py` (`_circuitikz_block_alias`, `requires_interp_shading`, `downgrade_interp_shading`), `app/services/latex_renderer.py`, `frontend/src/plugins/d3/plotlyPreprocessor.ts` (`isD3HierarchySpec`, `hierarchySpecToPlotly`), `frontend/src/plugins/d3/plotlyPlugin.ts`, `Docs/NewUser.md`, tests: `tests/test_latex_first_run_failures.py` (new, 21), `tests/test_render_tool_gating.py` (new, 3), `tests/test_latex_g_circuitikz_repair_backlog_guards.py` (structural pin follows the new alias), `frontend/src/plugins/d3/__tests__/plotlyD3HierarchySpec.test.ts` (new)). A new user asking for sample graphics hit all five inside their first few turns. (1) Playwright is an optional extra absent on a fresh `pip install ziya`, yet `render_diagram` was registered regardless, so every diagram request began with the model calling it and reading "Playwright is not installed"; the tool is now gated on the dependency exactly as the PCAP tools are on scapy, and the install command goes to the operator's log once. (2) The circuitikz profile aliased `amp`/`adc`/`dac`/`dsp` to a plain rectangle node style to cure D-042 (`\node[amp]` draws nothing in stock circuitikz) — but circuitikz defines those same names as **path-style bipole keys**, so the alias clobbered them: `to[amp, l={LNA}]` died with `I do not know the key '/tikz/l'` (the label key is only valid inside a bipole) and a bare `to[amp]` compiled while silently dropping the amplifier symbol (measured: 1 `<path>`, the wire, instead of 2). The alias now dispatches on context — `every to` raises a flag inside the path's TeX group, the key routes to circuitikz's preserved original on a path and to the shipped `<name>shape` in a node — verified live for both cases, the path→node ordering that would expose a leaking flag, and the user's full superheterodyne front end. (3) pgfplots `shader=interp` is refused outright by the dvisvgm driver and `-halt-on-error` made that "No pages of output"; it is the third member of the survives-PDF-but-not-DVI family with position marks and coloured `\charge`, and is now routed to the PNG path when the format is open, or downgraded to `shader=faceted` with a warning when SVG is pinned or PNG is impossible. (4) `\lewis` failing with `Undefined control sequence \CF_expafter` was reported as "check for a typo". The cause is upstream: chemfig **1.81 (2026/09/01)** removed `\CF_expafter` and `\CF_swapunbrace` from `chemfig.tex` but shipped an unchanged `chemfig-lewis.tex` that still calls the former four times, so every `\lewis` on ≥ 1.81 aborts (verified against the CTAN sources; reproduced by stripping the two definitions from a 1.71 `chemfig.tex` on `TEXINPUTS`; the user's `kpsewhich -all` showed a single, current file, which ruled out the shadowing theory first proposed). The chemfig profile now supplies the two 1.71 definitions, guarded by `\ifdefined` so ≤ 1.71 is a no-op, ahead of the `chemfig-lewis.tex` input. Should the shim ever not be in effect, an undefined `\CF_` internal is recognised before the generic "typo" branch (whose regex could not even match it — `_` is outside `[A-Za-z@]`) and reported as the package bug it is. (5) `{"type":"treemap","data":{name,children}}` — the d3.hierarchy input shape a model naturally emits because the prompt lists "d3" — matched no plugin. Plotly draws treemap/sunburst/icicle natively, so the plotly plugin now claims the nested shape and flattens it with path-based ids (two `unit` leaves in different branches stay distinct) and undefined branch values so Plotly sums children; converted before the `type`-stripping destructure that would otherwise erase the field it is recognised by.

- **The context-debug ledger attributes iterations to a request, stops silently eating its own history, and finally records context overflows** (`app/utils/context_debug.py`, `app/utils/custom_bedrock.py`, `app/streaming_tool_executor.py`, `app/api/chats.py`, `tests/unit/test_context_debug_requests.py` (new, 26)). Three defects, all visible in real data (66 conversations / 3493 recorded entries). **(1) No request identity.** A conversation routinely has several *concurrent* provider streams against one `conversation_id` — the primary stream plus delegates and background tasks — and each restarts its agentic `iteration` counter at 0. Keyed only by conversation, they interleaved into one flat deque whose `iteration` column read `2,3,4,3,3,3,2,4,5,...`: 101 non-increasing transitions across 20 minutes in one observed conversation, unattributable to any turn. Each stream invocation now mints a `request_id` and every entry carries it plus a monotonic per-conversation `seq`, so ordering survives interleaving; `get_requests()` returns the rounds grouped, and the flat `get_history()` shape is preserved for the existing panel. **(2) Silent self-cannibalisation.** The 200-entry ring buffer was per *conversation*, not per request, so concurrency consumed it several times faster than one turn's worth and the front was dropped with no marker — one snapshot began at iteration 59 with iterations 0–58 simply absent. Buffers are now per request and an explicit `evicted` count is recorded when one overflows, so loss is stated rather than inferred. **(3) Overflow was never recorded once** — `is_failure` was 0 across all 3493 entries, for three compounding reasons: the Bedrock client wrapper catches context-limit rejections itself and transparently retries with extended-context headers, so a successful retry left the overflow with no trace anywhere; the executor's detector was a three-substring list that missed the `exceeds the maximum number of tokens` phrasing two other modules already special-case, plus OpenAI's `maximum context length` / `context_length_exceeded` and Gemini's wording; and the legacy `app/agents/agent.py` path never called the recorder at all. Detection for the streaming executor and the Bedrock wrapper is now one shared `is_context_overflow_error()` — broadened, and verified not to swallow throttling's confusingly-similar "Too many tokens" — and the wrapper records the failure, with the rejected body as its payload frame, *before* attempting the extended-context retry. Failure entries carry an `origin` so a retry chain reads as a chain. Still outstanding: the legacy `app/agents/agent.py` overflow branches keep their own narrow substring check and do not record, so a turn that goes through that path remains invisible to the panel.

- **One malformed MCP server entry no longer costs the user every other MCP server** (`app/mcp/manager.py` (`_initialize_locked` per-entry `_prepare_and_queue_server`, new `_register_setup_failure_stub`), `app/mcp/config_validation.py`, `tests/test_mcp_server_setup_containment.py` (new, 10), `tests/test_mcp_config_none_normalization.py` (new, 6)). The whole per-server setup loop ran inside a single `try/except Exception` that returns `False`, so an exception while preparing *one* entry aborted initialization and **no** server connected — the only evidence being one terminal line reading `Error initializing MCP manager: 'NoneType' object is not iterable`. Two concrete triggers were reproduced against the unpatched loader, both reachable from a hand-written config and from any registry provider that emits an explicit null (`validate_config` only *warns* about either, so the loader still reaches them): `"env": null` → `server_env.update(None)` raised, `initialize→False` with zero servers connected; and `"args": null` → the resilience normalizer's `[str(value)]` coercion produced the literal argv `['None']`, passing the four-character string `None` to the server process as a real argument. Both are now handled at the point of use (a non-dict `env` is ignored with a warning naming the type; an explicit null `args` means no arguments, while the existing scalar coercion `"-v"` → `["-v"]` is unchanged and pinned by test). More importantly the blast radius is closed structurally: per-entry setup moved into a nested function called under its own `try`, so an *unanticipated* exception is attributed to the entry that caused it and the remaining servers are still prepared. The failed entry is not merely skipped — it reuses the preflight stub mechanism, registering a non-connecting `MCPClient` carrying a `setup_failed` diagnostic so `GET /servers/{name}/details` returns the reason instead of 404-ing into an empty Logs pane, and so `connect()` is terminal for it rather than respawned on every health check. Stub construction is separately guarded, since a config pathological enough to break setup could also break `MCPClient.__init__`. Deliberately a nested function rather than an inline `try:` block: the latter re-indents 165 unchanged lines, burying six real edits (`continue` → `return`, each of which already meant "skip this server") in ~330 lines of whitespace diff. Two of the ten tests are honesty controls — one proving the harness is not passing vacuously, one asserting the injected failure genuinely reaches the per-entry setup body — because "the siblings still connected" passes trivially if the poison never fires.

- **A registry install that cannot start is reported and rolled back instead of answering success** (`app/mcp/client.py` (`_detect_missing_runtime`, `_missing_runtime_failure`, `MCPClient.startup_failure`), `app/mcp/registry_manager.py` (`install_service`, `_describe_connect_failure`, `_rollback_failed_install`), `app/routes/mcp_routes.py` (`/registry/services/install`, `/servers/{name}/details`), `tests/test_mcp_install_failure_reporting.py` (new, 12)). Installing an MCP service whose launcher needs a runtime the machine does not have produced an installed-looking server with a registry description and zero tools, and no stated reason. Three things were wrong at once: `install_service` discarded the bool from `restart_server` and returned `'success'` regardless; the config entry was persisted before anything tried to start it, so a launcher that cannot run was left behind permanently; and the route returned the error dict with HTTP 200, which is what the frontend keys off. The observed case was `exec: node: not found` (exit 127) from an internal-toolbox launcher — and note that no *static* check could have caught it: the configured command is a symlink to a compiled dispatcher, so the `exec node` lives in a versioned wrapper the config never names. The launcher's own stderr is therefore the evidence, and it is parsed rather than guessed: exit-127 output is matched against a longest-first runtime list (`python3` before `python`) paired with the shell's not-found phrasings, and turned into a structured `runtime_not_found` record carrying the existing `_install_hint_for_command` text ("'node' is provided by Node.js. Install it with: brew install node", plus the version-manager-shim caveat). The record lands on a new `MCPClient.startup_failure`, deliberately separate from `preflight_failure` so it does not block the reconnect that will succeed once the runtime is installed, and is exposed on `/servers/{name}/details` alongside it. `install_service` now connects, and on failure reports the diagnosis (`error`, `detail`, `hint`, `failure_code`, log tail) and removes the entry it just wrote — targeted, not via `uninstall_service()`, which restarts every MCP server and deletes the download the retry will want. A connect failure with no structured record still explains itself as `connect_failed` rather than reverting to a bare "restart failed".

- **First-run credential guidance follows the endpoint policy, and an enterprise auth plugin can supply its own AWS instructions** (`app/utils/provider_detection.py`, `app/plugins/interfaces.py` (`AuthProvider.get_first_run_setup_help`), `app/plugins/__init__.py` (`get_first_run_setup_help` resolver), `tests/test_setup_help_endpoint_policy.py` (new, 9), `tests/test_first_run_setup_help_hook.py` (new, 16), `Docs/Enterprise.md`). A deployment restricted to Bedrock by `ConfigProvider.get_allowed_endpoints()` still printed the full six-provider menu when no credentials were found, instructing the user to set `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/etc. — endpoints `app.main` then refuses with `exit(1)` — and `maybe_autoselect_endpoint()` could silently switch `ZIYA_ENDPOINT` to one of them, producing a startup failure out of a *working* API key. Both now consult the allowlist; an empty allowlist reports the misconfiguration rather than emitting a header with no body, and `ZIYA_ALLOW_ALL_ENDPOINTS=1` still restores the full menu. Separately, the AWS half of that message was generic `aws configure` guidance, which in a Midway/ada environment cannot produce working Bedrock credentials at all — so a brand-new machine was told to do something that does not work. `AuthProvider` gains an optional `get_first_run_setup_help()` whose text replaces the AWS body (only that body, so a mixed policy still lists its other permitted providers). It is resolved in priority order **without** calling `detect_environment()`: the Amazon provider's detection falls through to an STS `get_caller_identity()` call, which fails exactly when the user has no credentials, so credential-dependent detection would return community advice on precisely the machines needing the enterprise text. Community builds register only `DefaultAuthProvider`, which returns `None`, and the unrestricted message is byte-for-byte unchanged.

- **Project switch: the "still loading" signal is visible from every sidebar tab, and a chat you click mid-switch stays selected** (`frontend/src/components/FolderTree.tsx`, `frontend/src/context/ChatContext.tsx`, tests: `components/__tests__/folderTreeSwitchBanner.test.ts` (new), `context/__tests__/projectSwitchUserSelectionSticks.test.ts` (new)). Two loading windows have different owners: `ChatContext.isProjectSwitching` is released as soon as the IndexedDB preload commits a conversation list (deliberately, so a project with local history never waits on the network), while `FolderContext.isSwitchingProject` stays up until the first file tree for the new project arrives — usually the slow part. Only `MUIFileExplorer` rendered the second window, so from the Chats/Skills/Backlog tabs a 60-second switch looked finished the moment the list appeared. Those tabs now show a compact non-blocking banner ("Switching project… loading file tree") for that gap; the list stays interactive underneath. Which exposed the second defect: clicking a conversation during the gap was undone when the switch's server sync landed and its relocation step (section 7) re-derived "belongs to this project" from the merged record. `loadConversation` now records the user's explicit choice in `userSelectedConversationRef` (cleared when a switch begins, never written by the passive preload), and the relocation is skipped when the live `currentConversationId` still matches it. Passive behaviour — saved-or-most-recent selection when nobody clicks — is unchanged.

- **Task deck: status badges no longer crowd out the card name** (`frontend/src/components/TaskCard/TaskCardsLibrary.tsx`, `__tests__/deckRunLinksWiring.test.ts` (+2)). `Unsigned`/`Signed`, 🌱, `Running` and `Attention` shared the name's flex row and the name span was the only shrinkable item, so three badges on a 260px sidebar ellipsized the name to a few characters. Badges move to a wrapping second line with the `type · N runs` metadata.

- **Shadow sessions: always-on window-title prefix, rewriting the child's own title sequences rather than fighting them** (`app/shadow/title.py` (new), `app/shadow/pty_host.py`, `tests/test_shadow_title.py` (new, 9)). A wrapped production terminal that is visually identical to an unwrapped one is the first thing a security review asks about, and the ⏺ start banner scrolls away. A persistent status *row* was rejected — it requires owning the screen (scroll regions, cursor tracking, altscreen and `SIGWINCH` re-layout, cooperating with the remote host's tmux), which is exactly what the byte-faithful passthrough design avoids. The title costs no rows: `TitleRewriter` sits on the *display* path only (`InteractiveFrontend.write_display`; the journal still records the child's original bytes) and intercepts OSC 0/2 title sets, re-emitting them as `⏺ <label> (<id>) (<child's title>)` so the remote prompt's own title survives as a parenthetical instead of clobbering ours. OSC 1 (icon name only), OSC 133 and the xterm title stack pass through byte-for-byte; the frontend pushes the terminal's title at start and pops it on exit so the pre-shadow title is restored. A title sequence split across PTY reads is held until it terminates, capped at 1 KiB so a hostile never-terminating OSC cannot stall the screen, and an ESC inside an OSC body aborts the sequence exactly as xterm does (the bytes pass through raw). The child's title text is remote-controlled and is stripped of control bytes and capped at 200 chars; the prefix can originate from the model (label) and goes through `safe_terminal_text`. Re-emitted on relabel from either side; `C-x C-z` → `t` toggles it for the rare program that depends on exact title round-tripping. Design: `Docs/design/shadow-sessions.md` §8.

- **`scripts/gfx_ledger.py` — single writer for the GFX sweep evidence, so re-runs stack on prior results instead of overwriting them** (`scripts/gfx_ledger.py` (new), `tests/test_gfx_ledger.py` (new, 42 tests)). The Stage 1/2 task cards had every agent `file_write` whole shared JSON files — per-engine blackboards and `backlog.json` — re-serialised from whatever the agent held in context. Measured consequences on the Sep 1 sweep: wave 3 (the theme-adversarial wave the sweep exists for) lost its verdicts in 13 of 20 engines when wave 4 wrote the blackboard from a stale copy; `mermaid.json` acquired two orphan `-wave2`/`-wave4` sidecars; and a consolidation pass with nothing in context wrote a 32-byte `{"groups": []}` over the backlog. The ledger file-locks, snapshots every touched file to `history/<ts>/` before writing, appends rather than replaces, and refuses a write that would drop an existing record. `record` appends a `{run, at, light, dark}` entry to a spec's `history[]` (a re-sweep sits beside the earlier verdict, which is what a before/after report needs); `merge-triage` folds `triage/*.json` into the backlog while preserving each matched defect's status, attempts, verification and history, flips a *verified* defect that reappears to `regression` rather than silently reopening it, and refuses to lose an id; `set-status` enforces legal transitions, and `wont-fix` retires the defect's signature into `retired.json` automatically so a later sweep logs the reappearance instead of minting a fresh `open` with `attempts=0`; `reconcile` derives status from the render evidence itself — a defect whose every named spec now carries an ok verdict in both themes on every affected engine becomes `verified`, a verified defect with a failing current verdict becomes `regression`, and a `fix-applied` defect whose specs were all re-rendered *after* the fix (microsecond timestamps on every ledger event, so the sweep that found the defect cannot be mistaken for evidence against its fix) and still fail becomes `still-broken`; incomplete evidence changes nothing. Removing fixed work from the queue therefore needs no agent or human to declare it: verifiers `record` what they saw and the ledger closes, reopens or holds. Running it over the migrated evidence closed one defect (`network` D-186, a node-stroke finding the triage agent raised from reading code while both specs it named had rendered clean in both themes) — the intended trade: a finding that does not reproduce is not in the queue. The manual `retire` remains as an override only. `queue` emits the fix groups a fan-out consumes, keyed by each defect's *most specific* suspect file (free-text `suspect_files` entries are reduced to bare paths first; grouping by verbatim overlap produced 241 groups for 244 defects, transitive union through the shared capture path produced one group of 180) and capped at 6 defects per group. `migrate` promoted the existing evidence in place (snapshot at `history/pre-migrate-*`) and the backlog was rebuilt from the intact triage files: 279 defects, 244 open, 35 deferred, 82 groups. Both GFX cards were rewritten (text only — no scope change) to mutate evidence exclusively through the ledger.

- **`{{run.id}}` / `{{run.short}}` task-card template variables** (`app/agents/task_templating.py`, `app/agents/block_executor.py`). `ExecutionContext.run_id` was threaded through every block but never exposed to templating, so a task had no way to stamp evidence it wrote with the run that produced it — the ledger above needs exactly that to append rather than overwrite. Attached at both binding sites (task instructions and `for_each` sources); the first-block fast-path guard now also recognises `{{run.` so a run's first task is not handed literal braces. Renders empty outside a run.

- **Shadow sessions, Phase 1 (observe + read): `ziya shadow` wraps any terminal command in a PTY, journals it locally, and lets a chat session read it** (`app/shadow/pty_host.py`, `app/shadow/sock_server.py`, `app/shadow/client.py`, `app/shadow/segmenter.py` (rewritten — the on-disk stub was a 37-line truncated file with a syntax error), `app/shadow/journal.py` (one broken line removed), `app/mcp/tools/shadow_tools.py` (new tools `shadow_list`, `shadow_read`, `shadow_comment`, `shadow_set_meta`), `app/mcp/builtin_tools.py` (new `shadow` category), `app/cli.py` (`ziya shadow [--label] [--allow-exec] [--allow-control] [--meta k=v] [--list] [cmd...]`), tests: `tests/test_shadow_segmenter.py` (10), `tests/test_shadow_host_e2e.py` (5), `tests/test_shadow_interactive.py` (1), `tests/test_shadow_tools.py` (4)). Design: `Docs/design/shadow-sessions.md` §2–§5, §7–§9, §11 Phase 1. The four data-layer modules (`journal`, `registry`, `masking`, `segmenter`) existed but nothing ran them; this lands the PTY host (core/frontend split per §6.2 so headless operation is structural), the segmentation ladder (`osc133` → `prompt-heuristic` → `raw`, per-stretch, with DECSET 1049 altscreen suppression), the Unix-socket API (`info`/`read`/`tail`/`search`/`comment`/`subscribe`/`set_meta`, plus truthful `exec_disabled`/`control_disabled`/`not_implemented` refusals for the Phase 2/3 ops), the `C-x C-z` menu (ask composer, relabel, on-demand OSC 133 shell instrumentation typed visibly, exec toggle, quit), and the overlay renderer. No exec, no control leases — read-only by construction. Two defects were found by the tests while building: (1) a `cmd` record could be journaled *after* the output it produced when input arrived on a different thread than the PTY loop, which made `tail` hide the output — `handle_input` now accounts for the line before writing it to the child and the segmenter is serialized with an RLock; (2) `AF_UNIX` path length (104 bytes on macOS) overflowed for deep `$HOME`s — the server falls back to `$TMPDIR/ziya-shadow-<uid>/` and records the real path in the registry. Known Phase 1 limits: a pasted line (`cmd\r` in one read) journals its echo as output once; masking covers input secrets, not displayed file contents (§10).

- **Shadow sessions: echo-off masking fired on every line typed at zsh or bash, masking the whole session** (`app/shadow/masking.py`, `tests/test_shadow_security.py` (+2, parametrized over real `zsh -f -i` and `bash --norc -i`)). Found on first live use: a `shadow_read` of a real zsh session was almost entirely `⏺ input masked (echo-off)` records. The detector keyed on the termios `ECHO` flag alone, but every line-editing shell clears `ECHO` at the prompt and paints its own echo — in raw mode, `ICANON` clear. A genuine password read (`sudo`, `ssh`, `read -s`, `getpass`) clears `ECHO` and stays canonical (`ICANON` set) so the kernel hands it a whole silent line. Measured from the PTY master on macOS: zsh/bash at prompt = `ECHO` off / `ICANON` off; `read -s` = `ECHO` off / `ICANON` on. The detector now requires both, so normal typing is journaled and a `read -s` in the same shell is still masked. The original e2e tests had only used plain `sh`, which keeps `ECHO` on and never exercised the ambiguity; the new tests fail against the old detector. Residual, documented in §7: a raw-mode TUI collecting a secret is not caught by termios state and falls to the output redactor.

- **Shadow sessions: pre-release security hardening — eleven findings, each with a test that fails against the unhardened code** (`app/shadow/redaction.py` (new), `app/shadow/segmenter.py`, `app/shadow/journal.py`, `app/shadow/pty_host.py`, `app/shadow/sock_server.py`, `app/shadow/registry.py`, `app/mcp/tool_result_demarcation.py`, `app/mcp/tools/shadow_tools.py`, `tests/test_shadow_security.py` (new, 12), `Docs/design/shadow-sessions.md` §10.1). Two were masking defects that put typed secrets on disk: the prompt-regex path dropped the echo tail *at Enter*, but a pasted or fast-typed secret's echo arrives afterwards and was journaled (fixed by suppressing output through the next newline after a masked Enter); and in `osc133` mode `on_enter` returned before consulting `masked`, so an instrumented shell recorded the secret echoed between markers B and C as the *command*. One was a model→shell injection path: `shadow_comment` text (and labels, provenance) was written raw to the human's TTY, where an escape sequence such as a cursor-position query makes the terminal *reply on stdin* — bytes the wrapper forwards to the wrapped shell as keystrokes, bypassing the "nothing is typed invisibly" principle; all chat-originated overlay text is now reduced to printables. One was availability: a model-supplied `search` regex runs inside the user's terminal wrapper and `(a+)+$b` hung a handler for over a minute; quantified groups are refused. The rest close the §10 "displayed secrets" gap with a shape-based output redactor as the single choke point before disk (AWS keys, PEM blocks across chunk boundaries, JWTs, GitHub/Slack/Google/`sk-` tokens, `Bearer`, credential-named `KEY=value`, `-pSECRET`; verified not to touch `12 passed`, `input_tokens: 1266457`, `ssh -p2222` or a bare `Password:` prompt), redact recorded argv while the child gets the real one, split multi-line input reads so `sudo cmd⏎password⏎` cannot concatenate into one `cmd` record (later lines are masked fail-closed), verify the connecting peer's UID on the socket (`SO_PEERCRED`/`LOCAL_PEERCRED`) in addition to path perms, `lstat`-verify both shadow directories against symlink/foreign-owner substitution, confine unlink-on-exit to the shadow directories so a corrupt registry entry cannot make `shadow_list` delete arbitrary files, and classify `shadow_read` as `trust="low"` with the SDO-183 sanitizer and encoded-payload scan applied to the transcript. Also `tests/test_shadow_tools.py` switched to `asyncio.run()` — `get_event_loop()` broke once any earlier test in the session closed the default loop.

- **Shadow journals are encrypted at rest when ALE is active** (`app/shadow/journal.py`, `tests/test_shadow_security.py` (+6, now 18), `Docs/design/shadow-sessions.md` §10). Closes the audit's remaining data-at-rest gap: the journal is the one file in the feature that holds terminal content, and a `SIGKILL`ed host leaves it on disk until the next reader reaps it. Every other ALE-aware store in Ziya encrypts the whole file and does read-modify-write; the journal cannot — it is append-only and read over the socket while being written — so it uses a **per-line envelope**: with `session_data` encryption enabled each record is written as `!ale1:<base64 ALE envelope>` (a JSON line always starts with `{`, so the prefix cannot collide), and `JournalReader`, seq recovery in `JournalWriter`, and rotation all decode either form, so a journal stays readable across an enable/disable boundary. Two fail-closed rules: `encode_line` raises rather than fall back to plaintext when the policy requires encryption but `encrypt()` cannot produce an envelope (no DEK), and `decode_line` lets the encryptor's `ValueError` propagate so a reader without key material errors instead of presenting an encrypted journal as empty — the same CWE-476 class the encryptor itself guards against. Tests run real ALE (passphrase KEK in a sandboxed `$HOME`, not a mock): ciphertext on disk with no canary bytes, round-trip through `JournalReader`, seq recovery through ciphertext, rotation cutting on group boundaries through encrypted lines, plaintext when ALE is off, `ValueError` without a key, the plaintext-fallback refusal, and an end-to-end `shadow_read` returning plaintext while the file on disk is ciphertext. Cost ≈0.5 ms/record (DEK unwrap + append), fine for idle-flushed chunks; base64 expands lines ~1.9×, so the 50 MiB rotation cap now holds about half the plaintext history. The registry entry (label, redacted argv, meta) intentionally stays plaintext: it is the discovery index and carries no terminal content.

- **Document PDF exports now carry the per-page footer by default, like every other export route** (`app/routes/export_routes.py` `DocumentExportRequest.include_footer`, `app/services/pdf_exporter.py` `export_document_pdf`, `app/data/built_in_skills.py` (`document_authoring` skill), `Docs/DocumentAuthoring.md`, `tests/test_document_export.py` (+2, seam test gains a positive footer assertion)). `POST /api/export/document` had its own request model whose `includeFooter` defaulted to `False` while the conversation and PDF routes default to `True`, and the `document_authoring` skill text never mentioned the flag — so every document the model exported silently lost the footer, and a reader hunting for the cause was pointed at `layout: report` instead. Both defaults are now `True`; the skill tells the model to pass `"includeFooter": false` only when the user asks for no footer. The seam test previously asserted only that no `footerHtml` landed in the body payload (true in both states) and never checked `footer_template`; it now asserts the footer is present by default, and a request-model test pins the document default to the PDF route's so the two cannot drift again. Both positive tests were confirmed to fail against the pre-fix sources.

- **A stream that closes without a terminal event is now retried instead of silently ending the turn — at the Bedrock provider and, for every provider, in the tool loop's no-output decider — and the empty-completion recovery budgets reset when tools run** (`app/providers/bedrock.py`, `app/streaming_tool_executor.py` (`_decide_no_tool_outcome` new `dropped_stream_retry` branch, reset-on-progress for `textonly_grace_used` and `empty_completion_retry_used`), `tests/test_bedrock_empty_stream_guard.py` (new, 4 tests), `tests/test_decide_no_tool_outcome.py` (+6), `tests/test_stream_loop_recovery_seam.py` (new, 5 tests)). Observed live on claude-fable-5 at iteration 48 of a 48-round tool loop: Bedrock returned HTTP 200 with one non-content event and then closed. The empty-stream guard counted raw events (`_parsed_events == 0`), so a metrics-only stream passed as a success and reached the executor as `text=''`, `tools=False`, `stop_reason=None`. The no-tool decider treats `None` as a clean stop (nudge cap 1), and `textonly_grace_used` — a per-stream budget with no reset — had been spent by a single nudge ~15 productive tool rounds earlier, so the ladder fell through to `('end', 'no_activity')`, logged only at DEBUG. The guard now keys on termination (a `StreamEnd` or `ErrorEvent` was yielded), so a heartbeat- or metrics-only stream yields the same retryable `ErrorEvent` the zero-event case already did and takes the `TRANSIENT_RETRY` path; the warning reports how many non-terminal events preceded the close. `textonly_grace_used` is reset alongside `tools_since_intent_continue` whenever tools execute, so the budget bounds *consecutive* no-progress rounds rather than the whole stream. The decider no longer files `stop_reason=None` under "clean stop": `last_stop_reason` is reset per iteration and assigned only from a `StreamEnd`, and every provider's `StreamEnd` carries a concrete reason, so `None` can only mean the stream closed with no terminal event. It now returns `spend_empty_retry` (a bare re-issue of the identical request — the model said nothing, so there is nothing to nudge against) regardless of the nudge budget, logged at WARNING as `DROPPED_STREAM_RETRY` with the event count; the bare-retry budget resets on tool progress too, so only *consecutive* drops are bounded. Provider tests drive the real `stream_response` through a fake boto3 client; the new seam file drives `stream_with_tools` across five model calls with a scripted provider and a stub at the `execute_single_tool` boundary, asserting on what the model was sent (the conversation per call) and what the user saw (final text) — tool → empty → tool → empty must nudge twice, tool → empty → *drop* must re-issue rather than end, and consecutive empties / persistent drops stay bounded. All positive tests were confirmed to fail against the committed pre-fix sources.

- **`memory_search` no longer promotes probationary proposals to active memory on a substring hit, and the lifecycle's signal check tolerates malformed rows** (`app/mcp/tools/memory_tools.py`, `app/memory/lifecycle.py` new `_has_signal` + `searched_and_used` rule, `tests/test_memory_search_probation.py` (new, 8 tests)). When an active-store search missed, `memory_search` scanned the *legacy* `proposals.json` with a raw `query in content` substring test and called `approve_proposal()` on every match — writing straight into `memories.json` with `learned_from="observation"`, no corroboration, no use signal, and no probation. It also read the wrong store: the live probationary queue is `ProposalsStore` (`probationary.jsonl`), so the path was operating on 15 stale rows while 266 open proposals were invisible to it. The fallback now searches `ProposalsStore.list_open()` with the same tokenizer the active search uses (every query token must match, so a broad query cannot dump the queue), returns matches labelled `[probationary — unverified]` with a `probationary` count instead of `auto_promoted`, and records a `search_hit` signal. Promotion stays with the lifecycle engine, which gains one rule: `search_hit` AND `response_match` → `searched_and_used` — the model explicitly looked for the entry *and* used it. `search_hit` alone never promotes and does not block rule-4 archival. `_has_signal` also now skips non-dict entries in `signals`, so one corrupt row cannot crash a lifecycle sweep (a purgatory test covered this against a working-tree hardening the rule refactor had overwritten; folded into the shared helper). Tests assert the proposal stays `open` and absent from `memories.json` after a hit, the signal is recorded, the lifecycle rule fires only with both signals, and — via `git show HEAD` — that the committed source still called `approve_proposal`, confirming the positive tests exercise the fix.

- **The always-on visualization prompt block is back under its 1550-token budget (~2156 → ~1463 tokens, ~700 tokens saved on every request)** (`app/agents/prompts.py`, `tests/test_prompt_token_budget.py`). Commit `ebf5b140` re-baselined the budget and its message claimed the html-mockup Login Form and drawio XML worked examples had been removed, but neither actually was, and `test_visualization_block_within_budget` / `test_html_mockup_has_no_worked_example` had been red since. Trimmed: both worked examples; the 18-bullet "Capabilities include" list, which restated the "Trigger examples" list one-for-one (the trigger list is kept, and the six renderers it lacked — class/ER, Gantt, drawio, KaTeX, pgfplots, circuitikz/music — were folded into it so no renderer lost its discoverability hook); viewer-feature bullets (export, download, fullscreen) that describe the UI rather than anything the model must emit; and a dangling `**Plotly (analytic & 3D)**: Use ` stub plus the trailing selection paragraph that duplicated the guidance above it. Preserved: FENCE INTEGRITY, the `packet`/`music` fence hints, the inline-styles and script-stripping constraints, the dark-mode colour guidance, and the `figure` modifier. One guard added asserting the duplicate list and stub stay gone while every renderer remains reachable from the trigger list.

- **Duplicating a task card no longer breaks its `sibling()` references** (`app/storage/task_cards.py`, `tests/test_task_card_storage.py` +4 tests). `duplicate()` remints every block id — correctly, since a shared id would let one card's scope approval govern the other's block — but the clone's `{{sibling("old-id")}}` references still named ids that existed only in the original, so a duplicated planner→fan-out card rendered its `for_each` source empty and ran zero iterations. `_assign_block_ids` now returns the old→new map when it replaces ids, and `create()` runs `_rewrite_sibling_refs` over every string field in the tree (not a fixed field list, so a reference in `instructions`, `repeat_for_each_source`, `until_condition` or any future field is carried). References to ids outside the tree (a calling card's block) and fill-only saves are untouched; a test pins the storage and validator reference regexes to the same acceptance so they cannot drift.

- **A `sibling()` reference that names a block by its NAME is now refused at launch with the id to use, reported precisely at run time, and surfaced to a model authoring a card before anyone spends a run on it** (`app/utils/task_card_validation.py` new `_check_sibling_refs`, `app/agents/block_executor.py` new `_diagnose_precise_miss`, `app/mcp/tools/task_card_tools.py` new `task_card_validate` tool + validation findings on every `task_card_write`, `app/mcp/builtin_tools.py`, `app/data/built_in_skills.py` (`task_cards` skill), `tests/test_task_card_validation.py` (+11), `tests/test_for_each_precise_miss_diagnosis.py` (new, 8), `tests/test_task_card_tools.py` (+8)). Measured on a real run: a Repeat with `for_each` source `{{sibling("Build design-doc roster").outputs.roster.docs}}` failed with "0 iterations run … resolved to empty text … have the upstream task emit_artifact a data part". Five layers each behaved defensibly and the defect passed through all of them: the skill's examples show block *names* and never say `sibling()` takes the *id*; ids are minted on save (`b-xxxxxxxx`) where the author never sees them; the launch validator deliberately skipped templated `for_each` sources (the *items* are runtime-only, but the *block reference* is fully static); the renderer collapses "block never existed" and "block not run yet" to the same `""`; and the executor's one-size remedy blamed the upstream `emit_artifact` — the one component that was probably already correct. Now: (1) launch refuses a `sibling("X")` where `X` is not a block id in the tree — when `X` matches a block's name the error quotes `sibling("<id>")` verbatim (or, for an unsaved tree, tells the author to set an explicit `"id"`, which `_assign_block_ids` keeps because it is fill-only); self/ancestor/descendant references are refused; an absent id in `instructions` is a warning rather than an error because a Call'd card may legitimately reference its caller's blocks. (2) An unresolvable *precise* `for_each` source now says which hop missed — unknown block id (listing the ids that have completed), no part of that name (listing the parts emitted), no such key (listing the keys), or not an array — with the old generic text kept only for `{{previous.…}}`, which the registry cannot locate. (3) New `task_card_validate` tool checks an unsaved `root` (or a saved `card_id`) and returns each finding with path and remedy; `task_card_write` now returns the same `validation` block and says in its message whether launch would refuse the card; its description no longer claims "block ids are reassigned on write" (they are not — only missing ones are filled). The `task_cards` skill now states the id contract, shows an explicit `"id"` on the referenced planner in its fan-out example, and instructs the model to validate before emitting a `task-card` fence. The previously failing card is refused at launch with `use sibling("b-c124a946")`.

- **Probationary memory proposals no longer become immortal, and self-corroboration no longer counts as evidence** (`app/storage/proposals.py`, `app/memory/lifecycle.py`, `tests/test_memory_lifecycle_purgatory.py` (new)). Audit of a live store found 246 of 268 open proposals past the archival age yet unresolvable: 233 sat at `corroborations == 1` with no use signal — a state no promotion rule (needs ≥2, or ≥1 + use) and no archival rule (needs 0) ever matched — and 228 of those had been "corroborated" only by the very conversation that proposed them, because extraction re-runs every stream and the projection counted any `corroborate` event, including the proposer's own re-derivation. Corroboration now counts only distinct conversations other than the origin (unattributed events don't count either), which is also the provenance-diversity defense the FARMA self-reinforcement attack (arXiv 2607.05029) argues for. A hard `EXPIRY_AGE_THRESHOLD` (3× archival age) archives anything still unpromoted, `_evaluate_archival` never contradicts `_evaluate_promotion`, `_has_response_match_signal` tolerates non-dict signal entries, and `ProposalsStore.prune_terminal` — previously defined with zero callers while `probationary.jsonl` reached 11.9k events / 3 MB, fully re-encrypted on every append — is now called at the end of each lifecycle pass (keeping the 500 most recent terminal rows). Expect the next sweep to archive the backlog; nothing is promoted by this change.

- **Builtin `[DIRECT]` tools are now presented to the model ahead of external `mcp_*` tools, and the MCP usage rules define what `[DIRECT]` means and route parallel work to Task Cards** (`app/mcp/enhanced_tools.py`, `app/extensions/prompt_extensions/mcp_prompt_extensions.py`, `tests/test_builtin_tool_precedence.py` (new, 4 tests)). Observed live: asked to run research and an audit in parallel, the model picked an external Amazon `mcp_Delegate` tool over Ziya's own task-card skill, and both delegates came back unable to search the web or read files because they run with the *external server's* toolset. Three causes compounded: `create_secure_mcp_tools()` appended external tools first and builtins last, with nothing downstream re-sorting, so with a large external toolset the builtins sat at the bottom of a ~200-entry list; `DirectMCPTool` stamps every builtin description with `[DIRECT]` but no prompt text ever defined the prefix; and an external "orchestrate sub-tasks" *tool* structurally outcompetes a *skill* reachable only through `get_skill_details`. Builtins are now assembled first, and two usage rules were added: prefer `[DIRECT]` tools (which run in-process with the model's context, write policy, memory and full tool set) over `mcp_*` look-alikes, and route parallel/multi-step delegated work through the `task_cards` skill rather than external delegate tools. Task Cards only — the swarm skill is deliberately not advertised here while its interface is reworked, and `task_decomposition` was flipped from `MODEL_DISCOVERABLE` to `USER_SELECTABLE` in `app/data/built_in_skills.py` so the model's skill catalog stops counter-advertising "parallel"/"delegate" against the new rule (it remains loadable by explicit `get_skill_details("task_decomposition")` and toggleable in the UI). The tests (7) assert on the outermost surfaces — assembled list order, rendered prompt text, rendered skill catalog, and the skill still resolving by name — and include git-history guards confirming the committed pre-fix source has the inverse ordering, no `[DIRECT]` definition, and the swarm skill still advertised.

- **Embedding cache no longer accumulates vectors for terminal proposals and deleted memories** (`app/services/embedding_service.py` new `EmbeddingCache.retain_only`, `app/storage/proposals.py`, `app/memory/lifecycle.py` new `_evict_orphan_vectors`, `tests/test_memory_embedding_eviction.py`). `ProposalsStore.add()` caches a vector per proposal so retrieval-feedback can score open proposals, but nothing ever removed it: the live cache held 2,851 vectors for 98 active memories (29×), so every semantic search scanned mostly dead rows and `embeddings.npz` had grown to 3 MB. `mark_promoted`/`mark_archived` now evict the proposal's vector at the transition (safe because `_promote_proposal` re-embeds under the new memory id *before* marking), and the lifecycle pass sweeps any vector belonging to neither a stored memory nor an open proposal — including when the open queue is empty, since a drained queue is exactly where orphans accumulate. `run_lifecycle_pass` reports the count as `evicted`. Also fixed `tests/test_api_memory.py::TestProposals` (3 tests red since the endpoints moved to `ProposalsStore`): the fixture now patches the `ProposalsStore` singleton to `tmp_path`, so the tests exercise the real endpoint path and no longer risk writing to the user's live `~/.ziya/memory/probationary.jsonl`.

- **Memory retrieval-feedback "used" signal made measurable and fact-scale** (`app/memory/feedback.py`, `app/config/env_registry.py`, `app/api/memory.py` new `GET /api/v1/memory/feedback/stats`, `tests/test_memory_feedback_calibration.py`). The signal that drives importance bumps and proposal promotion rules 1/3/3b fired on 2 of 29 loaded memories on the live store, making promotion effectively corroboration-only. Responses are now scored in ~300-char windows (matching the ~300-char average memory) instead of 800-char ones that diluted any match with unrelated text; the window count is capped at 80 by widening the stride so long responses keep their tails scored. Every (memory, response) scoring appends a best-cosine row to a bounded `feedback_stats.jsonl` (ids and scores only, never content), and the new endpoint reports p10/p50/p90 and hit rate against the current threshold so `ZIYA_MEMORY_USE_THRESHOLD` (new, default 0.55, read at call time) can be set from observed data rather than intuition.

- **Duplicate mind-map roots are merged, and the organizer no longer mints `_1` twins of an existing domain** (`app/storage/memory.py` `repair_mindmap` pass 1b / `_merge_duplicate_roots`, `app/memory/organizer.py` `bootstrap_mindmap`, `tests/test_memory_mindmap_integrity.py::TestDuplicateRootMerge`). The live store carried `domain_music_notation` and `domain_music_notation_1` — identical handle, identical refs — because `_find_matching_node` scores handle-word overlap ×2 plus tag overlap ×3 with a floor of 4, so a two-word handle whose tags happened not to overlap scored 2, missed, and the id-collision loop appended a suffix instead of recognising the same domain. `bootstrap_mindmap` now checks whether the derived id already names a root with the same normalized handle and merges into it; `repair_mindmap` folds any same-handle roots into the unsuffixed survivor (union refs/tags/children/cross_links, reparent children, re-point `scope.domain_node`). Seam test reproduces the live case end-to-end through `bootstrap_mindmap` with a mocked clusterer.

- **`pending_proposals` (API) and the system prompt's "N memory proposal(s) on probation" now count the probationary store, not the legacy `proposals.json`** (`app/api/memory.py`, `app/memory/prompt.py`, `tests/test_api_memory.py`, `tests/test_memory_prompt.py`). Both read `MemoryStorage.list_proposals()` — the August-era user-review queue (15 stale rows) — while the lifecycle engine adjudicates `ProposalsStore.list_open()` (268 rows). The number shown every turn was therefore unrelated to the queue it described. Tests now seed a decoy legacy row alongside probationary rows and assert the probationary count is what surfaces.

- **Memory mind-map referential integrity: deleted memories are now detached from `mindmap.json`, the system prompt reports live counts, and a `repair_mindmap()` pass self-heals the store** (`app/storage/memory.py` new `_detach_from_mindmap`/`repair_mindmap`, `app/memory/prompt.py`, `app/memory/maintenance.py`, `tests/test_memory_mindmap_integrity.py` (new, 12 tests)). `MemoryStorage.delete()` removed the memory and its embedding but never edited the mind-map, and `get_memory_prompt_section` rendered `len(memory_refs)` verbatim. On the audited live store this meant 728 of 816 unique refs pointed at deleted memories, 77 of 95 root domains contained zero live memories, and the system prompt advertised e.g. "Kuiper Network Architecture (298 memories)" for a store holding 98 total — while `memory_expand` on those domains returned nothing. `delete()` now sweeps every node's `memory_refs`; the prompt counts only refs that resolve to active memories; and `repair_mindmap()` (run as step 0 of `run_post_save_maintenance`) drops dangling refs, places unplaced active memories, and prunes ghost-only subtrees until stable. Note this prune is intentionally destructive: domains with no live content disappear from the Domain Overview on the next save.

- **Semantic search leg is restricted to ids the search can return** (`app/storage/memory.py`, `app/services/embedding_service.py` new `include_ids` allow-list on `EmbeddingCache.search`/`semantic_search`, `app/api/memory.py`). The embedding cache also holds vectors for probationary proposals and for deleted/archived memories (2,851 vectors for 98 active memories on the audited store); `search()` asked for `top_k = limit*2` over the whole cache and then filtered to active ids, so the semantic half of the RRF fusion routinely returned far fewer than `limit` rows. `/api/v1/memory/embeddings/status` also computed `missing = total - cached`, which went negative (`-2753`) and hid the dilution; it now reports true `missing` plus a `non_active_vectors` count.

- **Selecting a mantle-routed model also raised the CLASSIC account-wide retention switch, which that model never reads** (`app/main.py`, `tests/test_retention_enforcement_paths.py`). `start_server()`'s classic-switch branch had no `endpoint_override` check, so choosing `fable5`, `mythos5` or any `gpt-5.6` variant — all of which are gated on the separate *mantle* switch, applied in its own branch immediately below — additionally PUT `provider_data_share` on the classic `bedrock.{region}.amazonaws.com/data-retention` switch. This directly contradicted the `fable5` config comment, which states that mantle routing means it "no longer touches the classic switch other users' sessions rely on," and it over-granted: the legacy `requires_provider_data_share` boolean resolves to `provider_data_share`, the most permissive rung on the scale, where the only `bedrock-runtime` Covered Model (`fable5.1`) needs no more than `aws_review`. The defect became a *launch-path inconsistency* once the same enforcement was added to `ModelManager._initialize_bedrock_model` (entry below), which does gate on non-mantle: the identical model then had a different account-wide side effect depending on whether it was started as a server or through `ziya chat`. The classic branch now skips mantle models, matching the seam. Note this deliberately removes an accidental convenience — a `fable5` start previously satisfied `fable5.1`'s gate as a side effect, and the classic switch now moves only when a `bedrock-runtime` Covered Model is actually selected. 2 tests, both confirmed red against the unpatched `main.py` (`seam=True server=False`), asserting the gate exists on the server path and that both enforcement paths agree about skipping mantle — the cross-path agreement being the assertion that would have caught the inconsistency when it was introduced.

- **A Covered Model's retention gate was enforced only on the web-server startup path, so `ziya chat` and mid-session `/model` switches reached it with the switch untouched and failed the first call** (`app/agents/models.py`, `tests/test_retention_enforcement_paths.py` (new)). `ensure_bedrock_data_retention_mode` had exactly one call site, in `start_server()`. `main()` dispatches to `cli_main()` for `ziya chat` / `ask` / `review` and never runs that hook, and a runtime `/model` switch re-enters `ModelManager` without going through `main()` at all — so selecting `fable5.1` on either path reached its first invocation with the classic account switch still at `inherit` and returned a retention `ValidationException`, while the identical selection worked when launched as a server. `_initialize_bedrock_model` already carried precisely this reasoning for the MANTLE switch ("the CLI hands off to cli_main() and never runs the server startup hook, so on that path the switch would otherwise keep whatever value a prior session left"), so the classic switch is now enforced at that same shared seam, gated on the model NOT being mantle-routed. Deliberately raise-only — `allow_downgrade` is left at its `False` default, so a user selecting `fable5.1` (`aws_review`) can never lower an account another user has on `provider_data_share`, which is the multi-user invariant that makes a shared account safe. 11 tests; the 3 seam assertions were confirmed to fail against the unpatched `_initialize_bedrock_model` at HEAD, and the suite includes a negative control that the raise *does* fire from `inherit`/`default` plus a guard that any future non-mantle model declaring a retention mode stays reachable by this enforcement.

- **A transient provider fault mid-stream, after partial text had been emitted, killed the turn instead of resuming it** (`app/providers/base.py`, `app/providers/anthropic_direct.py`, `app/providers/openai_direct.py`, `app/providers/google_direct.py`, `app/providers/openai_responses_mantle.py`, `app/streaming_tool_executor.py`, `tests/test_mid_stream_resume.py` (new)). Observed live on anthropic-direct/fable-5: a 500 `api_error` ("Internal server error") arrived after several paragraphs had streamed, and the turn ended with a raw error string and a truncated answer. Neither half of the existing behaviour was wrong on its own — the error classified correctly as `READ_TIMEOUT` (retryable, locked by `test_anthropic_500_retryable.py`), and the provider's refusal to retry was also correct, since a from-scratch retry APPENDS the retry's full response to the partial text the consumer already accumulated (`assistant_text += delta`), which is the duplicated-message corruption that refusal exists to prevent. The defect was **vocabulary**: "cannot retry at THIS layer" was collapsed into the same `retryable=False` signal used for a genuinely fatal 400, and the orchestrator's `non_retryable_error_surfaced` branch treats both identically — so a recoverable blip took the same path as a `CONTEXT_LIMIT`. Meanwhile the machinery to recover already existed and was unreachable from here: `_continue_incomplete_code_block` rewinds `assistant_text` to its last complete line, replays it as an assistant prefill (or as in-prompt context on models without prefill support), and streams the remainder — reachable only from `handle_message_stop` when a code fence was left open on `max_tokens`. `ErrorEvent` now carries a third state, `resumable`, set by every provider that owns the content-yielded refusal (all four, parametrized in the tests, because this class of fix has drifted between providers before) and **withheld once a `ToolUseStart` has been seen**, since a text prefill cannot complete a half-emitted tool call. The executor routes a `resumable` fault to a rewind (`continuation_rewind`, so the partial tail is dropped from the rendered content and from the server's own validation copy) followed by a resume through the now-generalized helper — which takes a `resume_prompt` to replace the fence-specific wording (the old wording invites the model to invent a code block that was never there) and `strip_leading_fence=False` so a legitimate opening fence at the start of the continuation is not mistaken for a redundant header. Bounded and fail-loud by construction: at most one resume attempt per iteration, the turn ends after a successful resume rather than falling through to the no-activity decider (whose empty-completion retry would re-send the request), and a resume that produces no content surfaces the ORIGINAL provider error rather than silently handing back a truncated answer. 16 tests across all three layers — the event field, per-provider marking including the tool_use exclusion, and the executor seam driven end-to-end through `stream_with_tools` with a scripted provider — confirmed red against the unpatched tree, with the harness itself separately verified to pass against pre-fix code so a later failure is attributable to the change rather than the fixture. The resume also flushes every display buffer — content optimizer, `_block_opening_buffer`, viz buffer, content buffer — BEFORE measuring the rewind point, mirroring the order `handle_message_stop` already uses, and the "is there any text to prefill from" guard was moved to AFTER that flush. This is not defensive tidying; three shapes were measured failing without it, because `rewind_line` counts lines of `assistant_text` while the consumer can only rewind what it has actually RECEIVED. `StreamingContentOptimizer` withholds any buffer under `min_chunk_size` (15 chars) and refuses to flush at all once an unclosed ``` is seen, so a run of short lines and an opened code fence both left the consumer holding NOTHING while `assistant_text` held every line — the frontend's `applyContinuationRewind` then rejected the out-of-range offset, leaving the partial tail on screen for the resume to duplicate, and the un-flushed lines were never displayed at all. Worse, text ending in bare backticks lands in `_block_opening_buffer`, which `process_text_delta` withholds from `assistant_text` entirely, so the pre-flush guard saw an empty `assistant_text`, declined to resume, and killed the turn — the exact failure this change exists to remove. Only the `_block_opening_buffer` flush appends to `assistant_text` (the others were already accumulated by `process_text_delta`), matching the asymmetry in `handle_message_stop`. 6 further tests drive these shapes end-to-end through a port of `continuationProtocol.ts`, asserting on what the CONSUMER would hold rather than on executor internals, and are paired: one that the rewind is appliable, one that the buffered text is not silently dropped — since a resume emitting nothing would satisfy the first trivially. Note this does not touch `bedrock.py`/`nova_bedrock.py`/`openai_bedrock.py`, which structure their retry differently and have no content-yielded refusal to reinterpret.

- **A conversation shared across projects via a global folder now follows the project switch instead of disappearing or losing focus** (`app/storage/global_items.py`, `app/api/chats.py`, `frontend/src/context/ChatContext.tsx`, `tests/test_global_inheritance.py`, `frontend/src/context/__tests__/globalConvProjectSwitchWiring.test.ts` (new)). Two independent defects, both of which had to be fixed for the chat to survive the switch. **Server:** a global folder is visible in every project, so a chat created while viewing project B legitimately carries a `groupId` whose group RECORD lives in project A's `_groups.json` — but `collect_global_chats`/`collect_global_chat_summaries` resolved group globalness with `_effective_global_group_ids(project_dir)`, i.e. against the project that holds the CHAT FILE. The group was therefore unknown there, the chat read as non-global, and it was never surfaced cross-project; measured on a live workspace, a 26-chat shared folder crossed over with 14 chats — exactly the ones carrying their own `isGlobal` flag — and the 12 that were global purely by folder inheritance were silently absent. Group ids are unique, so globalness is now resolved against the union of every project's effective-global set (`_all_effective_global_group_ids`, mtime-cached per project, one `stat()` each in steady state); the single-chat cross-project fallback in `get_chat` used the same per-project lookup and would have 404'd on hydration, so it moves to the union too. **Frontend:** the project-switch effect passed the `folders` React state to `conversationIsEffectivelyGlobal`, and the `PROJECT_CLEAR` layout effect empties that state before the effect body runs — so at switch time every inherited-global chat evaluated as project-local, was filtered out of the IDB preload, and lost selection to whatever project-local conversation happened to be most recent (the observed "I end up in an unrelated conversation"). Both call sites now read the full folder set from IndexedDB, matching what the sync commit path already did. Section 7's relocation guard separately tested the raw `currentConv.isGlobal` flag, which is false for a chat shared via its parent folder, so it relocated away even when the preload had kept the right chat selected; it now uses the effective-global check. The new tests fail against the unpatched source (3 of 4 frontend wiring assertions, 5 backend cases) and the negative controls — a non-global foreign group must NOT surface, and `belongsToNewProject` must keep its project-local test — pass both before and after.

- **Task card activity now bumps the bound conversation's recency, so a chat whose only activity is a running task card sorts to the top of the conversation list** (`app/storage/chats.py` new `ChatStorage.touch()`, `app/storage/task_runs.py` new `_touch_source_conversation()`, `tests/test_task_run_chat_recency.py` (new)). Previously every task-run write (launch, status transitions, heartbeats, Ask open/close) touched only the run file — the chat's `lastActiveAt` never moved, so a conversation with an active or newly-finished run could sit buried mid-list while idle chats sat above it. `TaskRunStorage` now touches the source conversation on the meaningful lifecycle writes (`create`, `update_status`, `record_activity`, `mark_held`, `open_ask`, `close_ask`), throttled to one chat-file rewrite per 30s per chat (module-level, since API paths construct storage per-request) because a touch rewrites the whole chat file and heartbeats arrive every few seconds. `touch()` also bumps `_version` so the next client bulk-sync sees the server copy as newer, matching the mute endpoint's pattern. Best-effort by construction: a headless/scheduled run (no `source_conversation_id`) and a deleted or cross-project chat are silent no-ops that never break the run write. 8 tests, confirmed red against the unpatched tree.

- **Task-card tiles in a global chat viewed from a non-owning project now target the binding's own project, so their follow-up calls stop 404ing** (`frontend/src/components/TaskCard/TaskCardInlineTile.tsx`, `frontend/src/components/TaskCard/__tests__/crossProjectBindingTarget.test.ts` (new)). The backend half of this seam already existed: `list_task_bindings` (app/api/task_bindings.py) resolves a global chat's bindings from the chat's owning project and stamps `binding.project_id` on every returned binding, explicitly "so the client targets its card / run / iteration / cancel / rerun calls at the OWNING project rather than the possibly-different viewing project." The frontend half was never connected — `TaskBinding.project_id` was typed and documented but read by nothing, and both `LaunchedCardTile` and `StagedCardTile` hardcoded `currentProject?.id`, so from a non-owning project every follow-up call (card fetch, signature status, run stream, cancel/pause/resume/step, ask answer, resume-from, rerun) targeted the wrong project and 404'd. Both components now derive `projectId` as `binding.project_id ?? currentProject?.id ?? ''`; bindings that arrive without the stamp (POST create/resume responses) fall back to the viewing project, which is correct because those requests were targeted at it. This also closes the resume-binding concern flagged alongside the recency fix: with the client passing the owning project, the resume endpoints' `TaskBindingStorage(get_project_dir(project_id))` writes land in the owning project's binding file — the same file the cross-project fallback reads. 4 source-seam tests (following the `stagedCopyWiring.test.ts` pattern), 3 confirmed red against the unpatched tile, including a negative guard that the viewing-project-only derivation never reappears in the file.

- **Concurrent `render_diagram` calls could intermittently produce an empty/degenerate SVG because the headless renderer serialized only browser creation, not the ren