CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- ═══════════════════════════════════════════════════════════════════
HOW TO ADD ENTRIES:
All new changes go in [Unreleased] below. NEVER add to a numbered version.
When a release is cut, [Unreleased] is renamed to the new version number
and a fresh empty [Unreleased] section is created above it.
Versions are listed newest-first (reverse chronological).
═══════════════════════════════════════════════════════════════════ -->
## [Unreleased]
### Added
### Fixed
### Changed
## [0.9.0.1] - 2026-11-03
### Added
- **Recent-only view for the conversation sidebar** (`frontend/src/utils/chatTreeRecency.ts` (new: `applyRecencyFilter`, `RECENCY_WINDOWS`, `loadRecencyWindow`), `frontend/src/components/MUIChatHistory.tsx`; tests `frontend/src/utils/__tests__/chatTreeRecency.test.ts`, `frontend/src/components/__tests__/chatHistoryRecencyWiring.test.ts`). Long lists were burying the conversations in use. A clock button in the sidebar toolbar (not shown during a search) opens a menu with *Show all*, *Active in last 24 hours* and *Active in last 7 days*; a caption under the toolbar names the window, counts the hidden conversations and offers *show all*. Recency is the last write (`lastActiveAt`, falling back to `lastAccessedAt` for records without a server stamp). Folders with nothing visible left are hidden unless created or edited inside the window; the current, streaming and running-task conversations always show; a stale conversation that anchors visible forks or TaskPlan folders stays. The choice is persisted browser-wide under `ZIYA_CHAT_RECENCY_WINDOW`. The filter is applied to the cached tree's output rather than inside the tree memo, so the structural hash, sort-only fast path and folder-count cache guard are unchanged.
- **Memory tool results in the chat unfold to show the memories touched** (`frontend/src/components/MemoryCard.tsx`, `frontend/src/utils/mcpFormatter.ts` (`MEMORY_TOOL_KIND`, `MEMORY_TOOL_NAMES`, `parseMemoryEntries`), `frontend/src/components/MarkdownRenderer.tsx`, `app/mcp/tools/memory_tools.py`; tests `frontend/src/utils/__tests__/mcpFormatterMemoryRecall.test.ts`, `frontend/src/components/__tests__/MemoryCardFold.test.tsx`, `tests/test_memory_retract_proposal.py`). `memory_search` / `memory_expand` / `memory_context` used to fall through to the generic shape digest (a headline and a `count` fact); `memory_delete` and `memory_retract_proposal` showed only a message line. All seven memory tools now render the memory card. The card has a fold chevron in its header: a recall card opens folded showing the count and the query, and unfolds to one row per memory with id, layer, flags (contested / probationary) and tags; propose / save / delete / retract open with the text visible and fold on click. Retract now echoes the withdrawn text, layer and tags, and delete echoes tags, so the card has something to show.
- **Memory-store backup script** (`scripts/backup_memory_store.py` (new)). Snapshots `~/.ziya/memory` into `<project>/.ziya/memory-backups/<UTC stamp>/` (gitignored, survives reboots, unlike `/tmp`), copies the ALE-encrypted store files byte-for-byte so no plaintext is written, and records a sha256 + size manifest per file. `--label`, `--verify <dir>` and `--list` are supported; the last 12 snapshots are kept.
- **Sharded pytest suite runner** (`scripts/run_suite_sharded.py` (new)). `start [--shards N]` deals every `tests/**/test_*.py` round-robin by size into N detached `pytest` processes writing `shard_N.log` / `shard_N.xml`; `status` shows per-shard liveness and the last summary line; `report` parses the junit XML and lists failures grouped by file.
### Fixed
- **`ziya task` logged every Flex-tier probe as a WARNING and two ERRORs** (`app/utils/custom_bedrock.py` (`custom_invoke`, `_retry_with_extended_context`); `tests/test_custom_bedrock_service_tier_logging.py` (new, 4)). A Task Card run requests the discounted Flex tier optimistically and `BedrockProvider` retries once at the standard tier when a model rejects it, remembering the answer per model for the process. The `CustomBedrockClient` wrapper between them had no notion of that contract, so the expected rejection printed `🔄 INITIAL_ERROR`, `Error in custom invoke` and `Model ID being used` on every first call to each Claude model a card's blocks used (one per `model_tier`), which read as the run failing repeatedly. The wrapper now recognises the tier rejection, notes it at DEBUG and hands it straight back; every other error logs exactly as before. The same wrapper's extended-context retry had a catch-all that rewrote *any* `ValidationException` into "content is too large even for extended context", which on a large first request to a model in a task run (the proactive extended-context path) would have hidden the tier wording from the provider, so the model was never marked and the tier was re-probed on every call; that branch now passes a tier rejection through unchanged.
- **Tributary-fold gate oscillated and then wedged a 1M-context conversation over the input cap** (`app/utils/tool_history_rewrite.py` (`FoldPressure.chars_per_token`, `_saved_tokens`, `_payload_ratio`, `fold_pressure_for`, `plan_folds`), `app/utils/handoff_prelude.py` (`record_context_pressure(chars=)`, `record_context_overflow`, `messages_text_chars`), `app/streaming_tool_executor.py`, `app/server.py`; `tests/test_fold_gate_units_and_overflow.py` (new, 12)). Reported by mbcohn: Bedrock rejected every send at 1,003,527 tokens while the gate read ~460k; usage.db showed ~970k and ~350k sent on alternating turns. The gate rebuilt the unfolded size as billed tokens plus fold savings converted at the calibrator's *learned* chars/token ratio, which on that install was 13.4 against a real ~2.5, so savings were undercounted 5× going in and the fold target overshot 5× going out. Savings are now converted at the ratio the measured payload was actually billed at (both pressure producers record the payload's text chars with the tokens). Separately, a "prompt is too long" rejection produced no usage event, so the pre-send estimate stayed on record and every retry sent the identical payload; the executor now records the rejection's token count as pressure, so the next send folds.
- **Token calibrator learned 13.4 chars/token from image bytes counted as text** (`app/utils/token_calibrator.py` (`payload_text_chars` (new), `PLAUSIBLE_RATIO_BANDS`, `plausible_band`, `_discard_implausible_samples`), `app/streaming_tool_executor.py` (`_record_calibration`); `tests/test_token_calibrator_text_chars.py` (new, 9), fixture `tests/fixtures/mbcohn_token_calibration_2026-11-02.json`). The per-file attribution cancels algebraically, so every sample carries one turn's whole-payload `chars / tokens`; that char count was `len(str(message['content']))`, the Python repr of the block list, which for a pinned screenshot is ~700k "characters" billed at ~1.5k tokens. With a few images in context every turn's ratio came out ~5× high and piled up just under the generic 15 cap (88% of the reporter's opus-5-5 samples). Chars are now counted text-only (image/document blocks are 0), a Claude sample must fall in 1.5–6.0 chars/token, and a persisted file is purged of out-of-band samples and globals on load, so an already-poisoned `~/.ziya/token_calibration.json` heals on the next start.
- **Token bar could sit green while the provider rejected the send as too long** (`frontend/src/components/TokenCountDisplay.tsx`; test `components/__tests__/tokenBarZoneUsesServerPressure.test.ts`). The bar's colour zone used only the client estimate; the Hand-off affordance beside it already used the higher of that estimate and the server's record of the last turn's actual input size. The zone now uses the same rule, so a bar cannot read 20% green after a 1,003,527-token rejection. The figure shown is still the estimate; when the server figure is what drove the zone, the bar and total-row tooltips add "the last send used about N% of the context; this estimate is lower than what was actually sent". Nothing about billed usage is displayed.
- **A collapsed shell result counted a heredoc body as output and buried the failure line** (`frontend/src/utils/toolDigest.ts`: `splitShellCommand`, `digestShell`; tests in `utils/__tests__/toolDigest.test.ts`). The shell digest dropped only the `$ ` line, so `python3 - <<'EOF' … EOF` previewed its own script as "4 lines of output". The command now runs through its heredoc delimiter (or backslash continuations) and is exposed as `digest.command`; the exception line of a Python traceback, or a `command not found` / `No such file` / `fatal:` / `Error:` line, is surfaced in the existing amber warning strip, while a line that merely mentions an error class is not. Ported from the unlanded Oct 2 row-model design; its orphaned tests (`toolRowDigest.test.ts`, `ToolBatch.test.tsx`, failing to load for a month) are removed — the rest of that design was superseded by the shape digest and the backend-marked `ToolGroup`.
- **Opening a conversation hung the tab (RESULT_CODE_HUNG) when it held a large truncated tool result** — the collapsed tool block's digest ran a `$`-anchored truncation-marker regex over the whole payload, which is quadratic on a long single-line JSON blob (160 KB ≈ 12 s; a ~1 MB MCP result never finished). The marker is now stripped from the last 256 bytes only, anchored at the last `[`, so a `[` inside an earlier string value can no longer start the match and discard complete elements.
- **Streaming sidebar rows no longer show a stale timestamp** (`frontend/src/components/MUIChatHistory.tsx`, `frontend/src/context/ChatContext.tsx`, `ActiveChatContext.tsx`). The row's trailing time came from `lastAccessedAt`, which is only stamped when a message is appended, so a conversation deep in a tool-heavy turn kept showing the previous turn's end, tens of minutes old, next to its spinner. While streaming the slot now shows time since the last stream activity (`getProcessingActivityAt`, fed by the processing-state stamp, refreshed at most every 30 s during an uninterrupted text stream) and ticks every 30 s, so active turns read "now" and a stalled one visibly ages.
- **Task-card steer notes actually ship** (`app/models/task_run.py` (`TaskRun.steer_notes`), `app/storage/task_runs.py` (`enqueue_steer` / `mark_steer_delivered` / `expire_steers`), `app/streaming_tool_executor.py` (`stream_with_tools(feedback_key=…)`), `app/agents/task_executor.py`, `app/api/task_runs.py` (`POST /task-runs/{id}/steer`), `frontend/src/components/TaskCard/SteerComposer.tsx` (new), `TaskRunInspector.tsx`, `TaskCardInlineTile.tsx`, `useTaskRunStream.ts`, `taskRunApi.ts`, `task_run.ts`, `task-card-inline-tile.css`). The 0.8.8.8 entry for this feature described diffs that were never applied: only `app/utils/task_steer.py`, `tests/test_task_steer.py` and the two frontend tests landed, so the endpoint, the storage methods and the composer did not exist and the 10 tests that exercised them had been failing since 2026-09-22. The implementation is re-anchored on the current tree and is otherwise as described there.
- **Diff applier: a same-file multi-block diff no longer drops every hunk after the first** (`app/utils/diff_utils/pipeline/pipeline_manager.py` (`clean_duplicate_headers`, now per `diff --git` block; the merged same-file text now reaches `original_diff`, which is what the stages read; the shadowed first copy of `clean_duplicate_headers` removed), fixture `markdown_renderer_fence_lookahead_false_applied`). A model that emits one `diff --git` block per hunk for the same file repeats identical `---`/`+++` lines; the text-wide dedupe stripped them from the later blocks, the splitter had no file to attach those hunks to, and they were silently discarded — or, on another path, the orphan `+++ b/...` line was written into the file as a context line.
- **Diff applier: an over-counted `@@` header no longer misleads the apply stages** (`app/utils/diff_utils/parsing/diff_preprocessor.py` (`lower_overcounted_hunk_headers`, new), `pipeline_manager.py`; fixture `json_escape_sequence`; `tests/test_diff_overcounted_headers_and_same_file_blocks.py` (new, 5)). A header declaring more lines than its body made the parser read past the hunk. It is lowered to the body count, but only for a hunk bounded by a following header — the final hunk's surplus can be truncated trailing context, and the fuzzy matcher's placement depends on it (`prompts_manager_log_level_change` lands on the wrong line otherwise). The single-hunk whole-file-replacement heuristic also fired on an 8-line header over a 6-line body, rewrote the file from the body (dropping `return parsed; }`), copied the diff's indentation onto untouched context lines, and reported `changes_written=False` for a file it had changed; it now requires a 5+ line surplus, takes context lines from the file, and records the write.
- **Diff applier preserves an added line's trailing whitespace** (`app/utils/diff_utils/application/patch_apply.py`, four sites). `rstrip()` was used where only the newline should come off, so a `+` line ending in a space lost it (and a file line's trailing spaces leaked into the derived line ending). git apply keeps it; so do we.
- **Diff fixtures reconciled** (`tests/diff_test_cases/`): `false_already_applied_similar_block`'s `expected.tsx` was built from a different revision than its `original.tsx` (229 lines differed outside the hunk) and is regenerated from the original; `function_collision` becomes `expect_error` — a diff adding a second top-level `def process()` is refused by the Python validator as the signature of a replace-hunk misapplied as an insertion, which is the behaviour `tests/test_diff_language_handlers.py` pins; `additive_replace_bug` passed a relative path the pipeline never resolves; `test_apply_state*` now expect `already_applied` for an already-present diff; `test_malformed_hunk_warning_dedupe` captures `debug` (the parser demoted that line deliberately); `test_diff_pipeline_language_validation` follows 3ab92b0c — rollback is for fuzzy-placed hunks, an exact-anchor apply is kept even if the author's diff breaks syntax (companion test added). Eleven fixtures remain red; all have been red since at least July and are genuine misplacements (`MRE_*`, `multi_chunk_changes`, `multihunk2`, `simple_three_hunk_insert`, `not_matching_context_multipart`, `list_gemini_models_rest_api`, `d3renderer_container_styles`).
- **Release/sweep tests follow the sweep task to the deck** (`tests/sweep_task_source.py` (new), `tests/test_release_task.py`, `tests/test_release_announcement_skill.py`). `ziya task sweep` resolves against `.ziya/tasks.yaml` first and the project's deck card second; the linear file task was demoted to `sweep-old` on 2026-10-01 so the orchestrated card could take the name, and 13 tests that read `tasks["sweep"]` directly had errored since. They now resolve the task the way the CLI does, from a module-scoped fixture (the suite's per-test `ZIYA_HOME` sandbox hides the real deck from inline calls), and the four assertions on the linear prompt's `## Step N` numbering are expressed as block order and best-effort grouping for a card. `MRE_incorrect_hunk_offsets` fixture rewritten: its diff wrapped an if/elif chain in `try:` without re-indenting the bodies, so no placement could yield valid Python; hunks 3–4 and their offsets are unchanged. `test_custom_bedrock` pins `custom_bedrock._current_conversation_id`, which the streaming executor leaves set as a module global.
- **`parse_output` reports structured provider errors again** (`app/agents/parse_output.py`). The `{"error": ..., "detail": ...}` → `"Error: <detail>"` branch sat after an unconditional `return`, so it had been unreachable and the raw JSON reached the user; the check now runs first (`_error_agent_finish`) and the dead tail is gone.
- **Context-overflow retry no longer needs a conversation id** (`app/utils/custom_bedrock.py` (`_retry_with_reduced_max_tokens`)). The `max_tokens` reduction was reachable only through an `elif` that required extended-context support *and* a conversation id, so a direct caller's overflow was re-raised unretried. Both overflow paths now share one helper.
- **Legacy `backend_system_tests` reconciled with the code** — 26 failures. Deleted tests that asserted on retired internals (`nova_wrapper.BedrockRuntime`, langserve `RunLog`/`RunLogPatch`, bare-text SSE, a `ChatGeneration` stream shape nothing yields, mock agents defined inside the test); updated the rest to the live contracts (`data: {"content": ...}` SSE, `_check_auth_quick` returning `(ok, msg)`, the retuned `FILE_TYPE_MULTIPLIERS`, no 50k-token skip since e8399a1f, `sonnet3.5` → `sonnet4.6`); re-registered the Gemini post-instruction after the test's own registry wipe. `test_aws_auth`, `test_provider_token_accuracy` and `test_model_capabilities` (live credentials; 117 failures in CI) now carry `pytest.mark.integration`, which `pytest.ini` already excludes by default.
- **Stale test doubles brought up to date with the code they stub** (`tests/test_git_ops_amend_force.py`, `test_sibling_by_id.py`, `test_sibling_propagation.py`, `test_task_service_tier.py`, `test_until_*.py`, `test_self_improve_judge_evidence.py`, `test_task_launch_root_fallback.py`, `test_resume_launch_seeds_callee_prefix.py`, `test_tool_gating.py`, `test_project_tool_modes.py`, `test_task_tool_floor.py`, `test_task_executor_headless_tool_denial.py`). `execute_task_block` stubs now accept `headless=`; `StreamingToolExecutor.__new__` fixtures set `.provider`; launch tests patch `run_driver.start_run` instead of the removed `task_cards.asyncio.create_task`; the consent test uses `SCOPE_PROJECT`, which replaced `SCOPE_SESSION` in 8f209cec.
- **A model pinned to a conversation is sent from a region that serves it** (`app/streaming_tool_executor.py`). The session region is resolved for the server-wide model, so a conversation pinned to a different model could be sent from a region without a profile for it and rejected (seen when the eu failover had written `eu-west-1`). The executor now clamps the region with `region_for_model` for that request before the model id, client and provider are built; a model with a profile for the session's geography keeps the session region.
- **"Never mind" stops a running turn, and tool execution uses the same stop rule** (`app/utils/feedback_directives.py` (`is_stop_directive`), `app/tool_execution.py`). The negation scan ran before the whole-phrase match, so the "never" in "never mind" vetoed its own stop phrase; the whole-phrase check now runs first, and a negation such as "don't stop" or any message with extra content is still passed to the model as feedback. Mid-tool feedback was checked with a separate substring test that treated any message containing *stop*, *halt*, *abort*, *cancel* or *quit* as a stop; it now uses the shared `is_stop_feedback`.
- **Concurrent diagram renders no longer produce empty or degenerate SVGs** (`app/services/diagram_renderer.py`). Every `render_diagram` call shares one Chromium, and the existing lock only covered browser creation, so concurrent calls laid out pages in parallel and measurement-sensitive renderers (mermaid's `getBBox` pass especially) intermittently emitted an empty SVG for input that rendered fine alone. Each render now holds a render lock from opening the page until it is closed, matching the frontend's `MermaidRenderQueue`.
### Changed
- **Data-retention gate errors include what to do about them** (`app/providers/bedrock.py`). When a Covered Model call is rejected for data retention and the automatic restore fails, the error now ends with a remediation hint built from the restore's denied IAM action and the diagnosis (`retention_diagnostics.remediation_hint`). When neither names an action the error is unchanged.
- **Developer scripts, design notes and test coverage** — no runtime behaviour change. Scripts: `scripts/mem_probe/baseline.mjs` (attributes a tab's idle memory floor by removing iframes and off-screen messages step by step), `scripts/complandscape_hitlist.py` (stack-ranked build list projected from the competitive-landscape corpus with a printed formula), `scripts/gen_theme_v2.py` (theme-music generation script). Design: `design/asset-substrate.md`, `design/data-reuse-endemic.md`, `design/data-reuse-spec.md`, `design/kuiper-telemetry-connector-guidance.md`; `Docs/FeatureInventory.md` lists the recent-only sidebar view. Code comments: why the Python handler's duplicate-function check is not narrowed to identical copies (`app/utils/diff_utils/language_handlers/python.py`), and `get_schedule_state` always carrying `registered` / `enabled` (`app/api/task_cards.py`). New frontend tests: inline-math ReDoS guard, bench sync / migration / tool-feedback hooks, chat-history row selection wiring, app lazy-chunk boundary, usage/conversation API client. `templates/index.html` and `templates/asset-manifest.json` are rebuilt from the current frontend.
## [0.9.0.0] - 2026-11-01
### Added
- Shell-config plugins can extend the credential paths the shell refuses to upload with `curl @file` (`app/plugins/interfaces.py` `ShellConfigProvider.get_additional_sensitive_paths`, `app/plugins/__init__.py` `get_shell_config_additions`, `app/mcp_servers/shell_server.py`). The built-in set keeps only generic paths (`~/.aws/`, `~/.ssh/`, key files, ...); deployment-specific ones, such as an SSO cookie directory, come from the plugin.
- Plugins can add their own sections to `ziya --info` (`app/plugins/interfaces.py` `InfoProvider`/`InfoSection`, `app/plugins/__init__.py` `register_info_provider`/`get_info_sections`, `app/main.py` `print_info`). A failing provider shows as an `Error` row instead of breaking `--info`. The check for a specific registry CLI on PATH was removed from the core; deployments that need it supply it through this hook. Documented in `Docs/Enterprise.md`.
- When a Covered Model call is rejected for data retention and Ziya's automatic restore is refused by IAM, the error now ends with a **What to do** line for users on their own AWS account. It names the IAM role or user, the regions below the required mode, the policy statement to grant (`bedrock:GetAccountDataRetention`, `bedrock:PutAccountDataRetention`), the one-time per-region setting an administrator can apply instead, and what `aws_review` means for the data. A denial by an organization service control policy is reported as "this model cannot be enabled in this account" instead. AWS denial text in the restore error is kept to 400 characters so the reason is no longer cut off (`app/utils/retention_diagnostics.py` (`remediation_hint`, `describe_principal`), `app/utils/aws_utils.py`, `app/providers/bedrock.py`; test `tests/test_retention_remediation_hint.py` (new); `Docs/EnvironmentVariables.md`).
- Files shown in chat (`show_document`) have a **Download file** button and, when only a section or line range was shown, a **Show whole file** toggle that expands the block in place. Both read the file as it is on disk now through the existing confined file endpoint (`GET /api/v1/drag/file`); if the file changed since it was shown (SHA-256 differs) the block and the download say so. If the file can no longer be read, Download saves the text that was shown, named as a range (`report.lines-100-200.md`) so it is not mistaken for the whole file. Inline expansion is capped at 2 MB (`frontend/src/utils/documentDownload.ts` (new), `frontend/src/components/MarkdownRenderer.tsx`; test `components/__tests__/showDocumentDownload.test.tsx` (new)).
- CLI-style input history in the web composer. Up in an empty composer recalls the last message sent in the current conversation. Further Up/Down presses step through earlier and later messages, and Down past the newest clears the composer. In a recalled multi-line message the arrows move between lines and only cycle from the first or last line. Editing a recalled message ends browsing. History is taken from the conversation's own messages, so each conversation has its own. Left in an empty composer jumps to the first message of the conversation; Right in an empty composer, or with the caret at the end of the input, jumps to the latest and resumes following a streaming response. Both respect the bottom-up display order (`frontend/src/utils/composerHistory.ts` (new), `frontend/src/utils/conversationJump.ts` (new), `frontend/src/components/SendChatContainer.tsx`, `frontend/src/components/App.tsx`; tests `utils/__tests__/composerHistory.test.ts`, `utils/__tests__/conversationJump.test.ts` (new)).
- Optional extras now survive upgrades. One registry (`app/utils/extras_registry.py`, new) declares the five extras (`--browser`, `--latex`, `--data`, `--stats`, `--drag`) and drives the installer's tables, the startup banner (which now names every missing extra, not two of them), a new `GET /api/extras` / `POST /api/extras/{key}/install` endpoint, and an "Optional extras" card on the System Information page with an Install button for the extras the server can add itself (browser, data, stats, drag) and the command to paste for the one that needs sudo (TeX on macOS). `~/.ziya/extras_state.json` records which extras the user installed (an extra found already present is adopted as opted in); at startup any opted-in extra that has gone missing, is not platform-blocked and needs no sudo is re-installed in the background with a log line, so a Playwright bump that moves the Chromium build no longer costs `render_diagram` until someone re-runs the installer. Never-installed extras are only announced. `ZIYA_EXTRAS_AUTO_REFRESH=0` turns the refresh off. The browser status text now names the build the installed Playwright pins and any other build found in the cache ("Chromium build chromium_headless_shell-1208 for Playwright 1.58.0 (found chromium_headless_shell-1194, built for a different Playwright)") (`app/utils/optional_features.py`, `app/utils/install_extras.py`, `app/api/extras.py` new, `app/server.py`, `frontend/src/components/SystemInfo.tsx`; tests `tests/test_extras_registry.py`, new).
- Music notation for ensemble and harp scores, found by re-typesetting a two-harp score (Pratt, "Round", Harp 1 mm. 19-52) from a photograph (`frontend/src/utils/d3Plugins/musicPlugin.ts`, `app/data/built_in_skills.py` music skill; tests `musicEnsembleScoreGaps.test.ts` (new, 24) with the score as a fixture). `crossStaffLines` draws the "l.h." hand-crossing line from a note on one staff to a note on the other, labelled by the lower note. A measure-level `section` boxes a rehearsal mark mid-score (the spec-level one could only mark the opening bar). Brackets take `hooks: "both"` for the solid, two-hooked phrase/entry bracket, overlapping brackets stack onto separate bands, and every bracket rises (or, below the staff, drops) to clear the notes it spans; an 8va over a ledger-line D6 used to overprint the note. A rehearsal mark sits above a bracket on its bar, and a tempo mark is lifted (the first system moved down to match) when a first-system bracket rises past its band. Layout: explicit `systemBreak`s now define the lines, so the default width budget no longer breaks one bar early and orphans a bar; a last system at least half full is justified to the margin like the others (`justifyLastSystem` overrides); an empty measure (`notes: []`) is documented as an empty bar. Also: the D-138 flat-melody barring is now the pure `segmentFlatStaffIntoMeasures` / `measureCapacityWholeNotes` / `noteWholeNoteUnits`, which `musicFlatNotesWrapG33.test.ts` had pinned since 2186d44f without the code ever being committed.
- On the Anthropic Messages path (direct, or Bedrock via mantle) with Claude 4.5+, within-reach MCP servers use the API's own deferred tool loading instead of Ziya's loader (`design/mcp-on-demand.md`, "Anthropic's native path"; `app/utils/mcp_ondemand.py` `native_search_for` / `native_tool_payload`, `app/providers/base.py` `PassbackBlock`, `app/providers/anthropic_direct.py`, `app/streaming_tool_executor.py`, `app/utils/precision_prompt_system.py`; tests `tests/test_mcp_native_tool_search.py`, new). Their tool definitions are sent on every request with `defer_loading: true` and a `tool_search_tool_regex_20251119` entry, so the `tools` array is identical for the whole conversation: a within-reach server costs no prompt-cache miss, on search or on use, where the rebuild path cost one full-prefix write per load. The provider echoes the API's `server_tool_use` and `tool_search_tool_result` blocks back at their original index alongside signed thinking blocks; an unsigned thinking block now drops thinking only, since omitting those blocks would discard the found tools. The within-reach catalog names `tool_search_tool_regex` on this path and Ziya's `search_server_tools` / `load_server_tools` are left out of the tool list. Other providers keep the rebuild. Not yet: the Invoke-API Bedrock provider.
- MCP servers can be placed **Within reach** on the bench (capabilities-hub slice 9; `design/mcp-on-demand.md`). A within-reach server stays connected, but its tools are left out of the request until the model calls the new `load_server_tools(name)` builtin; its real tools, with their own schemas, signing, permissions and consent, are then callable from the model's next step in the same turn and stay loaded for the rest of the conversation. The system prompt lists within-reach servers by name, description and tool count beside the skill catalog. Loaded is derived, not stored: a `[MCP server loaded: <name>]` marker in the conversation's history, or an in-process per-conversation set, intersected with the current placement, so shelving a loaded server withholds it again at once and a fork inherits what was loaded. A Task Card whose `scope.tools` names a within-reach server's tool counts that server as loaded for the run. The loader refuses shelved, on-hand, unknown, disconnected and quarantined servers. A load costs one prompt-cache miss, because the tool list heads the cached prefix. Loading marks the card ✦, and the resolver's `mcp_ondemand_unsupported` 409 is gone. Turning skill discovery off also removes the loader, so within-reach servers become unreachable, and the toggle's warning now says so (`app/utils/mcp_ondemand.py`, new; `app/mcp/tools/mcp_loader_tools.py`, new; `app/utils/bench_prompt.py`, `app/utils/capability_events.py`, `app/utils/precision_prompt_system.py`, `app/services/activation.py`, `app/services/bench.py` `mcp_effective`, `app/streaming_tool_executor.py`, `app/mcp/builtin_tools.py`, `app/api/bench.py`; frontend `utils/activation.ts`, `components/Bench/{laneDnd.ts,BenchBoard.tsx,CapabilityCard.tsx}`; tests `tests/test_mcp_ondemand.py`, new, plus `test_bench_prompt_seam.py`, `test_bench_api.py`, `tests/fixtures/activation_cases.json` and the Bench jest suites).
- `scripts/memory_audit_snapshot.py`, `scripts/memory_restatement.py`, `scripts/memory_restatement_calibrate.py` — read-only instruments for judging memory-policy changes empirically. The audit appends a dated row (layer mix, promotion reasons, corroboration attribution rate, live-use counts, duplicate-event count) to `.ziya/outputs/memory_audit.jsonl` so any change reads as a before/after on the same fields. The restatement scripts embed the user side of the whole chat corpus incrementally and score each proposal by whether a later, different conversation restates it, against a size-matched temporal null; the absolute-threshold scoring was shown to be uninformative (a distilled fact never reaches dedup-level cosine with raw transcript) and remains only as the embedding cache.
- Managed jobs (`job_start`) are visible outside the turn that started them (`app/api/jobs.py`, new; `app/jobs/index.py`, new; `app/api/task_runs.py` (`status-index` gains `jobs`, `live` counts a running job); `app/jobs/manager.py` (`Job.state()` caches its final state, names its conversation and carries `ended_at`; `kill` waits for a freshly spawned wrapper's pgid); `app/server.py`; frontend `components/TaskCard/{jobStatusGlyph.ts,JobStatusGlyphs.tsx,jobRail.ts,JobChip.tsx,JobExitFeed.tsx}`, `hooks/useConversationJobs.ts`, `utils/jobSeen.ts`, `services/jobsApi.ts` (all new), `TaskRail.tsx`, `Conversation.tsx`, `MUIChatHistory.tsx`, `hooks/useRunStatusIndex.ts`, `services/taskRunApi.ts`, `task-surfacing.css`; tests `tests/test_job_status_index.py` and six frontend suites under `TaskCard/__tests__/job*`, all new; `design/job-runner.md` "UI"). A job lived only in the tool results of its turn, which scroll away during a long run. The sidebar row now shows a terminal glyph per job state (running pulses; exit 0 / non-zero / lost are static, coloured, badged, and clear once the conversation is visited); the open conversation's TaskRail shows a chip per running job with a tail popover, Kill and "Jump to the turn that started it" (resolved from `started_at` against message timestamps, no new state); and an exit card posts once at the thread's bottom when a job the rail was showing finishes. Jobs started during a turn are held back until the turn ends and only shown if still running, so a turn that runs several short jobs does not churn the rail or post a card per command. Jobs owned by a Task Card run are excluded from the sidebar: that run's gear already shows them. Also fixed in passing: killing a job within its first moments left it `lost` with no status, because the wrapper had not yet installed its TERM handler.
- A test pinning that proposal corroboration counts distinct foreign conversations, not events (`tests/test_proposals_corroboration_distinct.py`, new). The projection guard in `ProposalsStore._project` already discarded same-origin, repeated and unattributed corroborations — without it, the per-turn re-extraction above let a fact stated once promote as "highly_corroborated" after two turns of the same conversation — but nothing asserted it at the store surface or at the `_evaluate_promotion` seam.
- A per-block wall-clock budget, `scope.time_budget_secs` (`app/models/task_card.py`, `app/agents/task_executor.py`; tests `tests/test_task_time_budget.py`, new). At the deadline the model stream is cut off and one closing turn runs with every tool but `emit_artifact` withheld, asking the task to write what it has; the block is then judged normally and carries a `time_budget` record on its artifact and `task_finished` event. A closing turn that overruns its grace (10% of the budget, clamped to 60–300 s) fails the block with signature `time_budget_exceeded`. Merges as the MINIMUM across scope layers (a bound, not a grant), so a budget on a Parallel applies to each lane. Until now the only ceiling on a task was the iteration count: a lane in run 3f831376 spent 2 h 20 min on one query inside that count. The `task_cards` skill documents the field beside `shell_timeout_secs` and in checklist item 5.
- A "Data runs: before you press Run" checklist in the `task_cards` skill (`app/data/built_in_skills.py`; test `tests/test_task_cards_skill_data_run_checklist.py`, new), distilled from an 8-hour data card (chat 3f831376) that re-ran one CTE ~60 times in query slices, wrote a 500 MB intermediate no lane read, had six lanes re-pull row-level data because stage 1 persisted aggregates, sized results with an instrument the project had documented as biased, and ran ten slice jobs against one 64-s-capped proxy. The checklist: check the shelf (`memory_search`, `.ziya/`, existing tooling) before authoring; pull once at the finest grain and `dataset_register` it so lanes `analyze` locally; choose the instrument by the question; smoke one slice before fan-out; budget each lane with a stated falsifier; cap concurrency on a shared backend; no intermediate without a consumer; report in the user's unit. The Parallel section now says `repeat_max_concurrency` applies to it — the executor already honoured it (`_execute_parallel`) but the prose never offered it, so the cap was unreachable from an authored card.
- Gather contracts can be edited in the deck (`frontend/src/components/TaskCard/GatherContractSection.tsx` (new), mounted on `ParallelBlockEditor.tsx`, `RepeatBlockEditor.tsx`, `UntilBlockEditor.tsx`; `frontend/src/types/task_card.ts` (`GatherContract`, `Block.gather_contract`); test `frontend/src/components/TaskCard/__tests__/gatherContractSection.test.tsx` (new)). A container's "📥 Required outputs" row adds, edits and removes entries: part name, schema, per child / per iteration / in total (only the values the validator accepts for that container), min, max, the `min_units` quorum ("need 2 of 3"), re-run short units, and producer ids. Each entry shows one sentence saying when it fails the container. Fields the backend declares non-optional are omitted when cleared rather than written as null, switching to "in total" drops the quorum and re-run fields that `per: total` refuses, and keys the row does not show (`require_claims`, `container`, an inline schema) survive edits. The card-root Group gets the same row beside its self-improve toggle (per child or in total; no re-run, which the validator refuses on a group).
- Gather contracts take a quorum, `min_units` (`app/agents/block_executor.py` (`_check_gather`, `_rerun_targets`, `_gval`), `app/utils/output_contracts.py` (`gather_unit_count`, `gather_requires_every_unit`, `gather_pushdown`), `app/utils/task_card_validation.py` (`_check_gather`), `app/models/task_card.py` (`GatherContract.min_units`), `app/data/built_in_skills.py`; tests in `tests/test_gather_min_units.py` (new)). A Parallel of lanes that may each come up empty had no way to say "at least 3 of these must produce a finding": the default `per: child, min: 1` failed the container on one silent lane, and the suggested `per: total, min: 3` counts parts rather than lanes (one lane emitting three satisfied it) and, on a multi-task Parallel, needs every lane listed as a producer. `min_units: k` on a `per: child` or `per: iteration` gather passes the container when at least k units meet `min..max`; units that fell short are still named in the decisions. Below the unit count the contract is pushed down to producers as optional (validated if emitted, no correction turn, no lane failure), because a lane failing for lacking the part would fail the container the quorum lets pass; `rerun_missing` re-runs short units only when the quorum is unmet. `min_units` at or above the unit count behaves exactly as before. Launch refuses `min_units` below 1, on `per: total`, or above the units that can exist (children, or a counted Repeat's `repeat_count`).
- Launch warns about a Task that declares a gathered part `required: false` under a gather that still needs every child or iteration (`app/utils/task_card_validation.py`; tests in `tests/test_gather_min_units.py`). `required` on a Task governs only its own correction turn and failure, never the container's gather check, so the card failed at run time on the first silent lane with nothing said beforehand. The warning names the tasks and suggests a `min_units` value.
- Importing a task-card export onto another machine now offers to remap the working directories it carries (`app/storage/task_cards.py` (`portable_cwds`, `import_cards(cwd_map=...)`), `app/api/task_cards.py` (`POST /import/inspect`, `{payload, cwd_map}` wrapper on `POST /import`), `frontend/src/services/taskCardApi.ts` (`inspectImport`), `frontend/src/components/TaskCard/ImportCwdMapModal.tsx` (new), `importCwdMap.ts` (new), `TaskCardsLibrary.tsx`; tests in `tests/test_task_card_export_import.py`, `frontend/src/components/TaskCard/__tests__/importCwdMap.test.ts` (new)). An absolute `scope.cwd` is a path on the exporting machine; the executor confines cwd to the project root, so on the new host the import succeeded but every such block reported `scope.cwd ... escapes the project root` and would have run at the root. The import now lists each distinct absolute path with a directory browser (and "Set all to…" / "Project root" for the 1:1 case) before anything is written, and rewrites the card's and every block's matching cwd; rows left blank import unchanged and still warn as before.
- A collapsed folder in the Chats tab now shows what is happening inside it (`frontend/src/utils/chatTreeFolderBadge.ts` (`rollUpFolderStatus`), `frontend/src/components/MUIChatHistory.tsx`; test `frontend/src/utils/__tests__/chatTreeFolderStatusRollup.test.ts` (new)). A conversation that finished while you were away, is still processing, or has a task run in flight was invisible until its folder was opened; the folder row showed only `(76 · 9 folders)`. The row now carries the descendants' status ahead of the count, in the same vocabulary the chat rows use: a processing spinner with its count, a green check with its count, and the summed run-status gears (so a "Waiting on you" or "Held" chip anywhere inside bubbles up). Nested folders and delegate/fork children are included; the open conversation is never counted as unread. Expanded folders show nothing extra, since their children already carry it.
- Moving a conversation to another project, or toggling its global flag, now leaves a note in the conversation record (`app/utils/project_change_note.py` (new), `app/storage/chats.py` (`move_to`), `app/api/chats.py` (`move_chat`, `set_chat_global`), `frontend/src/components/ProjectChangeNotification.tsx` (new), `Conversation.tsx`, `api/conversationSyncApi.ts` (`moveChat`), `context/ChatContext.tsx`; tests `tests/test_project_change_note.py` (new), `frontend/src/components/__tests__/ProjectChangeNotification.test.tsx` (new)). The model's working directory changes with the project, but nothing in the history said so, and paths from before the move were read as if they still resolved. The server appends one unmuted `system` message at the change: its text, for the model, names both projects and the previous and new working directories (for a global toggle, the owner's directory and that a turn sent from another project runs in that project's root); its `projectChange` field, for the transcript, renders as a "Moved from project A to project B" row without the directories. A repeated toggle to the same value adds nothing. The move reply carries the note so a conversation followed into its new project shows it at once.
- A compact layout for hierarchical tool results (`frontend/src/components/CompactHierarchicalList.tsx`, `frontend/src/utils/hierarchicalGroups.ts`; `layout` carried by `utils/mcpFormatter.ts` and `utils/renderToolPart.ts`; tests `components/__tests__/CompactHierarchicalList.test.tsx`, `utils/__tests__/hierarchicalGroups.test.ts`). A formatter that returns `layout: 'compact'` gets one ~20 px line per hit instead of a padded panel. Rows tagged with the same `group` sit under one header with a hit count, `detail` is right-aligned, `dim` fades a row, and the directory part of a path title is dimmed. Clicking a row expands the same body the default layout shows. Results without `layout` render as before.
- **A fork leaves a forward link in its source, at the fork point** (`frontend/src/context/ChatContext.tsx` `forkConversation`; `frontend/src/components/HandoffNotice.tsx`; `frontend/src/utils/types.ts`; tests `frontend/src/context/__tests__/forkForwardNotice.test.ts`, `frontend/src/components/__tests__/HandoffNotice.test.tsx`). Forking copied the history into the new conversation and left nothing behind, so the source gave no sign that a fork had been taken or where. It now gets the same local notice a handoff leaves — system + muted, never sent to the model — appended at the moment of forking with a fork glyph and an "Open “Fork: …”" link; later messages in the source follow it, so the point stays marked. The fork itself does not carry the notice. In the sidebar a fork child now shows the fork glyph in place of the chat bubble, and a handoff continuation shows ⛓ there (`frontend/src/components/MUIChatHistory.tsx`; both had been rendered inside the title, where a long title's ellipsis clipped them).
- **Vega-Lite charts labelled UTC are now drawn in UTC** (`frontend/src/plugins/d3/vegaTemporalZone.ts` new; `frontend/src/plugins/d3/vegaLitePlugin.ts` Fix 3.73; test `frontend/src/plugins/d3/__tests__/vegaTemporalZone.test.ts`). Vega-Lite renders a temporal field in the browser's zone unless the scale is `type: "utc"`, and models title the axis "UTC" without setting it: a cell SNR chart with rows in -07:00 and a rule at 08:19:05Z read 01:10 … 01:25 under a "UTC" title. The preprocessor derives the intent from the spec — an axis/channel title naming UTC, GMT, Zulu or "(Z)", a literal Z/UTC in the axis format outside `%` directives, `utcFormat` in a `labelExpr`, a utc `timeUnit`, `scale.type: "utc"` on one layer, or a field whose values all state UTC or are date-only — and, unless a title names a local zone (PDT, CET, "local"), sets `scale.type: "utc"` on every layer sharing that scale, makes local `timeUnit`s utc, rewrites `timeFormat` to `utcFormat` in that axis's `labelExpr`, and formats tooltip/text entries on the same fields in UTC (`formatType: "utcFormat"` with `config.customFormatTypes`, which keeps the author's format string; measured against the real compiler, a utc scale alone leaves tooltips local). Data values are never rewritten; the rewrite is reported like the other preprocess passes. A chart is read against one clock, so the claim is not confined to the view that makes it: a 5-panel vconcat whose bottom axis alone was titled "UTC" first came out with that panel in UTC and the four above it in PDT. A UTC claim in the chart title or subtitle (UTC/GMT/Zulu, or a time written `HH:MMZ`), or else on any scale, is now inherited by every temporal scale without a claim of its own; a view titled with a local zone keeps it, and a chart title naming a local zone (matched case-sensitively, so prose such as "west" does not count) turns inheritance off.
- **Parallel tool batches are drawn as one unit** (`app/tool_batch.py` `_run_group`; `frontend/src/utils/toolGroupDisplay.ts` new; `frontend/src/apis/chatApi.ts`; `frontend/src/utils/renderToolPart.ts`; tests `tests/test_tool_batch_group_chrome.py`, `frontend/src/utils/__tests__/toolGroupDisplay.test.ts`). The batch runner overlaps every read-only call issued in one assistant message, but the chat rendered each member as if it were alone, so overlap was invisible and its absence had no tell. For a group of two or more, every `tool_start` now carries `batch_group = {id, size, index}` and one `tool_group_end` follows the results with the group's wall-clock and serial time. The members are drawn inside one bordered container (`frontend/src/components/ToolGroup.tsx`, `frontend/src/utils/toolGroupTokens.ts`; test `frontend/src/components/__tests__/toolGroupRender.test.tsx`): the chat writes a `<!-- TOOL_GROUP_START -->` comment before the first member to arrive and appends a `<!-- TOOL_GROUP_END -->` comment carrying the timing, and `renderTokens` collects what lies between into the container. Its header shows a progress bar and `k / N done` while running, then `✓ N calls in parallel · wall · saved X s vs serial (Y s, Z×)` (or "no saving" when the overlap bought under 100 ms); a group in a finished message that never closed reads as stopped. Each member's header keeps the `⫽` mark. A lone call renders exactly as before. A lone HTML comment in a message is no longer printed as text: the group anchor `<!-- TOOL_GROUP:id -->` used to fall through the renderer's html case and appear verbatim.
- **Independent writes now overlap with the reads around them** (`app/utils/tool_footprint.py` new; `app/tool_batch.py` `plan_groups`; test `tests/test_tool_footprint.py`). Every call that was not read-only used to be a barrier, so a message of three reads and two `ledger_record` ran as four serial steps; the audit log after the batching nudge showed multi-call batches saving only 29% of serial time, with `file_write`, `ledger_record` and `emit_artifact` the most frequent members. Each call now has a footprint of the resources it reads and writes, and joins the current group unless it conflicts with a member. `file_write` to a path the write policy already allows, `ledger_record`, `emit_artifact`, `store_output` and `memory_propose` can overlap; two calls touching the same thing (one file, one claim, the artifact list, the proposal store) still run in issue order, so a write followed by a read of it is unchanged. A writer that could open an approval panel (consent mode `ask`, or a path outside the write policy) stays a barrier, as does every other mutating tool. Reads with no declared footprint, including all external MCP tools, never overlap a write. Known gap: a shell read of a ledger or output file in the same message as a write to that store may see either state.
- **Reads start while the model is still writing the message** (`app/utils/tool_prestart.py` new; `app/tool_batch.py` `run_tool_batch(prestarted=)`; `app/streaming_tool_executor.py`; test `tests/test_tool_prestart.py`). Tool calls were queued as each `tool_use` block closed and none ran until the whole message had streamed, so a read issued early in a long message waited for every token after it. The leading run of read-only calls in a message now starts the moment its block closes; the batch replays its buffered events instead of running it again, so the chat renders exactly as before and the read's time overlaps generation. The first call that is not a read ends early start for that message, so nothing that writes or could ask for approval runs before the message is complete. A pre-started call does not drain pending user feedback (the stream loop still sees a stop), nothing starts early while feedback is pending, and calls the batch did not take are cancelled on a retried iteration, a mid-stream stop, and after each batch. `ZIYA_TOOL_PRESTART=0` turns it off.
- **oMLX as a local runtime** (`app/utils/local_models.py`; tests `tests/test_local_omlx_discovery.py`, new). The Apple-silicon MLX server is now identified (`owned_by: "omlx"` → endpoint `local-omlx`, label *Local · oMLX (:port)*) instead of surfacing as a nameless `Local · :8000`. Its default port was already scanned; a server moved elsewhere is found through `OMLX_PORT` or `server.port` in its `settings.json` (resolved the way oMLX itself does: `OMLX_BASE_PATH`, the macOS app's `~/Library/Application Support/oMLX/base-path`, then `~/.omlx`). Each model is enriched from `GET /v1/models/status` — effective context window (falling back to the checkpoint length, then the listing's vLLM-style `max_model_len`), output cap, vision (`model_type: vlm`), loaded state, and the server's own `reasoning_efforts` ladder, which replaces the DwarfStar default when present. Embedding, reranker, speech, decision and `MarkItDown` entries that oMLX lists on `/v1/models` are dropped from the chat catalog; when the status endpoint is unavailable the filter fails open. `ZIYA_LOCAL_RUNTIME` accepts `omlx`. **Keyed servers no longer vanish:** an oMLX started with `--api-key` answers `GET /v1/models` with 401, which the scan read as "no server here". The scan now retries a refused listing with `ZIYA_LOCAL_API_KEY` (new) and then the oMLX key read from `OMLX_API_KEY` / `auth.api_key` in its `settings.json`, remembers the key that worked on the endpoint, and sends it on status probes and chat requests (`local_endpoint_api_key()` replaces the `"local"` placeholder in `providers/factory.py`, `agents/models.py`, `services/model_resolver.py`). A server that refuses every key is named in a startup warning.
- **Visual refresh, stage 1 — "Paper & Ink"** (`frontend/src/styles/theme-tokens.css`, `frontend/src/utils/appearance.ts`, `frontend/src/components/AppearancePopover.tsx`; decision record `design/visual-refresh.md`; tests `utils/__tests__/appearance.test.ts`, `components/__tests__/appearancePopover.test.tsx`). Light mode gets a warm-neutral paper with an explicit surface ladder (canvas / sidebar / surface / inset) mirroring dark mode's; dark mode lifts off AntD's `#141414` onto a slightly cool `#1c1f24` ladder with borders two steps up and the sidebar darker than the reading canvas. Teal is now the one accent (`colorPrimary`, MUI primary, tab underline, selection); magenta stays on the brand mark. AI prose is set in `Charter, 'Iowan Old Style', Georgia, serif`; human turns are a full-width band with a 3px teal rule, AI turns open on the canvas, both sharing one text edge. The header's theme bulb becomes an **Appearance** popover (theme incl. System, light paper tone warm/linen/cool/white, response font, density, prose width cap), stored in `localStorage` as `ZIYA_APPEARANCE` and projected onto `<html data-paper/data-font/data-density/data-prose-cap>` so the stylesheet selects on them. Bench / Shell / Tasks / Memory gain text labels (icons only under 1100px); ephemeral chat folds into **+ New ▾**; the header middle shows a `project / conversation` breadcrumb. Model, project and shell controls do not move.
- **Visual refresh, stages 2–4 — Chats tab, collapse tab, titles** (`frontend/src/components/MUIChatHistory.tsx`, `utils/chatTreeSort.ts`, `utils/relativeTime.ts`, `utils/conversationTitle.ts`, `TaskCard/RunStatusGears.tsx`, `TaskCard/runStatusVocabulary.ts`; tests `utils/__tests__/chatTreeSort.test.ts`, `relativeTime.test.ts`, `conversationTitle.test.ts`, `TaskCard/__tests__/runStatusGearsCompact.test.tsx`). Chat rows are one line in both densities (Comfortable 36px, Compact 26px, from the Appearance popover); the trailing slot has a fixed order — state glyph · beads · relative time — with run-status gears as `⚙N` and the needs-human chips shortened to `Waiting` / `Held` in Compact (full text in the tooltip). The open conversation carries the same teal left rule as a human turn. Folders now sort above conversations at every depth (pinned, then active, then by activity) and root-level chats sit under an **"N unfiled"** divider; a just-touched unfiled chat therefore sits below idle folders, where it used to interleave. The floating panel-collapse button becomes an 18×56 tab on the panel divider, layered above the resizer strip so the whole tab collapses and the strip above and below it still drags. The header is left-justified (brand + `project / conversation` breadcrumb from the left edge) and the message-order toggle moves from a labelled primary button at the left edge to an icon beside Appearance. With the panel collapsed a 28px strip rides above the composer (`components/CollapsedPanelStrip.tsx`, `utils/panelStatus.ts`; tests `utils/__tests__/panelStatus.test.ts`, `components/__tests__/collapsedPanelStrip.test.tsx`) showing the model (with its config button), cache state, the first two active skill pills plus a `+N` overflow, the colour-coded token bar with total and `⇢ hand off`; it reads the same figures the sidebar footer publishes, so the two can never disagree. New conversation titles store the first non-blank line of the first message (capped at 200 chars) instead of a slice sized to the sidebar width at creation, which is why stored titles carried `...` mid-screen; rows and the breadcrumb truncate at render. Existing titles are unchanged.
- **Model-driven sidebar organisation** (`app/mcp/tools/conversation_organize_tools.py`, new `conversation_organize` builtin category, on by default; tests `tests/test_conversation_organize_tools.py`). Seven tools let the model file its own work: `folder_list` / `folder_create` / `folder_update` (rename, re-parent with `'root'` for top level, pin) / `folder_delete` (empty folders only), `chat_move`, `chat_rename` and `chat_fork` (same lineage fields as the sidebar's Fork). Metadata only — no tool can touch a message. Moves and renames bump `_version` so a stale browser copy loses on bulk-sync but leave `lastActiveAt` alone, so filing fifty old conversations does not make them all "just used". `ChatGroup` gains first-class `parentId` / `pinned`, and `ChatGroupStorage` now stamps `updatedAt` on create and update (the field `serverFolderWins` compares). The frontend's `tool_display` handler raises `conversationOrganizedFromBackend` for these tools and `ChatContext` answers with an immediate folder + chat-summary pull, so the sidebar updates within the turn rather than on the 30 s poll.
- **Chat-history tools show the folder hierarchy.** `chat_list`, `chat_read` and `chat_search` now join `chats/_groups.json` and report `folder: "Parent / Child"` beside the opaque `folderId`; `chat_list` gains `folder_contains`.
- **Tributary fold — replay a tool hunt as its reward** (`app/utils/tool_history_rewrite.py` `plan_folds` / `classify_fold` / `rewrite_assistant_text(fold=…)`, `app/models/project.py` `ContextManagementSettings.fold_*`, `scripts/measure_context_composition.py --simulate-fold`; tests `tests/test_tributary_fold.py`, `tests/test_measure_fold_simulation.py`, both new; `design/tributary-fold.md`). When the user asks for one data point and the model greps, dead-ends, narrows and answers, every later turn replays the hunt; muting cannot separate the hunt from the answer because they are one message. An assistant turn with ≥2 tool blocks and prose after the last one now replays, once two further human turns exist, as its pre-hunt prose + a placeholder (call labels, counts, a content-addressed `tb-…` handle) + its final prose — calls, results **and the interim narration** are folded, which no body-level lever reaches. The record is untouched; the placeholder carries no age-relative text so it folds once and is cache-stable thereafter; folds are planned before redundant-body elision so a body inside a folded span is never a stand-in. The model gets the span back with `recall_tributary(handle, mode=full|commands|results, grep=, max_chars=)` (`app/utils/tributary_recall.py`, `app/mcp/tools/tributary_tools.py`, `chat_history` category; tests `tests/test_tributary_recall.py`), which reads the conversation's own record (a muted message still resolves; another conversation's handle is "not found", never an error), and the Session Context block gains a *Folded tool turns* paragraph telling it that its own hunts will be replayed folded, so ids, paths and the query that worked belong in its final text or a store. **On by default** (`contextManagement.fold_tributaries`; kill switch `ZIYA_DISABLE_TRIBUTARY_FOLD=1`). Survey over the full store (464 long chats, 79M replayed tokens): tributaries are 71.7% of replayed history, 16% of each span is interim prose, steady-state reclaim 65% at grace 2, and 28.6% of folded turns are reused verbatim later — grace 1 would double that.
- **Drop audio files into local transcription.** Dropping a `.m4a` or `.wav`
on the composer, or picking one with the attach button, transcribes it
locally with faster-whisper and inserts the text at the cursor, instead of
rejecting it as an unsupported file type. `/api/transcribe` now also accepts
the `audio/x-m4a`, `audio/m4a`, `audio/wave` and `audio/vnd.wave` types, and
falls back to the file extension when the client sends no usable type.
Files up to 1 GB (`ZIYA_TRANSCRIBE_MAX_MB`) are accepted, so a 30-minute
meeting fits; the transcription routes are exempt from the global 20 MB
request cap. Transcription runs as a background job
(`/api/transcribe/jobs`) with a progress toast and a Cancel link, and only
the conversation it belongs to waits on it. A transcript that finishes
after you switch conversations is added to the composer of the one it
was started in, where earlier it was dropped. Each file's transcript is
inserted as its own block under a one-line header naming the source
(`[Transcribed from WAV audio file: take.wav]`), so several dropped files
no longer run together.
- **Task runs are now in the cost accounting a user can see.** The
conversation Info tab breaks `Cost` into the chat's own turns and each
Task Card run launched from it, named by card and launch number; a
conversation whose only spend was a run no longer reads "no usage
recorded". Every run tile gains a **Resources** line that stays with the
run (live, finished, historical) and expands to wall clock, model time,
turns, tool calls, tokens by dimension, cost with list price and tier
mix, tool-side CPU, files written and disk used, then the same per block
(`GET /task-runs/{run}/resources`, `GET /api/usage/conversation/{id}`).
Run ledger rows now carry the launching conversation's id; older rows are
still found through the chat's task bindings.
- The 📄 glyph in front of a shown document's path is a drag source: drag it out to Finder, Slack, Docs or an editor to deliver the file itself, while the path text beside it stays selectable (`frontend/src/components/FileDragChip.tsx` `FileDragIcon`, shared `useFileDrag`; test `frontend/src/components/__tests__/showDocumentDragIcon.test.tsx`).
### Fixed
- The tool-poisoning scanner no longer drops Pippin and Chorus tools whose descriptions document a marker syntax (`pippin_update_artifact`, `ChorusDocRead`, `ChorusComment` were blocked with `description matches injection pattern '<!-{2,}.*?-{2,}>' near: ...<!-- comment:ID -->...`, and a force re-authorization of the server did not prevent it). An HTML comment whose body is at most three `key:value` / `key=value` identifier tokens is treated as a placeholder marker, not hidden text (`app/mcp/tool_guard.py` `_is_placeholder_marker`). Any comment that can carry prose (bare words, a URL, more than three tokens, over 48 characters) still blocks, and the warning text is unchanged. The rug-pull fingerprint quarantine is not affected. Tests: `tests/test_tool_guard_html_comment_markers.py`.
- `ziya task` live terminal output. Text after a task's first tool call is now filtered too, so the closing `<self_assessment .../>` tag and `<thinking-data>` blocks no longer print raw. Before, `_StdoutSink._break_text()` threw the filter away on every tool call. `<progress note=.../>` tags are also stripped, with a space put back where the tag sat between two words. A model turn that follows another with no tool call in between (the output-contract correction turn, the time-budget closing turn) now starts on a new line: the executor emits the `\n\n` seam as a `task_text_delta`, where before it was added only to the stored summary (`app/agents/task_executor.py`, `app/cli_card_runner.py`). An `↻ iteration N` marker no longer gets appended to the end of an unfinished text line. Tests: `tests/test_cli_sink_meta_filter.py`, `tests/test_task_turn_seam_delta.py`.
- MCP servers installed from the public registries (official-mcp, open-mcp, GitHub) as PyPI packages go into their own venv at `~/.ziya/mcp_services/<id>/.venv` and run from there (`app/mcp/registry/installation_helper.py` `install_pypi_package`, `ensure_venv`, `resolve_in_venv`). Before, a bare `pip install` used the first `pip` on PATH; for a pip-installed Ziya that was Ziya's own environment, so a server's dependencies could replace Ziya's. That is how one user ended up with mcp 2.x under a Ziya pinned to 1.x. Open-mcp PyPI installs, which always failed the run-command policy, now work. The run-command policy accepts the service venv's own interpreter (`app/mcp/registry/command_policy.py` `_is_service_venv_python`): it is a symlink out of the install directory, so `<venv>/bin/python -m <module>` was refused; a planted symlink to a non-Python binary is still refused. Tests: `tests/test_registry_pypi_isolation.py`, `tests/test_registry_venv_run_policy.py`.
- When an MCP server's executable is missing, the log now names the command and the PATH it was looked up on (`app/mcp/client.py`).
- Ziya no longer refuses to start a retention-gated model (e.g. `fable5.1`) when the AWS role is not allowed to read the account data retention setting (`app/utils/aws_utils.py` `_ensure_data_retention`; tests `tests/test_retention_heal_read_denied.py`, `tests/test_retention_mode_ordering.py`). A 401/403 on the `bedrock:GetAccountDataRetention` read at startup or model initialisation now logs a warning and continues without writing, instead of exiting with "DATA RETENTION SETUP FAILED"; the model call decides, and a call actually rejected for retention goes through the existing heal path. Observed with role `bedrock-chatter`, which could not start Ziya although the account was already at `aws_review`. Explicit downgrades/resets and non-permission read failures (5xx) still fail.
- `ziya --info` reports whether MCP is enabled even when AST is off (`app/main.py` `print_info`); the line was printed only inside the AST branch.
- A drawio block that gives only a URL shows "DrawIO diagrams cannot be loaded from a URL" instead of failing inside the renderer, and the model is no longer told URL references work (`frontend/src/plugins/d3/drawioPlugin.ts`, `app/agents/prompts.py`). Diagrams render from inline XML only.
- A message sent while the model was waiting on a background job was held until the job finished. A message sent during a running turn is queued and only delivered between tool calls, and `job_wait` can last up to an hour, which made backgrounding pointless for anything you wanted to ask meanwhile. The wait now gives way: when a message arrives, `job_wait` and `job_start(wait_s=…)` return within about 0.2 s and `shadow_wait` within 10 s, with `reason: "user_message"`, `interrupted_by_user: true` and a `next` call to resume. The job keeps running, and the model receives your message right after (`app/utils/tool_interrupt.py` (new), `app/tool_execution.py`, `app/jobs/manager.py`, `app/mcp/tools/job_tools.py`, `app/mcp/tools/shadow_tools.py`). A stop request still abandons the call as before. Test: `tests/test_tool_wait_yields_to_feedback.py`.
- A server whose install directory was deleted while it was running now tells you to restart instead of reporting a throttling error. An upgrade that installs each version into its own venv and deletes old ones can delete the one a running server was started from. The CA certificate bundle in that venv then disappeared, so every model call failed with "SSL validation failed … [Errno 2] No such file or directory". Ziya treated that as a dropped connection: it retried five times and then showed a throttling error. A TLS failure caused by a missing certificate file is no longer retried. It ends the turn with an `installation_changed` error that names the removed directory (or the missing `AWS_CA_BUNDLE` path) and says to restart (`app/utils/install_integrity.py`, `app/providers/bedrock.py`, `app/streaming_tool_executor.py`). The chat shows it as a "Ziya was updated while it was running" banner that explains what happened, which version was removed, and the steps: restart the server, then reload the page. Your conversations are saved, and the banner has no Retry button because a retry cannot succeed until the restart. A missing `AWS_CA_BUNDLE` / `ZIYA_CA_BUNDLE` path gets its own "Certificate bundle not found" variant (`frontend/src/utils/installationChangedBanner.ts`). Tests: `tests/test_install_removed_while_running.py`, `frontend/src/apis/__tests__/chatApiInstallationChanged.test.ts`.
- A skill moved to **on hand** in the conversation lens no longer drops back to **shelved** a few turns later. The bench's conversation-layer placements (`placements`) and the conversation's loaded MCP servers (`loadedMcp`) are written to the chat record only by the server. The browser's copy of the chat doesn't hold them, so the next end-of-turn bulk-sync overwrote them with `null`, and a stale copy could restore an old value. Bulk-sync now keeps the stored values for an existing record, the same way it already keeps the handoff fields. A new record, such as a fork, still takes the values it was sent (`app/api/chats.py`). Test: `tests/test_bulk_sync_server_owned_placements.py`.
- A chat turn could be lost when its last viewer disconnected during a tool call. The reported case was a `job_start` + `job_wait` whose ssh tunnel and browser died: after the 60 s disconnect grace the server cancelled the turn mid-wait, kept the result in memory for only 5 minutes, and never wrote the assistant's answer to disk, so the reloaded tab showed the unanswered question with Retry. Four changes. (1) Every tool call holds its turn, so no tool wait is cancelled for lack of a viewer (`app/tool_execution._await_tool_result`, `chat_turn_relay.held`). (2) Once a turn has run a tool it is not cancelled for having no viewer at all. The new `ZIYA_CHAT_TURN_UNWATCHED_GRACE_SECS` restores a timeout; tool-less turns keep the 60 s grace. (3) Each turn is checkpointed to `~/.ziya/chat_turns/`: at most every 5 s while running, immediately when a tool starts, and when it ends. Records are encrypted as `conversation_data` and pruned after `ZIYA_CHAT_TURN_RECORD_RETENTION_HOURS` (default 168). The turn status, list and reattach endpoints answer from that record once the turn has left memory; a record a previous server process left mid-turn is reported as interrupted and its replay ends cleanly (`app/agents/chat_turn_store.py`). (4) A reattach no longer sends the stale-stream abort, which since the relay change cancelled the server turn it meant to rejoin (`frontend/src/apis/chatApi.ts`). Tests: `tests/test_tool_exec_relay_hold.py`, `tests/test_chat_turn_persistence.py`.
- Reloading the browser no longer cancels a chat turn running in a conversation you weren't viewing, such as one parked in a `job_wait`. After a reload, the reattach watcher only asked the server about the conversation on screen. A turn anywhere else got no subscriber, the relay's 60 s disconnect grace timer ran out, and the turn was cancelled. Now a reloaded tab reads every turn the relay holds once, from the new `GET /api/chat/turns` (`chat_turn_relay.all_statuses()`), and resubscribes to the background ones. The existing duplicate and stale-answer checks in `decideReattach` still apply. Turns in conversations from another project aren't picked up. Tests: `tests/test_chat_turn_relay.py` (`test_all_statuses_*`, `test_reload_resubscribe_from_listing_saves_a_waiting_turn`), `frontend/src/utils/__tests__/chatTurnReattachBackground.test.ts`.
- `task_card_write` `edits` can now change a single State variable with a `state_variables.<name>` key. Setting the key to `null` removes that variable. Before this, `scope.<field>` was the only dotted key the tool accepted, so changing one variable meant resending the block's whole `state_variables` dict, including large values that weren't changing (one card had a ~10 KB rules text). Other variables are left exactly as they were, and if any key in the edit list is bad (for example, removing a variable that doesn't exist), nothing is saved (`app/utils/task_card_edits.py`). Tests: `tests/test_task_card_edits.py::TestStateVariableEdits`, `test_tool_edits_one_state_variable_and_persists`.
- Four Mermaid rendering problems found while comparing dagre and ELK layouts. (1) Edge labels outside a composite state were drawn white on their light-grey label background under dagre: the contrast pass took the first filled shape anywhere above the label (often the black start dot) as its background. A shape now counts only if it is behind the label (`frontend/src/utils/colorUtils.ts`). (2) In headless renders (the diagram tool, the graphics sweep), a diagram's post-render steps ran before its SVG was attached to the page, so sizing was applied only by a delayed re-run that could land during the screenshot and crop it; ELK renders came out cropped. The plugin now waits for the SVG to be attached first (`mermaidPlugin.ts`). (3) A `--` line inside a composite state, which separates concurrent regions, was deleted, merging the regions; only a `--` outside any composite is removed now (`mermaidEnhancer.ts`). (4) The skip-edge rerouter only recognised Mermaid 10's edge markup and never ran on Mermaid 11/12; it now reads the current markup and runs only for diagrams laid out with dagre, since ELK routes around nodes itself (`mermaidEdgeRerouter.ts`). Tests: `utils/__tests__/colorUtilsLabelBackgroundGeometry.test.ts`, `plugins/d3/__tests__/mermaidStateConcurrentDivider.test.ts`, `mermaidEdgeRerouterModernDom.test.ts` (all new), `mermaidLayoutByType.test.ts`.
- When the Bedrock account data-retention switch was lowered mid-session, Ziya's automatic restore failed for roles that may set the switch but not read it. Each call to a Covered Model then failed with "data retention mode 'default' is not available … Automatic restore failed: Failed to read Bedrock data retention mode: HTTP 403 … bedrock:GetAccountDataRetention". The restore read the switch before writing it and stopped when the read was denied. The rejected call already shows the mode is below the requirement, so the restore now writes the required mode (raise-only) even if the read is refused. If the write is also refused, the error lists both denials. Startup and model-initialisation checks still read first, so a more permissive setting is never lowered (`app/utils/aws_utils.py` (`assume_below`), `app/providers/bedrock.py` (`heal_retention_gate`); tests `tests/test_retention_heal_read_denied.py` (new), `tests/test_bedrock_retention_heal.py`).
- Mermaid state diagrams drew every state as a doubled box (the state name, a divider, then the name again), and a composite state (`state X { … }`) also got a stray description, which made state diagrams with nested states hard to read. A preprocessing pass (`state-shape-fix`) added an `X: X` line for every state name it found. In Mermaid that line is a state *description*. The pass is removed, and descriptions you write yourself are kept and no longer duplicated (`frontend/src/plugins/d3/mermaidEnhancer.ts`, `mermaidStateLabelText.ts`; test `plugins/d3/__tests__/mermaidStateNoInjectedDescriptions.test.ts` (new)).
- After an upgrade, the startup banner listed an extra as missing while the background reinstall was still running (it named `duckdb, pyarrow` mid-install, then `ziya-install-extras --data` reported it already installed). The banner now says "Installing in the background: …" for those. The reinstall notices are now INFO instead of WARNING. A pip extra's notice no longer cites a Playwright version, and the restart note appears only for the browser. Each background install now logs its result (installed, or failed with the last pip lines and the command to run). Python's import caches are cleared after an install, so a package installed while the server runs can be imported without a restart (`app/utils/extras_registry.py`, `app/utils/optional_features.py`; tests `tests/test_extras_registry.py`).
- Sending a message could scroll the selected conversation out of view in a long chat list. Sending makes the conversation active and updates its timestamp. The sidebar sort then moves it, and any folder that contains it, to the top of its group, while the list kept its old pixel offset. The list now adjusts its offset so the selected row stays where it was on screen. This applies only to a re-sort of the same conversation that was visible beforehand. Expanding or collapsing a folder, switching conversations and searching scroll as before (`frontend/src/utils/chatListAnchor.ts` (new), `frontend/src/components/MUIChatHistory.tsx`; test `utils/__tests__/chatListAnchor.test.ts` (new)).
- Scheduled task cards, end to end. (1) The scheduler had been changed to fire only *registered* cards, but the API that registers one (`app/api/schedules.py`) was never written and no UI or CLI could reach it, so every card with a Schedule block that was not migrated from an old state file never fired. The API now exists (`GET/POST/PATCH/DELETE …/schedules/{card}`, `POST …/schedules/{card}/fire`, `GET /api/v1/scheduler/status`), with a Schedules tab in the Task Cards dialog (one row per schedule: the cadence in words such as "Mondays at 06:00", the next run as a date, the last outcome on one line with the reason expandable; pause/resume, Fire now, and Change schedule / Open card / Unregister in a menu; which process runs the schedules), a Register / Fire now line on the Schedule block and on a card staged in chat, and `ziya schedule list|register|enable|disable|unregister|fire|status` (`app/cli_schedule.py`, new). The block's "enabled" checkbox, which the scheduler ignored, is gone. (2) A scheduled fire was a thin copy of the interactive launch loop: it took no executor lock, so Cancel wrote `cancelled` over a run whose executor kept going and a sibling server's startup marked a live scheduled run `held`; it recorded the executor's deliberate headless unsigned-escalation hold as `failed`; it emitted no `run_started`/`run_completed`; it skipped the credentials preflight and job cleanup. Both paths now use one driver (`app/agents/run_driver.py`, new). (3) The model-facing skill text said staging a Schedule card registers it; it now says the opposite and tells the model to say so. (4) No cadence was validated anywhere: a 4-field cron, `25:00` or an interval of 0 saved and registered fine and the card then sat on "next slot not computed yet" for ever. The card validator refuses them at save/launch, registration refuses them with the reason (422), and a registration that can never fire is flagged in the list, the block line, the CLI and `schedule_state.json` (`cadence_error`). (5) The editor showed `hours`/`09:00` for unset fields while the scheduler assumed `minutes`/`00:00`, so an untouched interval fired every N minutes; the scheduler now uses the editor's defaults and registration records them explicitly. (6) `schedule_timezone` was declared and ignored; it is honoured (IANA name) for `daily_at`, naive `at` and `cron`, falling back to local time with a warning for an unknown name. Docs that called `croniter` optional were wrong; it is a hard dependency (`app/agents/task_scheduler.py`, `app/agents/schedule_registry.py`, `app/api/task_cards.py`, `app/utils/task_card_validation.py`, `app/cli.py`, `app/data/built_in_skills.py`, frontend `components/TaskCard/SchedulesModal.tsx`, `ScheduleRegisterControl.tsx` (new), `ScheduleHistory.tsx`, `TaskCardInlineTile.tsx`, `scheduleCadence.ts`, `services/scheduleApi.ts`; tests `tests/test_schedule_registry.py`, `tests/test_scheduler_run_parity.py` (new, 15), `tests/test_schedule_cli_and_fire_api.py` (new), `scheduleCadence.test.ts`, `schedulesWiring.test.ts`, `scheduleApi.test.ts`).
- One large conversation stopped other conversations from reaching the server. Browser-to-server sync (`bulk-sync`) batched 50 conversations per request regardless of size. If a batch went over the server's 20 MB request limit, the whole request was refused (413) and no conversation in it was saved. A new chat in that batch then 404'd when opened, and the next sync rebuilt the same batch. Batches are now limited by serialized size (about 8 MB) as well as count. A refused batch is split and retried, and a refused conversation is reported by its id and size. The sync route itself now accepts up to `ZIYA_BULK_SYNC_MAX_MB` (default 256 MB) so that a single very large conversation also syncs. `RequestSizeMiddleware` gains `path_patterns` (full-match regex), because its prefix rules could not describe `/api/v1/projects/{pid}/chats/bulk-sync`. The save that runs when a tab closes had the same all-or-nothing behaviour against the browser's 64 KB cap for those requests: one large conversation meant nothing was sent. It counted characters rather than bytes, so text with multibyte characters could pass its check and still be over the cap. It now fits conversations into the 64 KB one at a time, measured in bytes, and leaves the rest pending (`frontend/src/api/conversationSyncApi.ts`, `frontend/src/utils/pagehideFlush.ts`, `app/middleware/request_size.py`, `app/server.py`, `app/config/env_registry.py`; tests `api/__tests__/bulkSyncBatching.test.ts` (new), `utils/__tests__/pagehideFlush.test.ts`, `tests/test_request_size_bulk_sync.py` (new)).
- Three diff-validation defects, each of which cost a correction round in one session (`app/utils/diff_utils/language_handlers/typescript.py`, `app/utils/diff_validation_hook.py` `reader_reason`, `app/utils/phantom_diff.py`, `frontend/src/utils/diffUtils.ts`, `app/server.py`; tests `tests/test_diff_validation_harness_fixes_1027.py` (new, 15), `diffRejectedMarker.test.ts`). (1) The TypeScript check, which runs with `--noResolve` and is meant to reject only syntax errors (TS1000-TS1999), matched any code beginning with "1", so the semantic TS18004 ("No value exists in scope…") counted as syntax; a diff whose only fault was a type error, or a symbol added by a sibling diff in the same response, was rolled back. (2) That rollback was reported to the model and the reader as "hunks did not match the current file", though every hunk had applied; it now says the patched file failed the language check and quotes the first error. (3) The phantom-diff detector fired on a quoted mention — a reply describing the detector (`the model says "apply the diff above"`) was sent back for a diff it never claimed; quoted and inline-code spans are now excluded. The notice for that case also no longer says "A patch did not apply cleanly" when no patch was refused.
- `ziya-install-extras` with no target left out `--data` and `--stats` ("opt-in only"), while the startup banner said "Install everything with: ziya-install-extras"; a user who ran the installer still had no `analyze` tool until they discovered the flags. The default run (and `--all`) now installs every extra that applies to the platform; the flags remain for installing one. On a machine with everything present the run prints one "ok already installed" line instead of a multi-section plan (the plan is still shown for `--dry-run`). Launchers that create a fresh venv per version drop every pip extra on upgrade before the new version's reconcile can see them; reconcile now also reads the previous version's venv (`previous_install_site_packages`) and carries over what it held, so those extras are re-installed rather than announced as never-installed. A background refresh that fails (e.g. an expired index login) is reported on the System Information page (`last_install_failed`, last log lines). Reconcile log lines went through a module logger the project caps at WARNING and never appeared; they use the project logger now (`app/utils/extras_registry.py`, `app/utils/install_extras.py`, `app/scripts/install_extras.sh`, `frontend/src/components/SystemInfo.tsx`; tests `tests/test_extras_registry.py`, `tests/test_install_extras_data.py`, `tests/test_install_extras_idempotent.py`).
- A scheduled task-card fire ran with `project_root=None`. A fire has no request and so no `X-Project-Root` header; the scheduler passed `None` to the `ExecutionContext`, never set the request-root contextvar, and recorded `None` in the run's permissions snapshot, so tool calls and artifact paths inside the body resolved against the server process's cwd rather than the project. The fire now resolves its root with the same rule as a header-less interactive launch (`resolve_launch_root`: the project's recorded path when that directory exists, else `None`), sets the contextvar in the run task as the launch endpoint does, and records that root in the snapshot (`app/agents/task_scheduler.py` `_fire_one`; tests `tests/test_task_scheduler.py::test_fire_one_runs_against_project_path`, `::test_fire_one_missing_project_dir_falls_back_to_none`).
- The Chromium check accepted any `chromium*` build in Playwright's browser cache, not the one the installed Playwright launches. On a machine with two Ziya installs (for example a pyenv wheel and a per-version launcher venv) on different Playwright versions sharing one cache, a build left by the other install counted as present: `ziya-install-extras` said "already installed" and skipped, Ziya offered `render_diagram`, and the launch failed with "Executable doesn't exist", after which the model suggested a raw `playwright install chromium`. The check now reads the revision the installed Playwright pins from its bundled `driver/package/browsers.json` (no driver started) and requires that build, fully installed (`INSTALLATION_COMPLETE`). Without a readable manifest it falls back to the old any-build rule. The installer uses the same check through the interpreter it runs under, so it now installs the missing build instead of skipping (`app/utils/optional_features.py`; tests `tests/test_chromium_build_match.py`, new, and `tests/test_optional_features_install_path.py`).
- When the model revised a task card later in a conversation (`task_card_write`), the card's tile stayed where it was first staged, possibly far up the chat, and nothing showed that it had changed. The tile now moves to the end of the turn that revised it and carries a "revised" tag (×N after repeat revisions, with the time). The place it moved from gets a one-line "revised later — jump to the current version" marker. If the card had only been run in the chat, the run's tile stays where it is and a new staged tile with Run appears at the revision. Edits made in the deck editor still update the tile in place, and revising a card that has no tile in the chat creates none (`app/utils/task_card_revision.py`, new; `app/mcp/tools/task_card_tools.py`; frontend `apis/chatApi.ts`, `types/task_binding.ts`, `utils/taskBindingRevisions.ts`, new, `components/TaskCard/RevisedTileMarker.tsx`, new, `components/Conversation.tsx`, `components/TaskCard/TaskCardInlineTile.tsx`; tests `tests/test_task_card_revision_placement.py`, `utils/__tests__/taskBindingRevisions.test.ts`, both new, and `apis/__tests__/taskCardStageSync.test.ts`).
- A diagram taller or wider than the headless capture window lost its top and left edge (`app/services/diagram_renderer.py` `_CAPTURE_FIT_JS`; test `tests/test_diagram_capture_fit_flex_center.py`, new). The harness root centres its child with flexbox, so an oversized child sits at negative coordinates; the fit step removed the overflow clip but not the centring, and an element screenshot cannot reach above y=0. A six-system music score captured with its first two systems and left margin missing and an equal band of empty canvas at the bottom. The unclip now also anchors alignment to the start.
- `tests/test_scheduler_heartbeat_survives_system_jobs.py` asserted a spawned system-job pass had started before yielding to the loop.
- An `mcp_config.json` whose indentation contains U+00A0 NO-BREAK SPACE (or another Unicode space between JSON tokens) now loads instead of dropping every user server (`app/utils/json_whitespace.py`, new; `app/mcp/manager.py`; tests `tests/test_json_whitespace.py` and `tests/test_mcp_config_unicode_whitespace.py`, new). Text pasted from a browser or chat client often carries NBSP; Python's `json` rejects it with "Expecting property name enclosed in double quotes", which does not point at the cause. The file is now read as `utf-8-sig` (a leading BOM is ignored). On a parse failure, characters between tokens for which `str.isspace()` is true become U+0020 and U+200B / U+FEFF are deleted, leaving string literals untouched, and the parse is retried. A success shows a non-fatal warning finding such as "normalized 26 U+00A0 NO-BREAK SPACE on lines 57-66"; the file is not rewritten. A file that still fails reports the original position, plus a hint when the character there is one of these ("line 57 col 5 is U+00A0 NO-BREAK SPACE, not a space").
- The MCP status modal's Config Stanza tab has a Copy button that writes `JSON.stringify(stanza, null, 2)` to the clipboard, and a manual selection of the stanza is copied as `text/plain` only, with U+00A0 / U+2007 / U+202F mapped to spaces and U+200B / U+FEFF removed (`frontend/src/utils/plainTextCopy.ts`, new; `frontend/src/components/MCPStatusModal.tsx`; test `frontend/src/components/__tests__/mcpConfigStanzaCopy.test.tsx`, new). The stanza was already a plain `<pre>` that bypasses markdown and linkify, so its text never contained NBSP. Rich-paste targets could still pick up the HTML flavour of the selection, which no longer goes on the clipboard.
- The External MCP Servers panels in the MCP status modal show each server's name again, and say where it came from (`frontend/src/components/MCPStatusModal.tsx`, `app/mcp/manager.py` `server_sources` / `get_server_sources()`, `app/routes/mcp_routes.py` `/api/mcp/status` gains `server_sources`; tests `tests/test_mcp_server_sources.py` and `frontend/src/components/__tests__/mcpStatusModalServerName.test.tsx`, new). The header showed the one-line description *instead of* the `mcp_config.json` key whenever a description existed, so a registry-installed server had no visible name. The header now shows the name in bold with the description beside it. The expanded panel gains a Source row: built-in (and whether a config file overrides it), contributed by a plugin, installed from a registry (provider and service id), or declared in a config file (with its path). It also notes when the stanza uses keys Kiro, Q Developer or Claude Code write (`transportType`, `disabled`). Provenance is held beside `server_configs`, not inside it, so it is never written back to the config or shown in the Config Stanza tab. A stanza another tool wrote in Ziya's own format can't be told apart from a hand-written one.
- The phantom-diff correction pass can no longer close with "no diff is needed" while the turn's own tool output still names the error (`app/utils/phantom_diff.py` `find_unresolved_errors`, `app/utils/diff_validation_hook.py` `tool_outputs=`/`phantom_evidence`, `app/server.py`; tests in `tests/test_phantom_diff_reference.py`, +9). Incident 2026-10-25: a turn's `tsc` run reported `error TS18004 ... 'stave'` and its jest run `ReferenceError: stave is not defined`, the response said "once the stave-scope diff above is applied" and ended, the detector fired — and the correction pass grepped for the symbols of *earlier* diffs, found them, and answered that nothing needed re-emitting. Two causes. The pass was blind: `server.py` appended only the feedback as a new user turn, never the assistant turn being corrected, so the sanitizer merged the feedback into the user's previous message and the model had no record of what "the diff above" referred to; the response being corrected is now appended as an `AIMessage` first. And the feedback offered the lazy exit unconditionally: compiler / type-checker / test-runner error lines (`path(l,c): error ...`, `ReferenceError: ...`, `XError: ...`) from this turn's tool results are now collected and quoted in the correction request, which then withdraws "no diff needed" and accepts only the fence, or a quote of the later tool output showing the error gone. Without such evidence the wording is unchanged.
- Bead tools now act on the conversation's real home record when the conversation is driven from a window open on another project (`app/storage/beads.py` `_resolve_chat_storage`; test `tests/test_bead_cross_project_home.py`, new). A global conversation surfaced into another project's sidebar is stored under its own project, but bead storage resolved the record by the window's project and found nothing there, so every bead read and write went to the standalone fallback file: the model saw an empty tree, "closed out" the same stale beads turn after turn, and they came back parked; a later turn from the home window then rewrote the record and deleted the fallback with the other window's writes in it (incident 2026-10-25, conversation 31fa660d). The 2026-10-02 `resolve_chat_home` rule already used by context tools, auto-context and handoff is now applied to beads too.
- The probationary proposal log is now safe under concurrent Ziya processes (`app/storage/proposals.py`; test `tests/test_proposals_store_cross_process_lock.py`, new). The log is encrypted as one envelope, so every append is a full read-decrypt-rewrite, and it was guarded by an in-process `RLock` only — the store's own docstring said cross-process coordination was "not provided". With 10+ servers on one `~/.ziya`, two appends in the same window both read N events and both wrote N+1: the second rename won and the first process's event was gone, and because both used the same `.jsonl.tmp` path the loser crashed on a rename of a file the winner had already moved. A six-process test reproduced both the crash and the lost updates; the live log carried the other fingerprint, a duplicate `record` event for one id. An `fcntl` advisory lock on `probationary.jsonl.lock` now wraps every read-modify-write (`_append`, `prune_terminal`). It only serialises — nothing about which events are written changes — and the test asserts every append is present exactly once afterwards.
- Memory extraction no longer re-runs every earlier window of a conversation on every turn (`app/memory/extraction_cursor.py`, new; `app/memory/extractor.py`; tests `tests/test_extraction_window_cursor.py`, new). The server dispatches extraction after every stream with the full non-muted history, and topic windows are prefix-stable, so a 200-turn conversation paid triage plus ~25 extraction calls per turn for output dedup then discarded. Each conversation now records the hash of every window it has extracted (one file per conversation under `~/.ziya/memory/extraction_cursors/`, so concurrent servers never race); an unchanged window is skipped, and a window whose text changed — the trailing window growing, or a turn being muted — is extracted again. Lost or corrupt cursor state only costs a re-extraction.
- Every timestamp the model is shown now carries its UTC offset, in one form (`app/utils/session_context_prompt.py` `format_prompt_timestamp`, new; `app/utils/precision_prompt_system.py`; `app/utils/project_change_note.py`; test `tests/test_prompt_timestamps_carry_offset.py`, new). `<CurrentDateTime>`, `<ConversationStartTime>` and the per-message `<MessageTime>` tags were naive local wall-clock times (`2026-10-23 23:51:04`) with nothing saying which clock they were on, while the project-change note in the same transcript was stamped UTC and tool output (`date`, git, file mtimes) carried its own zones; a model asked to reason about elapsed time had to guess the zone and spent turns reconciling them. All four now read `2026-10-23 23:51:04-07:00` (local time with offset) and the "Message Timing" paragraph in the system prompt says so. An aware `now=` passed by a caller keeps its own offset rather than being converted to the machine's.
- Reloading a browser tab now reopens the project that tab had open (`frontend/src/context/ProjectContext.tsx`, `frontend/src/utils/tabState.ts`; test `frontend/src/context/__tests__/tabProjectStickiness.test.ts`, new). The conversation id was already per-tab, but the project was remembered only browser-wide (`ZIYA_LAST_PROJECT_ID` in localStorage), so switching projects in one tab and reloading another moved the second tab to the first tab's project — and then off its saved conversation, which belonged to the original project. A per-tab, instance-scoped `ZIYA_TAB_PROJECT_ID` in sessionStorage is now consulted first on init and by `readLastProjectId()` (which ChatContext uses to scope the startup conversation list); new tabs still open the browser-wide last project, and a stored id the server no longer knows is dropped. A tab's own project also outranks the `--root`/`--directory` startup project on reload: that rule is for new sessions.
- The streaming Bedrock client never recovered from "input length and `max_tokens` exceed context limit", although `CustomBedrockClient` documents that it shrinks `max_tokens` and retries (`app/utils/custom_bedrock.py`; tests `tests/backend_system_tests/model/test_custom_bedrock.py`). The reduction sat in an `elif` reachable only with a conversation id, on an extended-context model, for an error that was NOT an overflow — so it never ran for the one error it parses. It now runs after the extended-context branches. The non-streaming path was unaffected.
- `ziya chat` on `--endpoint openai` with a GPT-5.x / 6.x model failed its no-tools path and the `/quit` tangent summary with `400 Unsupported parameter: 'max_tokens'` (`app/agents/wrappers/openai_direct.py` (`DirectOpenAIModel(max_tokens_param=)`), `app/agents/models.py` (`_initialize_openai_model`); test `tests/test_openai_direct_max_tokens_param.py` (new)). The provider path already sent the model's `max_tokens_param`; the legacy wrapper `ModelManager.initialize_model` builds hard-coded `max_tokens`. Found by the real-API capability tests.
- Stale tests under `tests/backend_system_tests/model/`: ten Nova wrapper modules imported `app.agents.nova_wrapper`, which moved to `app.agents.wrappers.nova_wrapper` in 0.3, and two computed the source path from the wrong directory; `test_ziya_bedrock.py` followed the server-default model, so once that became Sonnet 5.5 (no sampling parameters) its temperature/top_k checks failed. The Nova suites go from 0 to 31 passing; the rest is listed in the session notes, not fixed.
- `tests/backend_system_tests/model/test_model_connections.py` asserted nothing (every test patched `ModelManager.__init__` and read back what it had just set) and errored on fixtures no conftest has defined since `01c97e40`. It now resolves every configured model, every alias (against its target) and every endpoint default through the real `ModelManager.get_model_config`, offline: 160 tests.
- `get_skill_details` could not load any project or user skill by name (`app/mcp/tools/skill_tools.py`; covered by the previously failing `tests/test_project_skill_wiring.py` and `tests/test_skill_resources.py`). The lookup called an undefined `_request_project_root()`; the `NameError` was caught and logged at debug level, so the model was told "Skill 'X' not found" and offered only the built-ins. It now uses `get_project_root_or_none()`.
- Skills inside grouping folders are actually discovered (`app/services/skill_discovery.py` (`iter_skill_dirs`, `MAX_SKILL_DEPTH`), `app/services/skill_sources.py` (`LocalFolderSource._dir_for`, `_version_of`); `tests/test_nested_skill_discovery.py` now collects and passes). The 0.8.10.0 entry and `Docs/Skills.md` described this, but the walker was never committed, so every scanner still read only a root's immediate children and the test module failed at import. A local or git skill source also now finds a skill by leaf name instead of `root / skill_id`, which no longer resolves a `../` id outside the source.
- `tests/backend_system_tests/model/test_model_connections.py` and `test_model_transactions.py` failed at collection with `KeyError: 'sonnet'`; they now resolve the alias through `MODEL_ALIASES`. Their tests still error at run time on fixtures (`mock_aws_credentials`, `setup_test_environment`) that no conftest has defined since refactor `01c97e40`.
- Memory relation extraction threw away the most connected batches (`app/memory/organizer.py` (`RELATION_MAX_TOKENS`, `_salvage_json_array`); tests in `tests/test_memory_organizer.py`). The relation reply was capped at 1024 tokens. With memory ids that dense, that is about 29 relations, and a 20-memory batch often returns more. The reply was cut off mid-string, `json.loads` rejected the whole array, and the batch stored nothing, which showed up as `Relation extraction: JSON parse failed: Unterminated string …` on roughly one batch in five. The cap is now 4096 tokens. A reply that is still cut off keeps every complete relation before the cut and logs `reply truncated … kept N complete relations`. A reply that is not JSON at all still logs the original warning.
- On a fresh install, dragging a skill onto the Bench's on-hand lane failed with "Could not move …" and a `404` on `PUT /bench/place` (`app/api/bench.py` (`_view_model` now takes `project_id`), `app/services/bench.py` (`conversation_known`); tests in `tests/test_bench_api.py`). The hub opens on the conversation lens whenever a chat is open and relies on the server's `lenses` list to fall back to `project` when that chat has no record, but the view model listed the lens whenever an id was *passed*, not when the chat *existed*. A brand-new "New Conversation" is an empty shell the client never sends to the server, so every drop wrote to a conversation layer that `ChatNotFound`-ed. The lens is now offered only for a chat the server holds; an unsaved chat lands placements at the project layer, and the conversation lens appears once the chat has a message.
- A long list of always-on skills filled the top of every sidebar tab (`frontend/src/components/ActiveContextBar.tsx`; test `frontend/src/components/__tests__/ActiveContextBar.stack.test.tsx` (new)). The active-context bar is mounted above the file tree, the skills tab, the chat list and the backlog, so sixteen on-hand skills wrapped into six rows of pills on each tab and pushed the chat list below the fold on a laptop screen. The bar now shows the first four always-on skill pills and stacks the rest behind one `+N skills ▸` chip whose tooltip names them; clicking it unstacks (restoring each pill's X). A `▴` control folds the whole row to one line — a colour dot per always-on skill (twelve, then `+N`), a dashed ring per skill the model loaded on demand this turn, and a `16 on hand · 1 ✦` count; clicking the line returns to whichever of capped/all was in use. Both choices are remembered per project. With four or fewer always-on skills nothing stacks and neither control appears. Solid pills still mean "sent with every turn"; a dashed ✦ pickup is never stacked.
- The model was intermittently shown doubled braces in the file context — `{{a,b}}` for `{a,b}`, `${{x}}` for a TS template literal `${x}` — and could carry them into its answers and diffs (`app/utils/precision_prompt_system.py`; test `tests/test_codebase_braces_not_escaped.py` (new)). The codebase was brace-escaped before being passed as the `{codebase}` template *variable*, but `str.format` never re-parses substituted values, so the escape protected nothing and reached the model verbatim. A guard skipped it whenever any file in context already contained a `{{` (JSX `style={{…}}`), so the corruption appeared and vanished with the set of files in context: a markdown-only context was corrupted, the same file beside a `.tsx` was not. The escape is removed.
- Conversation search silently returned partial results ("missing terms") (`app/utils/chat_search_index.py` (new), `app/storage/chat_search.py` (`_candidate_files`), `frontend/src/components/MUIChatHistory.tsx` (`performSearch`), `frontend/src/utils/db.ts` (`lastSearchSource`); tests `tests/test_chat_search_index.py` (new), `frontend/src/components/__tests__/chatSearchStaleAndFallback.test.ts` (new)). Every search opened, decrypted and parsed every chat file in scope, which on a 2,350-chat install took 8.9 s for one all-projects search. The sidebar starts a search per debounced keystroke and never cancelled the earlier ones, so overlapping scans went past the browser's 25 s deadline, and the client then fell back to scanning IndexedDB, which only holds the bodies of conversations opened in that browser. Search now goes through an in-memory filter: one 6.3 KB bitmap per chat of the character trigrams in its text, built on first search and refreshed by file mtime and size, so writes by other processes are picked up. Only chats that can contain the query are read, and the matcher is unchanged, so results are identical to a full scan; warm, the filter takes about 20 ms. Queries with no three-letter run of `[a-z0-9_]` (for example `2.1%`) still scan in full. Concurrent searches wait for one build instead of each scanning. A response superseded by a later keystroke no longer overwrites the newer results, and a result from the local fallback now shows a warning that it may be incomplete. Kill switch: `ZIYA_DISABLE_CHAT_SEARCH_INDEX=1`.
- Reading another conversation by id took minutes when its turns were tool-heavy or it lived in another project (`app/mcp/tools/chat_history_tools.py`, `app/storage/chat_search.py` (`_candidate_files`), `app/utils/tool_history_rewrite.py` (`collapse_tool_blocks`); tests `tests/test_chat_read_tool_heavy_and_scoped_search.py` (new), `tests/test_chat_search_conversation_ids.py`). In the case that exposed it, one assistant message was 534,000 characters, nearly all tool output, and `chat_read` truncation kept only the first tool body, so the model never saw that turn's conclusions. Its fallback, `chat_search` with `conversation_ids`, found nothing until `all_projects=true` was added, and then every call decrypted every chat file in every project before applying the id filter (7–22 s per call, about 70 calls). `chat_read` now collapses tool blocks to one-line stubs by default (`tool_results="summary"`; `"full"` restores the raw view). Ids passed to `chat_search` are looked up directly through the chat index, in whichever project owns them, and only those files are read.
- A handed-off conversation showed two rows at its end, "The model drafted a handoff document" and "Handed off to …", both with the same "Continue in …" link. Once the "Handed off to …" notice exists, the drafted-document notice is no longer shown (`isSupersededHandoffNotice` in `frontend/src/components/HandoffNotice.tsx`, applied in `Conversation.tsx`). A conversation handed off before that notice existed still shows the drafted notice as its forward link.
- A parallel tool group's container swallowed every later tool call and paragraph of the message. The server relay dropped the `tool_group_end` event, so no group was ever closed. The relay now forwards it in both the main and the diff-validation retry loop, and a group whose end marker is missing now closes once all its members have arrived, which also repairs messages already recorded without it.
- **The release sweep no longer blocks a release on a group whose work already shipped, or defers files over a previous release's triage** (`scripts/release_plan.py` (`batches` mode), `design/sweep-orchestrated.task-card.json` (P0, P3a, commit loop, P5); tests `tests/test_release_plan_batches.py` (+2), `tests/test_sweep_orchestrated_card.py` (+2)). Run cli-188224db41e6 committed 222 groups, then stopped at the release gate for two reasons. First, one commit also carried a file the plan assigned to another group. That group then found nothing to stage, took the "already committed" exit without recording its id, and the gate kept treating it as outstanding. A group whose content is already at HEAD (whole files clean, or every patch reverse-applies) is now recorded with the commit that carries it, by the commit loop and again by the residue stage. Second, 56 paths were deferred because triage batches from the previous release still counted them as triaged, although their verdicts named entries the current Unreleased section lacks. P0 now archives triage batches older than the last release tag, and `release_plan.py batches` re-batches any `assign` verdict whose entry is no longer in the index, listing it under `stale_assigns`. The next run (cli-db2df7ec03ba) then stopped at P4a because every entry was already committed or deferred and the commit-plan contract demanded at least one group; the contract now also accepts an empty roster carrying a non-empty `already_committed_entries` list, so the run reaches P6, which is the stage that reports the deferral. The commit loop and residue sweep also carry a `time_budget_secs` (600 s and 900 s), after one Bedrock stream in the serial loop sat silent for 26 minutes (cli-4d15fd5c2684) before the 900 s thinking ceiling and 300 s socket timeout released it. (`design/sweep-orchestrated.md`; tests `tests/test_sweep_orchestrated_card.py` (+2))
- **Moving the conversation you are viewing to another project now takes you with it** (`frontend/src/context/ChatContext.tsx`, `frontend/src/components/MUIChatHistory.tsx`; test `frontend/src/context/__tests__/moveFollowsConversation.test.ts`). Previously the move dropped the conversation from view and selected a different conversation in the source project. Now the move saves locally and on the server, switches to the target project, and keeps the conversation on screen and selected while the rest of that project loads. Moving any other conversation works as before. A moved conversation that was global now has the flag cleared on the server as part of the move. The sidebar used to run a separate toggle after the move, and with the conversation now kept in view that toggle would have made it global again.
- **Shell env-prefix bypasses closed** (`app/mcp_servers/shell_server.py`; test `tests/test_shell_env_prefix_path.py`). `PATH=/tmp ls` was accepted because the allowlist matched `ls`, but the child resolved it through the prefixed PATH and ran `/tmp/ls`. A `PATH=` prefix is now allowed only when it resolves the command to the same file the normal PATH does, so `PATH=/opt/x:$PATH cmd` still works; a PATH built from any other expansion is refused. Inside `if`/`while`/`for` bodies, a segment starting with `NAME=value` was skipped as an assignment, so `FOO=1 anycmd` ran anything and `LD_PRELOAD=` escaped the loader-hijack block. The prefix is now peeled and the command behind it checked, as at top level.
- **Startup warns about the obsolete PyPI `asyncio` backport** (`app/utils/asyncio_backport_check.py`, wired in `app/main.py`; test `tests/test_asyncio_backport_check.py`). `asyncio` 3.4.3 from PyPI (declared by some third-party packages, e.g. `mcp-shell-server`) does not parse on Python 3.7+. Any environment that puts site-packages ahead of the standard library imports it and fails with `SyntaxError: tasks.async(...)` inside an unrelated import. The server now prints its location and the `pip uninstall asyncio` command at startup. Warning only.
- **Shell allowlist accepts `read`, and quoted or absolute command words** (`app/mcp_servers/shell_server.py`, `app/config/shell_config.py`; test `tests/test_shell_command_word_and_read.py`). `… | while read line; do …; done` was refused with `'read' is not allowed`. `read` is now in the built-in floor; both `sh -c` routes run with stdin closed, so it can only read what the command itself pipes in. `"ls" -la` and `/bin/ls -la` were refused although `ls` is allowed. The command word is now unquoted, and an absolute path counts as its basename only when PATH lookup resolves to that same file, so a copy written elsewhere (`/tmp/ls`) is still refused. The aws, curl and IaC guards see the normalized word, so `"/usr/bin/curl" <IMDS URL>` is refused exactly like `curl <IMDS URL>`. Refusals now name the whole command (`'/Applications/Google Chrome.app/…/Google Chrome' is not allowed` rather than `'"/Applications/Google'`).
- **`~/.ziya/file_states.json` backups are bounded, and the state file is written atomically** (`app/utils/file_state_manager.py`; test `app/utils/tests/test_file_state_backups.py`). When the state file couldn't be read at load, it was moved to `file_states.json.backup.<timestamp>` and tracking was reset. Nothing ever read or deleted those backups, and one install had 476 of them (3.6 GB, the largest 1 GB). Only the newest 5 are now kept, and older ones are pruned at the next server start. The state file was also written in place, so a second server sharing `~/.ziya`, or this server's own next load, could read a half-written file, fail to decrypt it and discard the live state as "corrupted". Saves now write a temp file and rename it over the original.
- **Production frontend builds drop `console.log` / `console.debug` / `console.trace`** (`frontend/craco.config.js`, `frontend/src/context/ChatContext.tsx`, `frontend/src/plugins/d3/vegaLitePlugin.ts`; test `frontend/src/utils/__tests__/consoleStripBuild.test.ts`). About 1,500 log calls shipped, several on per-flush and per-save paths, and once DevTools has been opened the console keeps every logged object alive for the life of the page. Terser `pure_funcs` now removes the calls in production builds; arguments with side effects still run. `console.warn`, `console.error` and `console.info` are kept, and development builds are unchanged. `window.debugChatContext()`, `debugIndexedDB()` and `debugVegaLite.inspect()` print through `console.info`, so they still work in production. Three temporary `console.trace` diagnostics were removed, including one that ran on every save. The server's end-of-turn `COMPLETE MODEL RESPONSE` log is Python logging and is unaffected.
- **The production console strip now catches the log calls it missed, and keeps `render_diagram`'s rewrite notes** (`frontend/craco.config.js`, `frontend/src/utils/colorUtils.ts`, `ChatContext.tsx`, `StopStreamButton.tsx`, `StreamedContent.tsx`, `plugins/d3/*`, `D3Renderer.tsx`, `utils/diagramUtils.ts`; codemod `scripts/console_info_rewrites.py`; tests `frontend/src/utils/__tests__/consoleStripBuild.test.ts`, `frontend/src/utils/__tests__/rewriteNotesSurviveStrip.test.ts`, `tests/test_console_info_rewrites_codemod.py`). Terser's `pure_funcs` only drops a literal `console.log` whose value is unused, so two shapes survived: an alias (`colorUtils`' `const log = debug ? console.log : …`, which printed every mermaid/graphviz/drawio contrast line, about 230 lines per stream end) and expression-bodied arrows (`m => console.log(m)`). Both are now block-bodied, `console.group`/`groupCollapsed`/`groupEnd` are stripped too, and a source scan fails the build's tests if either shape comes back. Stripping `console.log` had also silenced the `🔧 TAG: …` lines the headless renderer reads to tell the model how preprocessing changed its spec, because `/render` loads the production bundle. The 177 such reporters in the diagram code now print through `console.info`, which survives the strip; the other 🔧 logs (chat, sync, sidebar) stay stripped.
- **Collapsed tool results no longer highlight or parse their body on mount** (`frontend/src/components/MarkdownRenderer.tsx`; test `frontend/src/components/__tests__/toolBlockLazyHighlight.test.tsx`). `GenericToolBlock` shows only the digest preview while collapsed, yet ran Prism and `marked.parse` over the full payload on mount and kept the HTML in state, for every file read and MCP result in history. Its header effect also inline-parsed the whole payload when there was no summary, for a header only hierarchical search results display. Both body effects now wait for the first expand, and the header parse runs only for hierarchical results. Short results that never collapse still highlight on mount, and the 150 K-character highlight cap stays for expanded results. Shell and shadow output render through `TerminalPane` and are unaffected.
- **Saves and cross-tab updates no longer read the whole IndexedDB store** (`frontend/src/context/ChatContext.tsx`, new `frontend/src/utils/idbTargetedRead.ts`; test `frontend/src/utils/__tests__/idbTargetedRead.test.ts`). `queueSave` read every conversation in every project, with full message bodies, as the base for its merge on effectively every save (React state almost always holds deferred shells, which disabled the cached path), and the `conversations-changed` broadcast handler did the same in every other open tab on every save. Both now read only the conversation ids they act on (`changedIds` / the broadcast's `ids`), which produces the same written records because nothing outside those ids reaches the write. Saves without `changedIds` still read the whole store. The 5-minute other-project cache, whose branch became unreachable, is removed.
- **A reloaded tab reopens its conversation even when IndexedDB holds no conversation list** (`frontend/src/context/ChatContext.tsx`, `ProjectContext.tsx`; test `frontend/src/context/__tests__/restoreAfterPlaceholder.test.ts`; diagnostic `scripts/mem_probe/startdiag.mjs`). With no IndexedDB shells (cleared site data, an IndexedDB repair, a restored tab whose session storage outlived its IndexedDB), startup ended with an empty list and set a placeholder id; the restore effect only ran when there was no current id, so the saved conversation was never selected and the tab showed "No messages in this conversation yet" until, 60 s later, recovery switched to the most recent conversation instead. The placeholder is now recorded and replaced by the restore while unused, and the restore opens the conversation through `loadConversation` so a deferred shell loads its messages. The startup project filter and server fallback also read the instance-scoped last-project key (new `readLastProjectId()`); the unscoped key they read before is never written.
- **Committed diffs no longer rebuild when another turn starts or ends streaming** (`frontend/src/components/MarkdownRenderer.tsx` `DiffViewWrapper`; test `frontend/src/components/__tests__/committedDiffStreamingRemount.test.tsx`; probe `scripts/mem_probe/probe.mjs --domtrace`). `DiffViewWrapper` chose between two render branches with different element trees using the conversation-wide streaming flag, which flips for every message at send and at stream end. On a 100-message conversation with 12 diff-bearing messages, each flip tore down and rebuilt ~23 K elements of history and the tab's DOM node count briefly went from 65 K to 139 K at send and 180 K at stream end, with nothing on screen changing. The wrapper now follows the streaming state of the message that owns the diff, which is always false for history, so only the diff still arriving in the live turn takes the streaming branch.
- **A reader scrolled up is no longer pulled to the bottom when a reply finishes** (`frontend/src/hooks/useScrollManager.ts`; test `hooks/__tests__/useScrollManagerNotify.test.tsx`). When a stream ended, its streamed-content entry was cleared and the scroll manager read the length dropping to 0 as "a new user message was sent": it forced a scroll to the end 100 ms later and cleared the "completed while you were away" indicator the moment it appeared. Measured with `scripts/mem_probe/streamend.mjs --where reading`, the message being read moved 3,465 px in 11 jumps. The drop now only re-baselines the length, so the reader stays put and the indicator stays until they scroll down or click it; the next turn's "new content below" indicator also works when the reply is shorter than the previous one. Sending a message and switching conversation still scroll to the end (`App.tsx`), and a user pinned to the bottom is still held there while the finished message settles.
- **Streaming no longer re-renders the whole app on every flush** (`frontend/src/context/StreamedContentContext.tsx` new; `ActiveChatContext.tsx`, `ChatContext.tsx`, `hooks/useScrollManager.ts`, `hooks/useSendPayload.ts`, `components/App.tsx`, `Conversation.tsx`, `StreamedContent.tsx`, `SendChatContainer.tsx`, `ExportConversationModal.tsx`, `RawMarkdownView.tsx`, `MarkdownRenderer.tsx`; tests `context/__tests__/streamedContentIsolation.test.tsx`, `hooks/__tests__/useScrollManagerNotify.test.tsx`). `streamedContentMap` (replaced every 150 ms) and `reasoningContentMap` (replaced on every thinking delta) were part of the `ActiveChatContext` and `ChatContext` values, and `App` read the map directly, so every flush re-rendered `App`, the sidebar, the modals and every history message. A sampled heap profile of one 100 s stream measured 18.4 GB allocated (~180 MB/s), almost all antd/MUI styling and React bookkeeping, and V8 old space filled with the promoted garbage (108 → 471 MB in 80 s) faster than it was collected — the main cause of browser windows growing to tens of GB with a few streaming tabs. The maps now live in their own context: only `StreamedContent`, live thinking blocks and an open raw view subscribe; `Conversation` reads a presence set that changes only when a stream starts or ends; send and export read the maps at call time through a ref; and `App`'s scroll manager is fed by a child watcher (`useScrollManager` gains a stable `notifyContentLength`; `contentLength` is now optional). A second per-chunk trigger went through the same contexts: `chatApi` calls `setProcessingState('idle')` on every content chunk, and `updateProcessingState` built a new Map each time even when the state was unchanged, giving `getProcessingState` a new identity and re-rendering the sidebar, header and closed modals per chunk. It now returns the existing Map when the state is unchanged (test `context/__tests__/processingStateNoOp.test.ts`). Same stream, same 100-message conversation, before → after both changes: 18.4 GB → 0.5 GB sampled allocations (186 → 4.9 MB/s), JS heap peak 575 → 159 MB, renderer RSS peak 1.43 → 0.90 GB.
- **Launching delegates no longer downloads every message in the project** (`frontend/src/utils/delegateLaunchSync.ts` new; `frontend/src/components/DelegateLaunchButton.tsx`; test `frontend/src/utils/__tests__/delegateLaunchSync.test.ts`). After a launch, the button refreshed the sidebar by calling `listChats(projectId, true)`, which returns every chat in the project with its full message history (363 MB and 25–33 s on a large project), and parsed all of it in the tab to find the few new chats. The launch response already names them (`orchestrator_id` and `conversation_ids`), so only those ids are now fetched with `bulkGetChats`. If the fetch fails, the periodic sync picks the chats up.
- **Streaming code blocks no longer rebuild their DOM on every flush** (`frontend/src/components/MarkdownRenderer.tsx` `CodeBlock`, `renderTokens`; test `frontend/src/components/__tests__/codeBlockStreamingDomChurn.test.tsx`; probe `scripts/mem_probe/probe.mjs`). Browser windows with a few Ziya tabs swelled to tens of GB. Measured with a headless CDP probe: idle tabs allocate ~0.2 MB/100 s, but a streaming tab's renderer RSS sawtoothed 0.4 → 2 GB while its JS heap stayed under 300 MB — the growth was Blink-side DOM garbage. Each 150 ms flush re-ran Prism on the still-open code block and wrote it via `dangerouslySetInnerHTML`, then an effect ran Prism again, serialized `innerHTML` and wiped/rebuilt the subtree a second time, destroying and recreating every highlighted span; closed blocks earlier in the message also re-ran Prism on every flush. The arriving block now renders as a single text node (updated in place) and is highlighted once when it closes, settled blocks memoize their highlight on the text, and tool results over 150 K characters are shown unhighlighted (a 300 KB result blocked the main thread for 34 s).
- **Autoscroll lands on the real end, not the top of the last block** (`frontend/src/hooks/useScrollManager.ts`; test `frontend/src/hooks/__tests__/useScrollManagerFollow.test.tsx`, new). Follow mode pinned to the bottom in a rAF when the streamed text changed, but a tool fence or thinking block renders its body a frame later, and while "Waiting for model response…" nothing changes the text at all, so the view sat on the block's header with the body hidden beneath the sticky composer dock. The hook now also observes the content box with a `ResizeObserver` and re-pins on any layout growth while following — during the stream and for 1.5 s after it ends — and no longer reads the browser clamping `scrollTop` after a shrink (a thinking block collapsing) as the user scrolling away. Scrolling up still leaves follow mode; returning to the bottom re-enters it.
- **Header's signature poll no longer floods the log with "escalation denied"** (`app/utils/scope_approvals.py` `is_scope_authorized_with_reason(log=)`, `app/api/task_cards.py` `_escalation_rows`; tests `tests/test_scope_authz_query_path_is_quiet.py`). The server log repeated dozens of `SCOPE_AUTHZ: no approval record for task 'b-…' — escalation denied` lines every few seconds although no task was escalating: the Task Cards header indicator polls `/task-cards/signature-summary` on every window focus from every open tab, and that read-only query asked the approval predicate about every escalating block of every card, which logged each "not signed" answer as a refused escalation. The two UI queries (`/signature-summary`, `/{id}/scope-status`) now ask with `log=False`; the reason code they display is unchanged, and the runtime gate (`is_scope_authorized`, used by the task runner, `ziya-approve` and the scope audit) still logs every real denial.
- **Offline no longer reported as a credential failure** (`app/providers/bedrock_client_cache.py`, `app/utils/custom_exceptions.py` `ProviderUnreachableException`, `app/utils/infra_errors.py` `is_auth_failure`, `app/server.py`; tests `tests/test_bedrock_client_cache.py` `TestCredentialPreflightClassification`, `tests/test_infra_errors_credential_network.py`). With DNS or the network down, the Bedrock client's STS pre-flight raised `EndpointConnectionError`, which was rewrapped as "AWS credentials check failed: Could not connect to the endpoint URL" and shown in chat as the re-authenticate banner. A network-shaped pre-flight failure now raises `ProviderUnreachableException` (a `KnownCredentialException` subclass, so the startup handlers still keep the server up) whose message names the STS host and says to check the network; `is_auth_failure` returns False for it and the stream emits `error_type: connection_error`, which the frontend renders as *Connection Failed* with a retry button. Expired/invalid tokens still classify as auth.
- **Local model server out of memory gets its own banner** (`app/utils/provider_error_chunks.py` new, `app/streaming_tool_executor.py`, `frontend/src/utils/modelMemoryErrorBanner.ts` new, `frontend/src/apis/chatApi.ts`; tests `tests/test_provider_error_chunks.py`, `frontend/src/apis/__tests__/chatApiModelMemoryError.test.ts`). An oMLX prefill memory-guard rejection (or a llama.cpp / Ollama OOM) arrived as an untagged error chunk, so the server relayed `error_type: unknown` and the chat showed the generic collapsed "Error Details" block with the raw text. Non-auth provider errors are now shaped by `build_provider_error_chunk`, which tags recognised memory-ceiling messages `model_memory_error`; the frontend renders *Model Server Out of Memory* with the server's needed-vs-limit figures pulled out, the three remedies (shrink the prompt, free RAM, raise the server's ceiling) and a retry button, with the raw text under *Technical details*. Checked ahead of the context-size banner, since the oMLX text also says "reduce context length"; unrecognised messages keep the previous untagged shape.
- **Model picker re-reads local servers and marks the loaded model** (`app/utils/local_models.py` `SCAN_UI_MAX_AGE_S`, `scan_is_stale`, `choose_default_model`; `app/routes/model_routes.py`; `frontend/src/components/ModelConfigModal.tsx`; tests `tests/test_local_picker_refresh.py`). Models added to an oMLX library never appeared in the model modal: a non-empty local-server scan was kept for the life of the process (only an empty scan had a TTL), and the picker routes asked for the memoised registration. `/api/available-models` and `/api/endpoints` now request a scan no older than 2 s, so opening the modal re-reads the server while the several requests one open makes share one scan; chat-turn callers keep the never-expires contract and are never stalled by a port probe. A rescan also re-runs per-model discovery, so the server's `loaded` state (oMLX `/v1/models/status`, LM Studio `state`) lands on the model entry, is relayed per model, and the picker lists loaded models first with a green *loaded* tag. The endpoint's default model prefers the loaded one, so switching to a local endpoint does not evict a 100 GB model; `ZIYA_LOCAL_MODEL` still wins.
- **A redirection on a compound closer was refused as a command** (`app/mcp_servers/shell_server.py` `_validate_compound_body`; test `tests/test_shell_compound_closer_redirect.py`, new). `for f in *.md; do …; done 2>/dev/null | head` was blocked with `'2>/dev/null' is not allowed`: the compound validator strips the `done` keyword from the segment and then treats the next word as the command, so the loop's own redirection (`done 2>/dev/null`, `fi 2>&1`, `} > /dev/null`, `done < file`) became a bogus command word. Redirection tokens are now skipped before the allowlist match; a closer carrying only redirections has no command to check. The redirection target is unchanged in effect — the write policy's independent `_redirection` scan still refuses `done > app/out.txt` and permits `.ziya/` and `/dev/null`.
- **Non-prefill continuation prompts carried the answer twice, and the open-fence verdict is now taken from the complete text** (`app/streaming_tool_executor.py` inject branches, `_fence_open_in_full_text`; test `tests/test_no_prefill_continue_conversation_shape.py`, new). On a model without assistant prefill, each of the `max_tokens` / intent / block-completion continuation branches appended `assistant_text` after the iteration-end block had already appended that turn; the pre-send coalesce merged the pair into one `assistant[text,text]` message, so the model saw its own reply duplicated ("the text was accidentally printed twice") before the `[System: …]` prompt. The branches now append only when no assistant turn is already last. Separately, the "cut off mid-code-block" decision used the incremental streaming tracker's `in_block`, which has reported an open fence on complete responses (a closed ```` ```mermaid ```` block, `transitions=1`); the verdict is now re-derived in one pass over the finished text, and a `FENCE_STATE_DIVERGENCE` warning records the incremental state when the two disagree so the next case is attributable. Known, not yet fixed: after a block-completion continuation the next iteration's tracker starts fresh, so the model's bare closer is read as an opener and a further continuation fires (bounded by the stall cap).
- **Service calls on a local endpoint evicted the chat model** (`app/services/model_resolver.py` `_local_service_model`; test `tests/test_local_service_model_affinity.py`, new). Memory triage, extraction, the intent judge and the tier-declared `improve_judge` resolved to the local server's *default* model — its first listed one — rather than the model the conversation was running. Observed on oMLX: after turn 1 on a 94 GB Qwen, triage asked for GLM-5.3 (103 GB), oMLX evicted Qwen to load it, and turn 2 failed with 507 "Cannot load … projected memory would exceed the ceiling". Local endpoints now use `ZIYA_MODEL` for every service category when the server lists it (env overrides such as `ZIYA_MEMORY_EXTRACTION_MODEL` still win); the server default applies only to a model the server does not have.
- **Complete reply judged "cut off mid-code-block" on fine-grained streams** (`app/text_delta_processor.py` viz-block flush, `app/streaming_tool_executor.py` tool_use flush; test `tests/test_fence_tracker_viz_flush_seam.py`, new). The content optimizer's `flush_remaining()` before a ```` ```mermaid ```` (or other viz) block and before a `tool_use` block yielded the held text to the user but never fed it to the fence tracker. With small deltas (claude-fable-5-1) the tracker was left holding a stale partial line (`## What `), glued the whole viz buffer onto it (`## What ```mermaid`), missed the opener and read the closer as a bare opener — `in_block=True, transitions=1` at a clean `end_turn`, so `NO_PREFILL_BLOCK_CONTINUE` asked the model to finish a block it had closed, and the reply gained a paragraph explaining nothing was cut off. Both flushes now feed the tracker first, matching every other optimizer drain.
- **Three diff-applier defects behind one docs-edit failure** (`app/utils/diff_utils/pipeline/pipeline_manager.py`, `application/git_diff.py`, `application/patch_apply.py`, `validation/validators.py`; test `tests/test_diff_countless_header_backtick_escape.py`, new). A three-file documentation patch had two files rejected as "Malformed hunk detected" and the third reported applied while it kept a removed line and dropped a kept one. (1) The difflib stage, the atomic git path and `_is_valid_hunk_header` required four numbers in the hunk header, so the count-less single-line form `@@ -63 +63 @@` — valid unified diff, and the form the system prompt asks the model to use — was rejected; the parser had accepted it all along. (2) The `\`` transport escaping the model is told to preserve was never undone before the `patch` and `git apply` stages (the frontend forwards the fence verbatim), so every hunk touching a line with a backtick failed those stages and reached difflib as its last chance. The pipeline now unescapes at entry, grounded in the target file so a template literal's genuine `\`` is kept. (3) difflib's context-preservation walk, entered whenever old and new line counts happen to be equal, consumed at most one old line per added line; a hunk that adds one line and replaces two with one misaligned by one and copied its trailing context from the wrong file line, with the stage reporting success. The walk now follows the hunk's raw `' '`/`-`/`+` markers. The streaming verifier's rejection of the third file was correct — it saw the corrupted result — and now reads "already applied" after a clean apply.
- **Overlapping-hunk merge sized its region by header counts, duplicating lines past an undercounted header** (`app/utils/diff_utils/application/overlapping_hunks_fix.py` `merge_overlapping_hunks`; test `tests/test_diff_overlap_merge_undercounted_header.py`, new). Found while chasing the follow-up from the fix above: on the live fallthrough path (patch and git apply both failed) the difflib stage receives the model's header counts unrecounted, and the parser repairs an undercount only when it is 2+ lines on both sides. When two hunks are merged as overlapping, the merged old region ended at the header-declared count of the hunk that started last, while the per-hunk splice replaced `len(old_block)` lines — so a body that ran one line past its header count pushed the splice off the end of the region and every line past it came out twice. The stage reported success, and a re-apply reported "already applied", so the streaming verifier could not catch it. The region now ends at the furthest of each hunk's declared-or-actual extent. A survey of the other `old_count` consumers in the stage (search windows, truncation rescue, already-applied probes) found no further divergence between recounted and raw headers across 20 single- and two-hunk miscount shapes.
- **Out-of-policy write approvals now actually learn, and say what they remember** (`app/utils/consent_grants.py` (new), `app/utils/consent_gate.py`, `app/utils/consent_ledger.py`, `app/api/consent.py`, `frontend/src/utils/consentStore.ts`, `frontend/src/components/ConsentPanel.tsx`, `frontend/src/components/WriteGrantsChip.tsx` (new), `SendChatContainer.tsx`; tests `tests/test_consent_grants.py` (new), `tests/test_consent_grants_api.py` (new), `ConsentPanel.test.tsx`; `design/scope-consent.md` D1–D4, `Docs/Capabilities.md`). Approving a write "for this conversation" or "always" felt like it remembered nothing: grants matched the exact file only (every new file asked again), lived in process memory (a restart forgot them, "always" included), and the panel's dropdown chose *how long* while fine print silently fixed *what*. The panel is now `Approve · for [scope ▾] [duration ▾]`: the scope dropdown walks from the file up its ancestors to the whole project, duration is this call / this conversation / this project, and any remembering choice shows the server-produced rule sentence that is stored verbatim. Grants match by prefix at the chosen rung, persist in `<project>/.ziya/consent-grants.json`, never cover the floor (`.git`, install dir, outside root), and are listed and revocable from a *write grants* chip next to the input box. The panel reopens at the rung last chosen in the conversation. `session` and `always` scopes are retired (the API returns 422).
- **Parallel tool calls in one turn were almost never actually parallel** (`app/tool_batch.py`, `app/mcp/client.py`; tests `tests/test_tool_batch_classifier_quotes.py`, `tests/test_mcp_client_rate_limit_builtin.py`, both new). The per-turn batch runner overlaps consecutive read-only calls, but its shell classifier split commands on `|` *inside quotes*, so every `grep "a\|b"` became a serial barrier; measured, three ~50 ms greps took ~5 s. Classification now tokenises shell-style (quoted operators are arguments), understands `rg`, `jq`, `xargs <cmd>`, `timeout N <cmd>`, `time <cmd>` and `git check-ignore`, and treats observing builtins (`render_diagram`, `analyze`, `dataset_*`, `job_status`/`job_read`, `task_run_read`, `ledger_query`, `view_image`, `shadow_wait`, …) as overlappable. The MCP client's 2 s per-tool rate limit no longer applies to the builtin shell server (external servers keep it; `rate_limit_seconds` in a server config overrides either way). Audit data (2026-10-15..20) showed the runner does overlap co-issued reads but rarely gets any: 77% of tool batches were a single call and the median model round-trip between them was 9.3 s against sub-second tools. A per-stream tracker (`app/utils/serial_reads_nudge.py`) now attaches a system note to the tool-result turn on the second consecutive message holding exactly one read-only call (repeating every third message while the streak lasts), telling the model its reads would have run concurrently had it issued them together; any batched or non-read message resets it (`tests/test_serial_reads_nudge.py`, new). External `mcp_*` tools, previously always serial, now overlap when they only observe: by `permissions.json` (`parallel.read_only_tools` / `parallel.serial_tools`), by the server's `readOnlyHint` annotation (now carried from `tools/list` through `MCPTool` and `SecureMCPTool.metadata`), by the absence of a write-target argument (`savePath`, `output_file`, …) and finally by name, where any mutating word (`update`, `post`, `download`, `run`, …) beats any observing one (`get`, `search`, `read`, `list`, …); anything unclassified stays a barrier (`app/mcp/client.py`, `app/mcp/enhanced_tools.py`; tests `tests/test_tool_batch_external_tools.py`, new).
- A valid Mermaid flowchart failed with `Expecting 'SQE' ... got 'STR'` when a quoted label contained JSON-ish text such as `{backlog:[…], done:[…]}` (`frontend/src/plugins/d3/mermaidEnhancer.ts`; test `mermaidQuotedLabelColonBracket.test.ts`, new). The `colon-in-node-id-fix` pass scans for `id-with-colon[` and restarts after every `[` it consumes, so inside the quoted label it resumed at `done:` — a clean token none of its substring guards rejected — and quoted it as a node ID. Both the scan and the replacement now skip positions inside an open double-quoted string on the line.
- The two gantt label-colon passes now agree on one encoding (`mermaidEnhancer.ts` `gantt-label-colon-fix`; test `mermaidGanttLabelColon.test.ts`). The stricter `gantt-task-name-colon-escape` (priority 140) runs first and emits `#58;`, which mermaid decodes back to a real colon; the older fallback still emitted a fullwidth colon (U+FF1A, a lookalike glyph) for the lines whose data section the strict grammar rejects, and its suite had been red since the strict pass landed. The fallback now emits `#58;` too, and the suite asserts the chain's output plus a case only the fallback handles.
- Vega-Lite interval bars (`x`/`x2`, or `y`/`y2`) rendered nothing (`frontend/src/plugins/d3/vegaLitePlugin.ts` `applyRangedBarFixes`, new; tests `vegaRangedBar.test.ts` and `vegaRangedBarRender.test.ts`, both new — the latter compiles and renders through the real Vega-Lite/Vega pipeline and counts visible bars: 0 before the fix, one per episode after). Two Vega-Lite semantics models reliably get wrong: a `timeUnit` on the primary channel is a bucketing transform that moves `x` into the 2012 reference year while `x2` stays in the data's year (decade-wide axis, bars off-screen) and floors sub-unit intervals to zero width; and once `x2` is set, `y` is a point position, so a quantitative `y` with no `y2` is a default-thickness sliver that `cornerRadius` erases. The timeUnit is now translated to `axis.format` (`utc*` → utc scale) on every layer sharing the scale, and a quantitative height on an x-ranged bar gets `y2: {datum: 0}`. Gantt bars (nominal y), explicit `y2`, log scales and the y-ranged mirror are left alone.
- ```` ```chord ````, ```` ```force-directed ```` and ```` ```network ```` fences were shown as raw JSON: the fence scanner and plugin registry knew the languages but `determineTokenType` only routed the literal `d3` language. A shared `d3FenceLang` map now routes them to the D3 renderer and supplies the spec `type` when the body omits it; `chord-directed` is also marked atomic in the fence scanner.
- **Muting a message is now a versioned change, so it can no longer be silently undone by sync** (`frontend/src/context/ChatContext.tsx` (`toggleMessageMute`); test `frontend/src/context/__tests__/muteBumpsVersionWiring.test.ts` (new)). Mute was the one conversation mutation that did not stamp `_version`. Every place that decides whose copy wins is version-gated: the server's bulk-sync keeps its on-disk copy of matched messages when the push is older than the record, and the client reconcile lets a fetched copy replace the in-memory one on a version tie. A mute pushed while the server's version was ahead (routine after a streamed turn) was dropped, and the unmuted copy flowed back in, so the muted turn still rendered as muted but was sent to the model again. Observed as a fork of a conversation with a muted 225k-token response being rejected at over 1M tokens; toggling the mute off and on in the fork restored the smaller request.
- **A flamegraph, railroad or wavedrom block missing its closing fence no longer swallows the next diagram** (`frontend/src/components/fenceScanner.ts` (`ATOMIC_FENCE_LANGS`); test `frontend/src/components/__tests__/atomicFenceLangCoverage.test.ts` (new)). A `flamegraph` fence followed directly by a `mermaid` fence with no close in between was read as one flamegraph block, which failed with "line 11 has no trailing sample count" on the escaped mermaid opener. `repairAtomicFenceRuns` already closes a diagram block at the next column-0 opener, but its language set had not been extended when these three renderers (and their aliases) were added. The new test reads the diagram aliases out of `determineTokenType`, so a renderer added there without the repair now fails a test.
- **Mermaid xychart with time labels on the x-axis renders** (`frontend/src/plugins/d3/mermaidXychartAxisLabels.ts` (new), `mermaidEnhancer.ts` (`xychart-axis-label-quote`); test `__tests__/mermaidXychartAxisLabels.test.ts`). `x-axis [05:54:15, 05:54:16, ...]` failed with "Expecting ... got 'COLON'": the xychart lexer reads a bare band label as letters, digits and `+=*#_.&-` only. Band labels outside that set (colons, inner spaces) are now wrapped in double quotes; labels mermaid already parses are unchanged. The pass is registered for both `xychart` and `xychart-beta`; the existing xychart passes match `xychart` only, so none of them ran for a `xychart-beta` header.
- **Mermaid state diagrams with colons or semicolons in labels render.** A
time in a note or transition label (`note right of X : at 05:54:19`) failed
with "got 'DESCR'"; label colons and prose semicolons are now escaped and
still display as written. The state-shape pass no longer declares phantom
states taken from label text (`05`, `54`), and a `;` between statements or
at the end of one no longer creates a state named `;` or `B;`.
- **Search-result lists choose raw code vs. markdown per row.** The first
row's language used to decide for every row, so one markdown row made
line-numbered code rows render as mangled markdown, and vice versa.
- **Escaped backticks and dollars inside double-quoted shell arguments now
reach the program as bash would deliver them**
(`app/mcp_servers/shell_server.py`). Bash removes the backslash before
`$`, `` ` ``, `"` and `\` inside double quotes; the executor's `shlex`
tokenizer did so only for `"` and `\`, so a SQL quoted identifier in
`python3 graf_q.py "SELECT ... FROM \`table\` ..."` arrived as
`` \`table\` `` and ClickHouse refused it, and `"\$HOME"` was expanded by
the pre-tokenize variable pass instead of being delivered literally. The
escaped pair is now swapped for a placeholder before expansion and
restored per token; `$?`/`$$` expansion skips an escaped `$` too. The
"write the query to a file first" workaround is no longer needed.
Tests: `tests/test_shell_double_quote_escapes.py`.
- **A model could not retract a memory proposal it made last turn**
(`app/mcp/tools/memory_tools.py`). `memory_propose` returned the
`prop_*` id only as a sibling field; the chat record keeps just the
message line, so on the next turn the model had no id to hand to
`memory_retract_proposal` and fell back to "reject it when you review
proposals". The message line now names the id, and
`memory_retract_proposal` also accepts the proposal's `content` (exact
match, or a unique substring among this conversation's own open
proposals) and lists this conversation's open proposals when it cannot
resolve a request. Same-conversation retraction has no gate; proposals
from other conversations remain the user's to review.
- **Zero-argument external MCP tools rejected with "EMPTY ARGUMENTS"**
(`app/streaming_tool_executor.py`). Tools such as a Jira server's
`refresh_jira_auth` / `get_jira_myself` (schema `required: []`) were
bounced client-side before reaching the server. `SecureMCPTool` carries
the server's JSON schema only in `metadata['input_schema']` and has no
pydantic `args_schema`, so the `optional_only_tools` probe (pydantic
only) never classified any external tool; separately, the set stored
`mcp_<name>` while the gate compares the `mcp_`-stripped name. The probe
now reads the dict schema first and registers both spellings.
- **document_authoring skill pointed at a hard-coded port.** The export
recipe said `localhost:6969`; a task-card stage copied it, got a non-200
from a port nothing listened on, declared the export server unreachable
and fell back to pandoc while the real server (on another port) was
answering `%PDF` the whole time. The skill now addresses
`localhost:$ZIYA_PORT` (the server publishes its bound port into the shell
tool's environment), requires a `head -c 5` → `%PDF` check, and only
permits an "unreachable" conclusion after every `lsof`-discovered port has
been tried and recorded. Docs/DocumentAuthoring.md mirrors the recipe.
- Rendered images inlined into tool blocks as `data:` URIs are elided from history before it is sent, so a conversation with several renders is no longer re-uploaded image by image on every turn or rejected by the request-size limit before the server's own elision runs (`frontend/src/utils/elideInlineImages.ts`, new; test `frontend/src/utils/__tests__/elideInlineImages.test.ts`).
### Changed
- Ziya's remote (HTTP/SSE) MCP client works with both `mcp` 1.x and 2.x, and new installs resolve to 2.x (`pyproject.toml` `mcp = ">=1.23.0,<3"`). SDK objects are read through `app/utils/mcp_sdk_compat.py`, which handles the 2.x snake_case renames and builds the HTTP client type the transport expects (`httpx` on 1.x, `httpx2` on 2.x). This fixes remote servers such as chorus-mcp failing with `'InitializeResult' object has no attribute 'protocolVersion'` when `mcp` 2.x was present. It also fixes three reads that 2.x would have broken without any error: tool errors (`isError`) reported as success, image MIME types reset to `image/png`, and `readOnlyHint` annotations dropped. Audio content is no longer labelled as an image. `RemoteAuthProvider` plugins declare `http_libraries` and build their client with `mcp_sdk_compat.http_module()`. A provider that cannot build the type the installed SDK needs is refused before connecting, with the reason (`remote_auth_sdk_mismatch`). Tests: `tests/test_mcp_sdk_compat.py`, `tests/test_mcp_remote_sdk_e2e.py` (a real in-process server; run under each SDK). The stdio client and built-in servers do not use the SDK and are unaffected.
- The Bedrock default model is Opus 5.5 again (`opus5.5`), replacing Sonnet 5.5. This affects only users who haven't picked a model; a model set with `--model`, `ZIYA_MODEL` or in saved settings still wins (`app/config/models_config.py` `DEFAULT_MODELS`).
- Mermaid upgraded from 11 to 12.2.0, and building the frontend now needs Node 22.12 or later (`.nvmrc` and `mise.toml` pin 24). Mermaid 12 changes the default look (`neo`) and lays graph diagrams out with ELK; Ziya pins the classic look so existing diagrams keep their appearance, and chooses the layout engine per diagram type. `scripts/mermaid_layout_eval.py` (new) renders a set of hard diagrams under both dagre and ELK in headless Chromium and scores each from the SVG (edge crossings, edges through nodes or labels, overlapping labels, size); results go to `.ziya/layout-eval/` (`frontend/package.json`, `frontend/src/plugins/d3/mermaidPlugin.ts`).
- Four music test suites that had gone stale against already-shipped renderer rules now follow them: `musicSystemWrap` (D-140 grows an undersized pinned width; D-138 ignores a pin past the legibility limit, with its own test), `musicOrnamentation` (a below chord symbol is compared with a bare note, since D-438 reserves a band for above symbols too), and the `musicWrapParity` snapshots (stave ink #6b6b6b from 2186d44f, grace-note slashes, ledger and chord-symbol headroom). Each was confirmed to fail identically on HEAD's renderer before it was changed.
- Within-reach MCP loads are cache-stable and can be per tool (`design/mcp-on-demand.md`, revision 2026-10-25; `app/utils/mcp_ondemand.py`, `app/utils/bench_prompt.py` `mcp_filter_for_root` / `apply_mcp_filter`, `app/models/chat.py` `loadedMcp`, `app/mcp/tools/mcp_loader_tools.py`, `app/streaming_tool_executor.py`, `app/api/bench.py`, frontend `CapabilityCard.tsx`; tests `tests/test_mcp_ondemand.py`, `test_bench_prompt_seam.py`, `frontend/.../benchLoadedChip.test.tsx`). The first cut derived "loaded" from a marker in history, so tributary folding or muting the loading turn un-loaded the server and the next use cost a second full-prefix cache miss; its catalog listed only connected servers, so a server connecting after turn 1 moved the system prompt; and a loaded server's tools were inserted mid-array. Now the loaded set is stored on the chat record (a fork copies it; the bench card shows "loaded" / "N tools loaded"), the catalog is built from placement and config alone, and the tool array is ordered builtins → on-hand servers by name → loaded servers in load order, so a load only appends. New `search_server_tools(query, server?)` ranks a within-reach server's tools without loading anything, and `load_server_tools` takes an optional `tools` list, cumulative per conversation, so one tool of a hundred-tool server can be loaded without the other ninety-nine.
- A task card's Schedule block no longer puts the card on the scheduler by itself; the user registers the card, and the registration (not the card) is what fires (`app/agents/schedule_registry.py`, new; `app/agents/task_scheduler.py` `_enumerate_scheduled_cards`; `app/api/schedules.py`, new; `app/server.py`; `app/api/task_cards.py` `/schedule-state` gains `registered` / `enabled`; `app/data/built_in_skills.py`; tests `tests/test_schedule_registry.py`, new, and `tests/test_task_scheduler.py`). The scheduler listed deck cards with an enabled Schedule block, so a card the model staged in chat (conversation-only) ran once on Run and never fired again, with nothing saying so; pausing meant editing the card. Now `POST /api/v1/projects/{pid}/schedules/{card_id}` registers a card with a `registered_at` time, copying the cadence from its Schedule block (or a daily 09:00 default for a card without one; the whole card is then the body) with the request body's edits on top; `PATCH` enables/disables it or changes the time and frequency, re-projecting the next fire at once; `DELETE` unregisters and drops the fire history; `GET` lists registrations with next fire, last outcome, fires, skips and `exhausted` (max runs reached), plus cards that carry a Schedule block nobody has registered, and `GET /api/v1/scheduler/status` reports which process holds `~/.ziya/scheduler.lock`. None of these touch the card (no version bump, nothing in its export). One registration per card; `<project>/schedule_registrations.json`. Cards the old rules had armed (a `schedule_state.json` record whose card still has a Schedule block) are migrated once into registrations, paused if the block was disabled; a card with a Schedule block and no record was never armed and is listed as unregistered. The task-card skill no longer tells the model that launching a Schedule-rooted card registers it. The management UI (Scheduled list with register / pause / edit) is not in this change.
- The bench's Within reach and Shelved lanes list MCP servers and skills most recently used first (`app/utils/capability_usage.py`, new; `app/mcp/tools/skill_tools.py`, `app/mcp/manager.py` `call_tool`, `app/api/bench.py` (items gain `last_used`), `frontend/src/apis/benchApi.ts`, `frontend/src/components/Bench/laneFold.ts`, `BenchBoard.tsx`; tests `tests/test_capability_usage.py`, `frontend/src/components/Bench/__tests__/benchBoardRecency.test.tsx`, new, and `laneFold.test.ts`). They were in the server's catalog order, which reads as random. A use is a successful `get_skill_details` load or a call to one of a server's tools that passes the quarantine and permission checks; the time is kept per user in `~/.ziya/state/capability_usage.json` under a file lock, only ever moves forward, and is written at most once a minute per item per process. Never-used items follow in the previous order. On hand is unchanged. Built-in skills at their default still fold into the "N built-in skills" row, now ordered by recency inside it. `Skill.lastUsedAt` was not usable for this: it records creation and edits, not use.
- The MCP status modal lists External MCP Servers (or the "No external MCP servers configured" notice) above Builtin Tools and the builtin servers (`frontend/src/components/MCPStatusModal.tsx`; test `frontend/src/components/__tests__/mcpStatusModalSectionOrder.test.tsx`, new). The servers a user configured were below the builtin ones they rarely change.
- The handoff drawer's "Ask the model to draft it" now runs on the standard `medium` model tier by default, with a selector for another tier or the conversation's own model (`frontend/src/utils/handoffDraftModel.ts`, new; `frontend/src/hooks/useHandoffDraftRequest.ts`; `frontend/src/components/HandoffDrawer.tsx`; tests `frontend/src/utils/__tests__/handoffDraftModel.test.ts`, new, and `frontend/src/hooks/__tests__/useHandoffDraftRequest.test.tsx`). The drafting turn inherited the conversation's model pin, so a conversation pinned to a frontier model paid frontier rates for a summary the user then edits. The tier resolves through `/api/model-tiers` for the active endpoint (the same table Task Card blocks use) and pins the background turn per request; the choice is remembered per browser, and an unreadable tier table falls back to the conversation's pin rather than failing the request. The automatic draft written under context pressure is unchanged — it is part of the model's own turn.
- `test_large_context` calls models through the production provider path (`app/providers/factory.create_provider`) instead of the legacy LangChain wrapper, names provider errors and refusals, and uses a benign needle — Claude Sonnet 4.6 refused "find the secret code" with `stop_reason=refusal`. Real-API result: 65 passed; remaining failures are Google (402, prepaid credits exhausted), `bedrock/grok-4.6` (stream stall), and `openai/gpt-5.5-pro` / `gpt-5.4-pro` (Responses-only models on a Chat Completions endpoint — a product gap, not a test issue).
- The tributary fold is now gated on context pressure (`app/utils/tool_history_rewrite.py` (`FoldSettings.pressure_ratio`, `FoldPressure`, `FOLD_PRESSURE_HYSTERESIS`, `plan_folds(pressure=)`, `fold_pressure_for`, `record_fold_savings`), `app/utils/handoff_prelude.py` (`get_context_pressure_detail`), `app/models/project.py` (`contextManagement.fold_pressure_ratio`, default `0.8`), `app/server.py` (passes `conversation_id` to the rewrite), `app/utils/session_context_prompt.py`; tests in `tests/test_tributary_fold.py`). Folding every eligible hunt as soon as two turns had passed cost the model verbatim text it still had in view — the exact lines a diff's context is built from, the exact path a follow-up edits — long before the window was under any pressure, and it had to notice the loss to recall it. Nothing now folds until the conversation's *unfolded* replay reaches 80% of the context limit (the same per-conversation measurement the handoff nudge uses); then the oldest eligible turns fold first, until the projected size is back under 65%. The gate compares the unfolded size (the measured size plus what the previous turn's folds removed), so a fold never lowers the pressure that justified it: the folded set is a prefix that only grows, each placeholder is written once and stays in the cached prompt prefix, and a conversation never flaps between folded and unfolded. Unknown pressure (first turn after a restart) folds nothing. `fold_pressure_ratio: 0` restores the old fold-everything behaviour. The "Folded tool turns" paragraph in the Session Context no longer asserts that earlier turns *are* folded, since below pressure none are.
- **File-write results render as a real diff.** The body of a `file write:` tool block (a `file_write` patch) now goes through the same renderer as a chat diff fence — file header, unified/split and line-number controls, Prism syntax highlighting inside the add/remove colouring — with the "already written to disk" caption and no Apply button (`ToolDiffPreview` → `ReadOnlyDiffView` in `frontend/src/components/MarkdownRenderer.tsx`). The previous react-diff-view rendering drew removed and added lines interleaved, struck through and uncoloured; its palette in `styles/toolDiffApplied.css` is reduced to the caption. Test: `components/__tests__/toolDiffPreview.test.tsx`.
- **Steer record-shaped work to the dataset tools.** A review of task-card runs found the model building bespoke JSON stores and `python3 + json` aggregation scripts (5,000 snapshot copies of one backlog, 1,700 JSON files for one matrix) while `analyze` went uncalled. The shared session-context prompt now carries a standing `### Working with data` section (chat and task blocks alike, gated on the `datasets` category); the `dataset_register` / `analyze` descriptions open with when to reach for them; the `task_cards` skill's blackboard recipe is an append-only JSONL log read by `analyze` instead of a rewritten state object; and the card validator warns when a task's instructions maintain a hand-built JSON store and name no data tool.
- `dataset_register` / `analyze` accept the JSON shapes models actually write: an object of objects becomes rows keyed by `_key`, an object with one array-of-objects member reads that member, and `record_path=` (or `path#member` in `analyze` inputs) selects one when several qualify. Previously any top-level JSON object failed with a pandas "All arrays must be of the same length" error.
- **Shell `aws` subcommand policy narrowed and made explainable.**
`aws s3 cp` / `aws s3 sync` are blocked only when they write *into* S3
(upload, bucket-to-bucket); a download to a local path (`aws s3 cp
s3://bucket/key /tmp/x`) is the same read as `s3api get-object` and now
passes. `mv`/`rm`/`rb`/`mb` and the IAM/STS/deploy legs are unchanged.
The refusal now says it comes from Ziya's built-in subcommand policy, not
the command allowlist, so an approved `aws` in Shell Configuration no
longer looks like a broken allowlist. And the policy defers to an explicit
grant: if the verified allowlist names `aws`, `aws <service>` or
`aws <service> <action>` itself, the user asked for it and the hardening
steps aside. The grant is read from the signature-checked scope env, so an
unsigned `aws` hand-written into `mcp_config.json` does not lift it.
- The context-budget bar in the sidebar footer and the collapsed strip is one shared component, stacked by what is spending the budget (files · chat · tools · ast), so the two can no longer disagree on colour or proportion; the track turns red past the danger threshold (`frontend/src/components/TokenBreakdownBar.tsx`, new; `frontend/src/components/TokenCountDisplay.tsx`; test `frontend/src/components/__tests__/tokenBreakdownBar.test.tsx`).
- Packet and force-directed graphs use the shared Open / Source / Save diagram actions, and every plugin's action cluster is styled by one CSS rule set, hidden until the diagram is hovered (`frontend/src/plugins/d3/diagramActions.ts`, `packetPlugin.ts`, `forceDirectedPlugin.ts`, `frontend/src/index.css`).
### Removed
- Removed obsolete tests under `tests/backend_system_tests/model/`: seven near-duplicate Nova wrapper modules (import, syntax, LangChain type-hint and runtime copies of `test_nova_wrapper.py`), a reproduction of a long-gone LangChain `AgentFinish` error (`test_nova_lite_streaming.py`), and five methods asserting `ZiyaString` / `ZiyaMessageChunk` behaviour those classes no longer have; also the one-off 2025 migration scripts `tests/strategic_integration_plan.py` and `tests/execute_integration.py`. `test_nova_wrapper.py` now follows the live `NovaWrapper` (base of `NovaBedrock`): its stream test stubbed `converse` while `_astream` calls `converse_stream`. `test_ziya_bedrock.py` follows the client wrapping moving to ModelManager. Model suite, offline: 19 failures to 0, apart from the bug above.
## [0.8.10.0] - 2026-10-14
### Added
- **The `git` tool can amend the top commit and force-push, and asks you every time.** Until now a stacked-change hand-off ended with "my git tool can't --amend or force-push" and two commands for you to type. `commit` now takes `amend: true`: it replaces HEAD with HEAD plus what's staged (or exactly `paths`), and keeps HEAD's message if none is given. The approval names the replaced commit and warns when it's already on a remote branch. `push` now takes `force: true`, which is `--force-with-lease=refs/heads/<b>:<sha>` on the remote-tracking sha shown in the approval and never a bare `--force`. The approval lists every remote commit that would be dropped, a remote that moved since your last fetch is refused by git with a hint to `fetch`, and forcing the remote's default branch is refused outright. Both run under their own consent ops, `commit_amend` and `push_force`, declared in the new `consent_human_only_ops`. For those ops the gate always asks: a `permissions.json` `always`, an autonomous project, or a conversation or session grant doesn't skip the prompt, and the panel offers "once" only. Approving `commit` or `push` therefore never approves a history rewrite. `git_ops.truthy` reads the flag the same way for dispatch and for the consent op, so `amend: "false"` is a plain commit in both places (plain `bool("false")` would ask about a commit and then amend). (`app/utils/git_ops.py`, `app/utils/consent_gate.py`, `app/mcp/tools/git_tool.py`, `app/utils/git_grant_hazards.py`, `design/git-tool.md`, `Docs/Capabilities.md`; tests `tests/test_git_ops_amend_force.py` (new), `tests/test_git_ops_fixup.py`)
- **The Conversation Info dialog's Cost row lists the spend per model.** Each ledger row already records the model that answered that call and is priced at that model's rate, so a conversation that switched models mid-way was summed correctly — but only the total was shown, beside no model at all, which read as "priced at the current model". The row now shows one line per model (`global.anthropic.claude-opus-5-5 · $48.07 · 288 calls`, …) under the total. The conversation rollup is grouped by model instead of status; the estimate/actual split the row needs comes from `totals`, so nothing was lost. (`frontend/src/api/usageApi.ts` — `fetchConversationCost`, new `modelGroupLabel`; `frontend/src/components/ConversationInfoModal.tsx`; test `frontend/src/api/__tests__/usageApi.test.ts`)
- **Task cards can be exported to a file and imported into another deck.** The deck has **Export** on each card's `⋯` menu and in the editor toolbar, **Export all** and **Import** beside New/Refresh; `GET /task-cards/export[?ids=…]`, `GET /task-cards/{id}/export` and `POST /task-cards/import` back them. The file (`<name>.ziya-task-cards.json`) is the card's *definition* only: `id`, run history, `version`, `draft` and `source` are stripped, as are every default-valued and null field, so a 4-block card is a few hundred bytes of readable tree rather than 8 KB with ~190 `null`s and a task block claiming `schedule_enabled: true`. An `about` list in the envelope explains the block vocabulary, `{{sibling("ID")}}` and what `scope` means, for a reader (or another tasking agent) with no Ziya knowledge. Import remints every block id and rewrites `sibling()` references to follow — the same reason Duplicate remints: approvals key on block id, so a signature from another machine must not authorize anything here, and an imported escalating card shows **Unsigned** until signed in this deck. An import is all-or-nothing; a name collision (a `call` by name resolves the first match) and launch-validator findings are reported as warnings, not refusals. A bare card object or a list of cards imports too. Also carries the card's `requires` block through create, which `TaskCardCreate` previously dropped. (`app/storage/task_cards.py`, `app/api/task_cards.py`, `app/models/task_card.py`, `frontend/src/services/taskCardApi.ts`, `frontend/src/components/TaskCard/TaskCardsLibrary.tsx`; tests `tests/test_task_card_export_import.py` (new, 32))
- **`git` tool `fixup` op: fix a commit in the middle of the stack** (`app/utils/git_ops.py` (`plan_fixup`, `op_fixup`; `_commit_files` / `_record_commit` factored out of `commit`), `app/mcp/tools/git_tool.py` (`target` argument), `frontend/src/components/ConsentPreflight.tsx` (summary sentence); tests `tests/test_git_ops_fixup.py` (new, 15), `tests/test_git_tool_schema_parity.py` (+1), `__tests__/ConsentPreflight.test.tsx` (+1); `Docs/Capabilities.md`, `design/git-tool.md`). The tool could only commit on top of HEAD, so a fix that belonged in an earlier commit had no route through it. `git(op="fixup", target=<commit>)` commits the staged changes (or exactly `paths`) as `fixup! <target subject>` and returns `fold_in`, the `git rebase -i --autosquash` command that folds it into the target. The tool never runs the rebase, so it still never rewrites history. The approval shows the target, the files and the commits after it, and warns when the target is already on a remote branch or a merge commit is in the way. A target that isn't an ancestor of HEAD is refused.
- **Tool-consent prompts render the plan, not JSON** (`frontend/src/components/ConsentPreflight.tsx` (new), `consent-preflight.css` (new), `ConsentPanel.tsx`; standard recorded in `Docs/BrandGuide.md` "Consent & Approval Panels"; tests `__tests__/ConsentPreflight.test.tsx` (new, 4), `__tests__/ConsentPanel.test.tsx` (+1)). The "Allow git push?" panel dumped the preflight as `JSON.stringify` in an amber box borrowed from the task-card Ask panel. It now shows a one-sentence summary for every `git_ops` plan (push/commit/add/unstage/stash/fetch/switch/checkout/clone), a labelled detail grid (humanized keys, SHAs highlighted, booleans as words, `{path, change}` rows readable), the raw plan behind a toggle, and a footer split into Approve+scope and reason+Reject. The chrome is teal on both themes with the question in a header band; the amber stays with task-card Ask panels, where it means *awaiting_input*.
- **The block editor can mark a block optional** (`frontend/src/components/TaskCard/RequiredToggle.tsx` (new), mounted in `TaskBlockEditor`, `CallBlockEditor`, `ParallelBlockEditor`, `RepeatBlockEditor`, `UntilBlockEditor`; `runMapModel.ts` (`blockConfigLines` shows `Required: no`); test `TaskCard/__tests__/requiredToggle.test.tsx` (new, 8)). `Block.required` existed on the model and in the executor but no editor surface could set it, so `required: false` was reachable only by hand-editing a card's JSON. Every non-root block header now carries a `required` checkbox; unchecking writes `required: false` (shown amber as "optional"), re-checking removes the key so a saved card stays minimal. The root has no toggle — the field is ignored there.
- **Pre-launch warning for a required Task under `on_failure: stop` with no output contract** (`app/utils/task_card_validation.py` (`_has_effective_contract`, `_walk` `parent_stops`); tests `tests/test_task_card_validation.py::TestRequiredUnderStopWithoutContract` (7, the positive case fails on the old validator)). Such a task's only verdict is the model's self-assessment, and the executor fails it on `partial` alone — which under `stop` halts every later sibling (a nightly card lost its second half to one "partial: lane 1B skipped"). The validator now warns, naming both exits: an `output_contract` (a satisfied required contract overrides `partial`) or `required: false`. A State's `output_contract_defaults` in scope silences it; children of a Parallel are not warned, since `_execute_parallel` never reads `on_failure`.
- **`scope.cwd` is documented, settable on every block layer, and checked before launch** (`app/data/built_in_skills.py` (`task_cards` grammar — Scope section), `app/utils/task_card_validation.py` (`_check_cwd`), `frontend/src/components/TaskCard/BlockScopeButton.tsx` (working-directory row), `runMapModel.ts` (`blockConfigLines` Scope line); tests `tests/test_task_card_validation.py::TestCwdConfinedToProject` (10), `TaskCard/__tests__/scopeCwdAllLayers.test.tsx` (new, 3)). `merge_scopes` already carried a container's `cwd` down to its leaves (innermost wins, like `model_tier`) and the executor already confined it to the project root — but the grammar never mentioned `cwd` and said scope "does NOT cascade", only the Task editor could set it, and an escaping or nonexistent `cwd` was discovered at run time as a silent fall-back to the project root. Models may now put `"scope": {"cwd": "services/api"}` on a Group, Parallel, Repeat, Until, Schedule or the card root. The validator errors on a `cwd` that escapes the root (`../x`, or an absolute path elsewhere) and warns on one that does not exist yet; container editors show and set it; the run map's Configuration names it. Not a privilege — no signature involved.
- **The focused-block panel says why a block failed** (`frontend/src/components/TaskCard/failureReason.ts` (new: `failureReasonOf`), `BlockDetailPanel.tsx`, `frontend/src/types/task_card.ts` (`Artifact.self_assessment`); tests `TaskCard/__tests__/failureReason.test.ts` (new, 4), `detailFailureReason.test.tsx` (new, 3)). A failed block or iteration showed `failed` as a status word and a summary that often read like success; the rationale lived in the un-rendered `self_assessment` field or deep in the decisions list. The panel now opens the output with a "Why it failed" box carrying the same string `task_finished.failure_reason` and the `task_run_read` tool produce — `self_assessment objective_met=partial: lane 1B skipped`, `empty_summary: no prose`, `output_contract: …` — derived from the persisted artifact so a run opened days later reads the same as one watched live.
- **Shell and shadow-terminal results render as one terminal pane** (`frontend/src/components/TerminalPane.tsx` (new), `frontend/src/utils/terminalPane.ts` (new), `MarkdownRenderer.tsx` (`ToolBlock` dispatch); tests `utils/__tests__/terminalPane.test.ts` (new, 24), `components/__tests__/terminalPane.test.tsx` (new, 8, including the ToolBlock seam)). A `run_shell_command` result was a raw `$ cmd` transcript in the generic tool chrome, and a `shadow_send` / `shadow_wait` / `shadow_read` result was a JSON tree whose transcript sat in one field among `ok`, `idle`, `next_seq` and the lease record. Both now share one shape: a header with a `REMOTE` badge (or a `LOCAL` one, shown only while a shadow session is attached to the conversation or the conversation contains a shadow tool call — otherwise every command is local and the tag distinguishes nothing; `frontend/src/utils/shadowPresence.ts` (new), test `utils/__tests__/shadowPresence.test.ts` (new, 3)), the working directory, the command, and status chips (`✓ exit 0` / `✗ exit N` / `⏱ still running` / line count and elapsed), over the transcript. The indicator colour tells them apart — teal for a command the server ran, fuchsia for one driven through a `ziya shadow` session (not amber or red, which already mean warning and error). Remote panes carry a seq gutter (the number `shadow_read(from_seq=…)` takes) and fold the lease overlay, the echoed command line and the shell prompt into a "N journal events hidden" row. The working directory is read from the `cd X &&` prefix or, for remote results, from the prompt line when it matches a known shape (`(date) <exit> [~/path]`, `user@host:~/path$`, `[user@host ~/path]$`, `~/path ❯`); the `<N>` in the first of those also supplies the exit status when the journal reports none. Long output is clipped to 8 rows with an expand control. Blocked commands and failed shadow results keep the generic block.
- **A file-operation block's file can be dragged into other applications** (`frontend/src/utils/fileDrag.ts` (new: `attachFileDrag`, `textToDataUrl`), `frontend/src/components/FileDragChip.tsx` (new, `filePathFromHeader`), `frontend/src/components/MarkdownRenderer.tsx` (chip in the `GenericToolBlock` header for `file read:` / `file write:` blocks), `app/api/drag_helper.py` (`GET /api/v1/drag/file`), `app/utils/drag_file_source.py` (new: `resolve_drag_file`, confined like `file_read`, 8 MB cap), `app/utils/drag_pasteboard_helper.py` (generic `<!--ziya-drag-file:name:data:…-->` carrier beside the PNG one: `extract_drag_payload`, `dest_path_for`, `safe_filename`; the helper keeps the file's own name in a per-drag subdirectory, and `cleanup_stale` sweeps those too); tests `tests/test_drag_file_source.py` (new, 8), `tests/test_drag_pasteboard_helper.py` (+7), `frontend/src/utils/__tests__/fileDrag.test.ts` (new, 4), `frontend/src/components/__tests__/fileDragChip.test.tsx` (new, 2)). The header of a `file_write` / `file_read` result names a file but holds no bytes (a patch-mode write carries one hunk, a read may be a window, history drops tool inputs), so a `⠿ name.md` chip in that header fetches the file from the server on press and publishes it at dragstart as a real named file (`DownloadURL` + File item), its text (`text/plain`) and the helper carrier (`text/html`). Finder / Explorer / browser drop zones get the file; on macOS with the native helper, Slack desktop does too, under its own name. The chip is a separate element so the header text beside it stays selectable.
- **Diagrams and tables dragged out of the chat drop into Slack desktop (macOS)** (`app/utils/drag_pasteboard_helper.py` (new: `plan_rewrite`, `extract_png_data_url`, pyobjc poll loop `main`, `HelperSupervisor`), `app/api/drag_helper.py` (new: `POST /api/v1/drag/arm`, `GET /api/v1/drag/status`), `app/server.py` (spawn on startup, stop on shutdown), `app/utils/install_extras.py` (`--drag`; included by `--all` and the no-target default on macOS via `default_python_extras`, skipped when `AppKit` is already importable so the default run never needs the package index just to re-check it — `python_extra_installed`; tests `tests/test_install_extras_data.py` (+4)), `pyproject.toml` (`ziya[drag]` = `pyobjc-framework-Cocoa`), `frontend/src/utils/nativeDragArm.ts` (new), `frontend/src/utils/diagramDrag.ts` / `tableDrag.ts` (PNG carrier comment in `text/html`; prepare + arm on `pointerdown`); tests `tests/test_drag_pasteboard_helper.py` (new, 13), `frontend/src/utils/__tests__/nativeDragArm.test.ts` (new, 5), `diagramDrag.test.ts` (+1), `tableDrag.test.ts` (+1); `Docs/EnvironmentVariables.md` (`ZIYA_NATIVE_DRAG`)). A web page cannot put a file on a drag, and Slack desktop — an Electron receiver — accepts a drop only when the drag pasteboard carries a real `public.file-url`; it ignores Chromium's file promise, inline PNG bytes and data URLs, and strips every filename when it sees the `org.chromium.chromium-renderer-initiated-drag` marker. A small helper process (spawned only on macOS with pyobjc installed via `ziya-install-extras --drag`; `ZIYA_NATIVE_DRAG=0` disables) watches the system drag pasteboard and, for a drag whose renderer origin is this Ziya page, re-declares the item without the Chromium markers and with a `public.file-url` to a PNG it writes under `~/.ziya/tmp/drag/` (stale files are swept). The PNG is read off the pasteboard itself: the frontend prefixes the drag's `text/html` with an `<!--ziya-drag-png:…-->` comment (Chromium publishes it as `public.html`), which the helper strips again so rich-text targets still receive the plain `<img>` or `<table>`. The frontend arms the helper with a throttled fire-and-forget POST on `pointerdown`, which also prepares the PNG immediately — fixing a drag from a cold diagram or table shipping the SVG fallback because the hover-delayed rasterization had not finished. Every other drag on the machine is untouched; the route was confirmed with `scripts/drag_pasteboard_probe.swift` / `drag_pasteboard_inject.swift`.
- **Clicking the chart handle copies it as an image** (`frontend/src/utils/diagramDrag.ts` (`copyImageToClipboard`, `dataUrlToBytes`, `copySource`/`onCopied` options), `frontend/src/utils/tableDrag.ts` (same options), `frontend/src/components/D3Renderer.tsx`; tests in `diagramDrag.test.ts` (+3), `tableDrag.test.ts` (+1)). The hover handle on pointer-interactive charts (Plotly, Vega, Joint, DrawIO, force-directed) is also a copy button: click writes the PNG as an `image/png` `ClipboardItem` (handed a `Promise<Blob>` so the write stays inside the click gesture while rasterization finishes) and a toast confirms. Static diagrams and tables have no handle at all — the whole diagram, or any press on a table beside its text, is the drag source — so nothing sits over content. The handle exists only where a plugin owns the pointer and a drag on the chart itself must keep panning/zooming it. Also fixes the table drag always shipping the SVG fallback: arming `table.draggable` at `pointerdown` changed `outerHTML` and invalidated the prepared-PNG fingerprint, so the fingerprint now uses `innerHTML` and the exported markup drops the `draggable` attribute.
- **Markdown tables can be dragged into other applications** (`frontend/src/utils/tableDrag.ts` (new), `frontend/src/components/DraggableTable.tsx` (new), `frontend/src/components/MarkdownRenderer.tsx` (table case wrapped); tests `frontend/src/utils/__tests__/tableDrag.test.ts` (new, 7) and `frontend/src/components/__tests__/draggableTable.test.tsx` (new, 1)). A rendered table can be dragged from any press that lands beside its text — cell padding, gridlines, empty cells — decided per gesture at `pointerdown` by hit-testing the caret position against the text node's line boxes (`isPointOverText`), so a press on the words still selects text; a hover-revealed handle (top-right) remains as the discoverable fallback. The table travels in three forms at once: a PNG of the table as rendered via Chromium's `DownloadURL` (Slack, Finder, Explorer receive an image), the `<table>` itself with computed styles inlined as `text/html` (Docs, Word, Confluence and Sheets paste a real table), and the markdown source as `text/plain`. The PNG is made by wrapping the styled table in an SVG `foreignObject` and painting it through the diagram rasterizer; a drag that starts before it is ready carries the SVG. Also hardens `d3RendererVizCleanup.test.tsx` against CRA's `resetMocks`, which was silently wiping its `newPlot` implementation.
- **Rendered diagrams can be dragged into other applications as images** (`frontend/src/utils/diagramDrag.ts` (new), `frontend/src/components/D3Renderer.tsx` (drag-out effect on the plugin container), `frontend/src/types/d3.ts` (`D3RenderPlugin.pointerInteractive`), opted out in the Plotly, Vega, Vega-Lite, force-directed, Joint and DrawIO plugins; tests `frontend/src/utils/__tests__/diagramDrag.test.ts` (new, 10) and `frontend/src/components/__tests__/d3RendererDiagramDrag.test.tsx` (new, 2; the packet case fails on the old renderer, whose container was never a drag source)). Dragging a rendered Mermaid, Graphviz, packet, LaTeX or other static diagram did nothing: browsers only put image bytes on the drag pasteboard for a native `<img>` drag, and an inline `<svg>` carries nothing. The plugin container is now a drag source that publishes a PNG rasterization (opaque background taken from the surrounding theme, 2x on retina) through Chromium's `DownloadURL` drag type — the same file-promise path an `<img>` drag uses — so Slack, Finder/Explorer and documents receive a real `.png` that renders inline rather than an attachment, plus a `text/html` `<img>` for rich-text web targets. The PNG is prepared when the pointer rests on the diagram and cached against the SVG's content; a drag that starts before it is ready carries the SVG instead. Plugins that own pointer gestures (zoom, brush, pan, node drag) set `pointerInteractive`; for those the renderer shows a small hover-revealed handle at the diagram's bottom-left that is the drag source instead, so dragging the chart itself still pans/zooms. Plotly supplies its own PNG via `Plotly.toImage` (`__vizExportPng` on its render root) because its SVG layers omit WebGL traces. Also fixes a pre-existing test-environment gap: `frontend/craco.config.js` now maps `d3` to its UMD bundle under jest, so any test that drives `D3Renderer` through `import('d3')` (including the existing `d3RendererVizCleanup` test) runs instead of failing on d3's ESM entry.
- **The Schedule editor shows what the scheduler did with the card** (`app/api/task_cards.py` (`get_schedule_state` adds `last_run`), `app/utils/task_run_read.py` (`last_run_summary`), `frontend/src/components/TaskCard/ScheduleHistory.tsx` (new: `ScheduleHistory`, `ScheduleCardContext`), `scheduleSummary.ts` (new: `summarizeScheduleState`, `formatRelative`), `ScheduleBlockEditor.tsx`, `TaskCardEditor.tsx` (provides the context), `frontend/src/services/taskCardApi.ts` (`scheduleState`, `ScheduleStateRecord`), `task-card-editor.css`; tests `tests/test_task_cards_schedule_api.py::test_enriches_with_last_run_outcome_and_skips`, `TaskCard/__tests__/scheduleHistory.test.ts` (new, 6)). A skipped slot and a failed last fire were recorded in `schedule_state.json` and the server log but on no screen; a skipped 03:05 night was discovered at 08:00 by opening the run. The Schedule block now carries a status line — "fired 47 times · last 22h ago: failed — self_assessment objective_met=partial: lane 1B skipped · skipped 1 slot (6h ago: run abcdef12 still live) · next in 2h" — refreshed every 30 s, amber with a ⚠ when a skip, a queued slot, or a failed/held last run needs attention. The endpoint resolves the newest still-existing run in `run_ids` to `{status, failed, failure_reason, held_reason}` using the same rule `task_run_read` applies, so a deleted run is skipped rather than reported.
- **`task_run_read` tool: a run's outcome is readable without the browser** (`app/mcp/tools/task_run_read.py` (new), `app/utils/task_run_read.py` (new: `summarize_run`, `list_runs`, `read_journal_events`, `failure_reason_of`, `resolve_card_id`), `app/mcp/builtin_tools.py` (`task_cards` category), `app/mcp/tools/task_card_launch.py` (description no longer points at "task-run views" that no tool exposed); tests `tests/test_task_run_read.py` (new, 6), `tests/test_task_run_read_tool.py` (new, 3)). Run records are encrypted at rest and the live journal was read only by the UI, so a model — or an operator working through one — diagnosing a failed scheduled run had to reconstruct the verdict from text deltas in `events.jsonl`. The tool lists a project's or a card's runs newest first (card by id or name), reads one run (or `run_id='latest'` for a card) and returns bounded, payload-free facts: status and hold reason, the card name/version from the launch snapshot and whether it was a scheduled fire, every block's status with its `failure_signature` / `self_assessment` / one-line `failure_reason`, a `failures` list naming which block failed and why, an index of emitted outputs, the verdict-bearing journal events (`task_finished`, holds, asks — text and tool traffic are counted, never returned), and the card's schedule-state with fires and skips. `block_id` drills into one block's full decisions and iteration summaries. `failure_reason_of` applies the executor's rule to the stored artifact, so runs whose journal predates the `failure_reason` field get the same answer.
- **A container's child can be marked `required: false`** (`app/models/task_card.py` (`Block.required`), `app/agents/block_executor.py` (`_execute_parallel`, `_execute_sequence`), `app/data/built_in_skills.py` (`task_cards` grammar), `frontend/src/types/task_card.ts`; tests `tests/test_block_required_false.py` (new, 9; the positive cases fail on the old executor, which counted every child)). A Parallel reported failed whenever any child failed, and under `Group(stop)` that halted the run, so a fan-out of `{1A baseline, 1B known-issues}` had no way to say that 1A must pass and 1B may not. An optional child's failure now neither trips an enclosing `on_failure: "stop"`, nor makes a Parallel report failed, nor — as the last child of a sequence — fails the sequence's own artifact; a child that raises is treated the same way. It is recorded, not hidden: the container's `decisions` carry "optional child <name> failed … (required=false)" with the child's signature, the child's own stage evidence still reads failed, and the next sibling still receives the failed artifact as `{{previous_sibling}}`. Default `true`, so every existing card is unchanged. Also corrects the grammar text added earlier in this release, which said `on_failure` on a Parallel governs sibling cancellation: `_execute_parallel` does not read it at all.
- **A satisfied output contract outranks a `partial` self-assessment** (`app/agents/task_executor.py` (verdict/contract reconciliation after the contract check), `app/utils/completion_check.py` (`is_failure` docstring), `app/data/built_in_skills.py` (`task_cards` grammar); tests `tests/test_output_contract_overrides_partial.py` (new, 6; the positive case fails on the old executor, whose contract check could only add failure and never clear it)). A task that emitted every `required` entry of its `output_contract`, each validating against its pinned schema, and then closed with `objective_met="partial"` was recorded failed — and under `Group(stop)` halted the run — on the strength of its own self-report. The contract tests the thing the verdict exists to catch ("did this task abandon its goal mid-run?") directly, so when a required contract is fully satisfied the task is now recorded ok and the verdict kept as a warning: in `decisions` with the model's rationale, and as `self_assessment` on the artifact and the `task_finished` event. Unchanged: a task with no contract or an all-optional contract (the verdict is the only evidence), a `false` verdict (a model that says false with every output present is contradicting itself), an unmet contract, and the empty/truncated-stream failures, which stand even with outputs present.
- **A Schedule says what a due slot does while a run of its card is still live** (`app/models/task_card.py` (`Block.schedule_concurrency`), `app/agents/task_scheduler.py` (`_tick_card`, `_concurrency_gate`, `_live_runs_for_card`; the fire pass is extracted from the loop body so the decision is testable), `app/data/built_in_skills.py` (`task_cards` grammar), `frontend/src/types/task_card.ts`, `frontend/src/components/TaskCard/ScheduleBlockEditor.tsx` ("if still running" selector); tests `tests/test_task_scheduler.py` (7 added — the default-skip case fails on the old scheduler, which fired). The scheduler had no notion of overlap: a slot fired while a previous fire, or a hand-launched run of the same card, was still running the same workspace, and a laptop waking at 09:00 fired the missed 03:00 catch-up slot on top of a run started by hand at 08:00. `schedule_concurrency` is `skip` (default: the slot is dropped, `next_fire_at` advances, and the skip is recorded in the card's schedule-state as `skips[]` / `skips_so_far` / `last_skip_at` with the live run's id, so GET …/schedule-state shows "skipped: run <id> still live" instead of silence), `queue` (the slot is held with `deferred_since` and fires on the first tick after the live run ends, bypassing the catch-up gate since a deferral is a decision to run late, not a missed slot), or `parallel` (the previous behaviour). The gate runs before the catch-up gate, so catch-up fires are subject to it; only `LIVE_RUN_STATUSES` count, so a failed, held or partial predecessor does not block.
- **`task_finished` carries the verdict, not just `ok`** (`app/agents/task_executor.py` (`_failure_reason`); tests `tests/test_task_executor_finished_verdict.py` (new, 4)). The event now has `self_assessment` (the parsed `objective_met`/`rationale`, or null), `failure_signature` (`self_assessment_partial`, `empty_summary`, `truncated_summary`, `output_contract:<names>`) and a one-line `failure_reason`. Run records are encrypted at rest and events.jsonl was the readable trace, so an operator diagnosing a failed night had to reassemble the model's closing tag from text deltas.
- **The task-card grammar says how a Task is judged** (`app/data/built_in_skills.py`, `task_cards` skill — also what `task_card_stage` returns as the grammar). States in so many words that `objective_met="partial"` records the task as failed, lists the other three executor-imposed failures (empty summary, truncated summary, missing contracted output), and that a Parallel reports failed to its parent whenever any child failed regardless of its own `on_failure`, which only governs sibling cancellation — so `Parallel(continue)` inside `Group(stop)` halts the Group.
- **A staged task card can be inspected and edited before it runs** (`frontend/src/components/TaskCard/TaskCardInlineTile.tsx` (`StagedCardTile`), `TaskCardsLibrary.tsx` (`handleSave`, `handleAddToDeck`), `frontend/src/hooks/useTaskBindings.ts` (new `TASK_CARD_SAVED_EVENT`), `task-card-inline-tile.css`; tests `TaskCard/__tests__/stagedTileFullInspect.test.ts` (4 of its 5 cases failed against the old tile; 4 added)). A card staged into a conversation — by the model's `task_card_stage`, by `/goal`, or by the deck's *Copy to conversation* — showed only its name, a collapsed Instructions box holding the first task's text, and Run / Save to deck / Discard; nothing of a loop's mode, stop condition, scope or later blocks was visible, and there was no way to change any of it short of discarding. The tile now renders the same block outline the deck uses, in edit mode, beside a definition pane for the selected block (type, instructions, loop mode and count, until-condition, state variables, on-failure, scope), and has an **Edit** button that opens the card in the deck editor via the same backlink the launched tile uses. The deck announces a save (plain Save and *Add to deck*) with the card id, and a staged tile showing that card refetches it and re-checks its signature status, so the edit appears in the tile and a card just added to the deck stops offering *Save to deck*. The outline stylesheet is loaded by the tile itself, since previously it only arrived once the deck had been opened. The launched tile is unchanged on purpose: it displays `run.card_snapshot`, so a later edit does not rewrite what a finished run is shown to have executed.
- **Tool results are previewed by shape, not by their first 200 bytes** (`frontend/src/utils/toolDigest.ts` (new: `digestToolResult`, `formatBytes`), `frontend/src/components/ToolDigestView.tsx` (new: `DigestPreview`, `ToolResultViewer`, `chooseBody`), `frontend/src/components/MarkdownRenderer.tsx` (`GenericToolBlock` collapsed and expanded bodies, `DIGEST_EXEMPT_TOOLS`); tests `utils/__tests__/toolDigest.test.ts` (new, 17), `components/__tests__/toolDigestView.test.tsx` (new, 12)). A collapsed tool block showed the first line of the payload, which for pretty-printed JSON was a lone `{`, for compact JSON a run of keys, for a shell transcript the command echoed back, and for a result like `{"message": "...\n..."}` a sentence trapped in escaped JSON. The block now derives a digest from the payload's shape with no per-tool knowledge: a wrapper with `total` and an array becomes "225 issues matched, showing 100 (NETREL-600 → NETREL-699)" with status-histogram and distinct-assignee chips; a single object becomes its name plus a key/value grid of scalar fields; an object whose payload is one string is unwrapped into prose, with the URL as a link button and any failure line in an amber strip; a shell transcript previews its output lines and a non-zero `[Exit code: N]` as a chip, never the command; truncated JSON reports the keys and total it could scan. The header byline reads "102 KB · JSON" instead of a pretty-print line count. Expanded, a collection opens on a Table tab (identifier column first, hidden columns and rows beyond 50 revealed on request, copy as CSV), a record on a collapsible Tree, a message as prose, with Raw always one tab away and the digest headline pinned above. Diffs, memory cards, hierarchical search results and the file/AST/memory tools keep their existing bodies. A payload the MCP server cut off mid-object (Jira search pages near 100 KB) is closed after its last complete element (`salvageTruncatedJson`), so it still digests as "225 issues matched, showing 57" with an *output truncated* chip and a Table, instead of a list of the keys it saw. A message that is a list of entries (memory search's `[id] (layer) …` paragraphs) reads as "N entries" with the first entries previewed, and only a message's opening lines can raise the amber warning, so a memory containing "cannot" no longer looks like a failure. A diff (file write) headlines its file and `+N −M` and previews changed lines rather than its `---`/`+++`/`@@` headers; a file or markdown body headlines its first line with words, without `/**` or `#`.
- **Skills in grouping folders are discovered** (`app/services/skill_discovery.py` (`iter_skill_dirs`, `MAX_SKILL_DEPTH`; all three scanners use it), `app/services/skill_sources.py` (`LocalFolderSource._dirs`, `_dir_for`), `app/utils/skill_resources.py` (`readable_skill_dirs`); test `tests/test_nested_skill_discovery.py` (new, 11); `Docs/Skills.md`). Every scanner looked only at the immediate children of a skills root, so `.agents/skills/frontend/react-patterns/SKILL.md` was skipped with no warning (only name problems log). A subdirectory with no `SKILL.md` of its own is now a grouping folder and is searched further, to three levels below the root; a directory that has a `SKILL.md` is a skill and is never searched inside. The agentskills.io spec constrains only the skill directory (`name` must match the parent directory of `SKILL.md`, which stays the leaf), so this is compliant; the Vercel `skills` CLI added the same bounded recursion behind `--full-depth`. Skill ids are keyed on root and name, not path, so moving a skill into a folder keeps its id. The read-tool allowlist resolves symlinked skills at any depth, and a local or git skill source lists and installs grouped skills by leaf name.
- **Managed job runner: start a long command, wait on it by condition, kill its whole process tree, and record what it produced** (`app/jobs/wrap.py`, `app/jobs/policy.py`, `app/jobs/manager.py` (new), `app/utils/journal_wait.py` (new; the `shadow_wait` condition matcher lifted out of `app/shadow/sock_server.py` (`_op_wait`), which now calls it), `app/mcp/tools/job_tools.py` (new: `job_start` / `job_wait` / `job_status` / `job_read` / `job_kill`), `app/mcp/builtin_tools.py` (`jobs` category), `app/mcp_servers/shell_server.py` (`ShellServer(env=…)`; timeout message points at `job_start`), `app/agents/task_executor.py` and `app/api/task_cards.py` (kill owned jobs at block end / run end, not on hold), `pyproject.toml` (`ziya-jobwrap`); tests `tests/test_job_runner.py` (new, 27), `tests/test_job_runner_task_ownership.py` (new); `Docs/FeatureInventory.md`; design `design/job-runner.md`). The 2026-10-02 network scan needed five attempts to start one background query job: `nohup` and `pkill` are off the allowlist, the write checker (rightly) refuses an opaque `subprocess.Popen` script, the `mkdir` for the output directory was part of a refused compound command so the job failed at its last write, and the only way to wait was `sleep 75; cat log`. None of that is a policy bug; what was missing was a supported way to background. A job is admitted by an in-process `ShellServer` built over the exact env the MCP manager hands the shell subprocess, so the allowlist, interpreters, write policy, YOLO and the verify-or-clamp gate apply identically and the floor cannot be lowered from here; background operators are refused on top; declared outputs must be writable (and never server-only); and a refusal at any step creates nothing. The job runs under a stdlib wrapper in its own session with a credential-stripped env, journals to `.ziya/jobs/<id>/` (a `server_only_paths` entry), survives a server restart (pid + start-time guarded re-attach; a `-9`'d wrapper reads `lost`), is TERM'd then KILL'd as a group at `max_runtime_s` or on `job_kill`, and on exit records a ledger `command` observation with exit code, duration and the SHA-256 of each declared output (`outputs_missing` for ones never written). `job_start(wait_s=…)` returns a short job's result in one call, and a first `job_wait` with no `from_seq` replays the whole job journal rather than starting at the tail (a job's journal is its own, unlike a shadow session's). `keep_awake=true` (`app/jobs/inhibit.py`, new) holds `caffeinate -i -w <wrapper pid>` / `systemd-inhibit` for the job's lifetime — spawned by the server with a fixed argv, never adjudicated as a job command — and reports `held` / `unavailable` / `failed`. `ziya jobs list | tail | kill | gc` (`app/cli.py` (`cmd_jobs`)) reads the job directories directly so it works with no server; `gc` keeps running and ledger-cited jobs and refuses when the ledger is unreadable rather than treating "unreadable" as "uncited". Jobs re-attach at server startup (`app/server.py` lifespan). The ledger observation is `inline` (store-`attested: false`), which the docs state. Journal rotation (50 MB files, 200 MB per job) is in the wrapper. The runner is advertised where a model decides how to run a command, not only in the refusal it gets afterwards: the built-in Task Cards skill's "Long-running commands" section (`app/data/built_in_skills.py`) now steers to `job_start`/`job_wait` first (with `detach="run"`, `keep_awake`, and the refused operators named) and keeps `scope.shell_timeout_secs` as the fallback for a step that must stay synchronous; and the Session Context block every chat and task turn sees (`app/utils/session_context_prompt.py` (`_format_jobs_section`)) carries a two-line version, gated on the `jobs` builtin category being enabled. Tests add `tests/test_job_runner_cli_inhibit.py` (new, 6) and `tests/test_job_runner_guidance.py` (new, 4).
- **Task Card runs now feed memory** (`app/agents/run_memory_harvest.py` (new; `harvest_run_memories`, `build_run_text`, `schedule_harvest`), `app/agents/task_scheduler.py` (scheduled fires), `app/api/task_cards.py` (`/launch` runs), `app/cli_card_runner.py` (`ziya task --card`, `/card`); tests `tests/test_run_memory_harvest.py` (new, 13); `Docs/FeatureInventory.md`, `Docs/EnvironmentVariables.md`). Until now memory extraction only ran on the web chat stream, and it dropped the task results injected into chat because they arrive as `system` messages. Research done in cards never reached memory at all. A run that finishes `done` or `partial` now has its root result, decisions, text outputs and passed stage summaries sent through a run-specific extraction prompt, which treats the report as data, keeps only established facts and drops facts about the run itself. The resulting facts go into the probationary store under stricter rules than chat extraction. Every run of one card is a single corroboration source, so a 60-way fan-out or an hourly schedule cannot promote its own finding; a chat or a different card has to agree. Proposals never fast-track on quality; the grade is kept as `run_quality`. A run never rewrites an active memory, it only bumps the corroboration of one it agrees with. A contradiction of a pending proposal is recorded as `contradicted`. Neither the activity counter nor the domain clocks advance, so frequent automated runs do not age the user's probationary memories. Opt out with the card tag `no-memory` or `ZIYA_MEMORY_HARVEST_RUNS=0`.
- **Memory export: a bounded, previewed, audited slice of memory for another user** (`app/utils/memory_export.py` (new; `ExportSelection`, `export_options`, `plan_export`, `build_bundle`, `scan_memory`, `fingerprint_ids`), `app/api/memory.py` (`GET /api/v1/memory/export/options`, `POST /api/v1/memory/export/preview`, `POST /api/v1/memory/export`), `frontend/src/api/memoryApi.ts` (`exportReadiness`, `runExport`), `frontend/src/components/MemoryExportPanel.tsx` (new), `MemoryBrowser.tsx` (Export tab); tests `tests/test_memory_export.py` (new, 16), `tests/test_api_memory_export.py` (new, 6), `frontend/src/api/__tests__/memoryExportHelpers.test.ts` (new, 8); `Docs/FeatureInventory.md`). The design doc says memory is "never shared without explicit user action"; this is that action. The Export tab selects by mind-map domain (a node takes its subtree), includes or excludes projects (exclusion wins, so a memory scoped to both an included and an excluded project is held back), and filters layers and statuses. A live preview shows the exact count, a breakdown by layer, domain and project, each reason memories were held back, and a leak scan of the content that reuses the shadow redactor's credential shapes plus home-directory paths and email addresses; flagged items must be acknowledged before the button enables. Every exported memory is rebuilt from a field whitelist — conversation ids, retrieval telemetry and access dates are dropped and project paths reduced to folder names unless the user opts in — and relations are pruned to ids inside the bundle. The export request carries the preview's fingerprint of the selected id set; the server re-resolves the selection and refuses with 409 when the store no longer matches and 412 when warnings are unacknowledged, and logs `memory_exported` / `memory_export_refused` to the security audit trail. The bundle header honours anonymization too (the first test run caught raw paths in the per-project counts).
- **Task cards declare the skills, tools and MCP servers they need, and are checked before they run** (`app/models/task_card.py` (`CardRequirements`, `TaskCard.requires`), `app/utils/task_card_preflight.py` (new; `collect_requirements`, `check_requirements`, `run_preflight`), `app/cli_card_runner.py` (`run_card(strict=)`), `app/cli.py` (`ziya task --strict`), `app/api/task_cards.py` (launch preflight, 412 on a strict card), `app/data/built_in_skills.py` (task_cards skill: `requires`); test `tests/test_task_card_preflight.py` (new, 8)). A card run on a host that lacks an MCP server or a skill used to find out when the executor reached the task that wanted it, as one line in that task's artifact decisions; for a batch run that is an hour in, from a confused model. A card can now carry `requires: {skills, mcp_servers, tools, strict}`, and before the first model call every requirement — plus every skill and tool named in any scope of the card — is checked against the live environment (stored skills and `.ziya/skills` discovery, loaded tools, `MCPManager.get_server_status()`). Missing, unconfigured, disconnected and quarantined items are reported with the block that needs them. By default the card still runs (a warning); `requires.strict` on the card, or `ziya task --strict`, refuses — exit 3 from the CLI, 412 from the launch endpoint. Floor tools (`emit_artifact`, `render_diagram`, beads) are never reported missing.
- **A headless run's implicit memory store is removed at exit** (`app/config/environment.py` (`_setup_memory_store`); test `tests/test_memory_reference_mode.py`). `--memory-bundle` without `--memory-dir` created a `ziya-memory-*` temp directory per run and never removed it. It is now registered with `atexit`; pass `--memory-dir` to keep a store across runs.
- **Memory import, and headless runs against a reference memory set** (`app/utils/memory_import.py` (new; `ImportOptions`, `load_bundle`, `plan_import`, `apply_import`, `seed_store`), `app/utils/paths.py` (`get_memory_dir`, `memory_writes_allowed`), `app/api/memory.py` (`POST /api/v1/memory/import/preview`, `POST /api/v1/memory/import`), `app/config/common_args.py` + `app/config/environment.py` (`--memory-dir`, `--memory-bundle`, `--memory-readonly`), writers in `app/storage/memory.py`, `app/storage/proposals.py`, `app/services/embedding_service.py`, `app/utils/memory_clock.py`, `app/memory/feedback.py`, `app/memory/organize_history.py`, `app/memory/extractor.py`; `frontend/src/components/MemoryImportPanel.tsx` (new), `MemoryBrowser.tsx` (Export / Import tab), `memoryApi.ts` (`importReadiness`, `runImport`); tests `tests/test_memory_import.py` (new, 12), `tests/test_api_memory_import.py` (new, 3), `tests/test_memory_reference_mode.py` (new, 3), `frontend/src/api/__tests__/memoryImportHelpers.test.ts` (new, 6); `Docs/FeatureInventory.md`). Two ways to use another user's bundle. Interactively: the Import side of the Memory Browser's transfer tab classifies every memory in the bundle against your store — new, already present (same id and content), duplicate content under another id (skipped, with references redirected to yours), or an id collision resolved by *keep both* (incoming memory renamed and every reference to it rewritten), *keep mine* or *take theirs* — merges the bundle's mind-map nodes into yours (your handle wins, member lists union), and tags each imported memory `imported` and `from:<label>` with an `imported_from` record. The import echoes fingerprints of both the bundle and the store's current state; the server refuses with 409 if either moved and logs `memory_imported`. Headless: `ziya ask … --memory-bundle ref.json --memory-readonly` runs with the bundle as its only memory and writes nothing — the six modules that each computed `~/.ziya/memory` now resolve through `get_memory_dir()`, every writer (store, proposals, embedding cache, activity clock, feedback stats, organize history) is a no-op under `ZIYA_MEMORY_READONLY`, and post-conversation extraction returns before any model call. `--memory-dir DIR` instead keeps the seeded store and what the job learns across runs. Import never embeds (a 2,000-memory bundle would be 2,000 provider calls at startup); the panel offers the existing backfill after a successful import.
- **Claude 5+ prompt treats the context window as unlimited** (`app/extensions/prompt_extensions/claude_extensions.py` (`_CONTEXT_BUDGET_INSTRUCTION`, `_claude_major_version`), `tests/test_claude_context_budget_extension.py` (new)). For Claude models with major version 5 or later, `claude_family_extension` adds a short CONTEXT BUDGET instruction: don't behave as if context is about to run out (estimating what remains, cutting work short, suggesting a new conversation) unless the user says it is a constraint. Being economical with reads on their merits is still allowed. The version comes from the request's model name, so unversioned aliases (`sonnet`, `haiku`) don't get it.
- **The system prompt states the user's language locale, taken from the browser** (`app/utils/user_locale.py` (new), `app/utils/session_context_prompt.py`, `app/middleware/project_context.py`; test `tests/test_user_locale.py` (new, 22)). With no locale to go on, the model often wrote British spellings for US users. Browsers send `Accept-Language` on every request. The middleware now records the preferred tag (highest `q`; a bare `en` becomes `en-US` when the header also lists it), and the Session Context gets `<UserLocale value="en-US" source="browser" />` plus a line asking for that regional variety in spelling, vocabulary and date and number formats. Quoted text, identifiers and existing file content keep their own spelling, and an explicit instruction from the user overrides the default. Without a browser (CLI, headless task runs), the locale comes from `LC_ALL` / `LC_MESSAGES` / `LANG`, ignoring `C` and `POSIX`. The header is client text that ends up in the prompt, so only well-formed tags of at most 35 characters are accepted. The line doesn't change between turns, so it stays in the cached system prefix. There's no settings dialog to override it yet.
- **Output contracts: a task output can be checked against a schema at the moment it is emitted** (`app/utils/output_contracts.py` (new), `app/utils/output_schemas/{number,distribution,per-entity-table,finding,text}_v1.json` (new), `app/utils/task_artifacts.py` (`start_artifact_collection(contracts=, claim_lookup=)`, `emit_part`, `active_contracts`, `collected_parts`); test `tests/test_output_contracts.py` (new, 31); `design/typed-findings.md` Implementation notes 7–17). In a multi-lane investigation card a per-unit reporting rule had to be written into five places and nothing checked that any lane's JSON carried the fields. A contract names an `emit_artifact` part and a schema: shipped (`ziya:finding/v1`, `ziya:distribution/v1`, `ziya:number/v1`, `ziya:per-entity-table/v1`, `ziya:text/v1`), project (`.ziya/schemas/<name>.json`, usually extending a shipped one with `allOf`) or inline (up to 16 KB). `$ref` may name only those registries, so validation never fetches anything, and a card's schemas are pinned with their SHA-256 so an edit mid-run does not change the check. A contracted part that fails is not recorded, and the tool result lists each problem as a JSON pointer with the field's description, e.g. `/impact/per_unit/sd: required (standard deviation per unit)`. Cross-field rules use a fixed set of checks (`ordered`, `range`, `subset`, `reconciles`), not an expression language. Numbers are evidence-ledger claim references or literals; with `require_claims` literals are rejected, an unknown or refuted claim is rejected, and the resolved value, status and version are copied onto the part. Parts whose names no contract declares are recorded as before. A Task block declares `output_contract: [{name, schema, required, max_count, require_claims}]` (`app/models/task_card.py` (`OutputContract`, `GatherContract`, `Block.output_contract` / `gather_contract` / `output_contract_defaults` / `gather_contract_defaults`), `pyproject.toml` (`jsonschema`)). In a run (`app/agents/task_executor.py`, `app/agents/block_executor.py` (`execute_block` pins the run's schemas), `app/utils/permissions_snapshot.py` (records them at launch as `output_schemas`); test `tests/test_output_contract_task_seam.py` (new, 11)): the task's prompt gains a *Required outputs* section generated from the schema; a task that finishes without a required output gets exactly one correction turn naming it, and if it is still missing the task fails with signature `output_contract:<name>`; the schemas checked are the ones pinned when the run was launched, even if `.ziya/schemas/` changes mid-run; and when a State block sets `evidence_namespace`, numbers must be ledger claims. A State block's `output_contract_defaults` apply, merged by name, to every Task after it in the same body (and their subtrees), so a reporting rule is declared once rather than in every lane; a Task's own entry of the same name wins, `"output_contract": []` opts out, the defaults do not leak out of the body that holds the State or cross into a Called card, and each concurrent iteration of a parallel loop has its own (`app/utils/output_contracts.py` (`merge_contract_defaults`), `app/agents/block_executor.py` (`ExecutionContext.contract_defaults`)). Card validation, and so launch (422, no run), now checks contracts before any model call (`app/utils/task_card_validation.py`, `app/utils/output_contracts.py` (`field_path_problem`); test `tests/test_output_contract_validation.py` (new, 12)): every contract schema must resolve, with an unknown shipped schema naming the ones that exist; and a reference like `{{sibling("lane").outputs.finding.impact.per_unitt}}` to a Task whose contract (own or inherited from a State) covers `finding` must name a field the schema has, the error listing the fields that do exist, while a `for_each` source must name a list. Containers enforce `gather_contract: [{name, schema, per: child|iteration|total, min, max, producer}]` (`app/agents/block_executor.py` (`_check_gather`, push-down at dispatch), `app/context.py` (iteration context `path`), `app/utils/task_artifacts.py` (`iteration_path` on parts), `app/utils/output_contracts.py`; tests `tests/test_gather_contract_seam.py` (new, 9), gather cases in `tests/test_output_contract_validation.py`): the producer task of each child or iteration gets the contract as a required output, with its one correction turn, other tasks have parts of that name validated but owe nothing, and when the container finishes any child, iteration (named by roster key for `for_each`) or total short of `min` or over `max` valid parts fails it with signature `gather_contract:<name>` and a `contract_shortfall` event. Parts emitted in a nested loop are credited to the outer iteration through `iteration_path`. A State block's `gather_contract_defaults` apply to matching containers in its scope. Validation refuses a child of several tasks with no `producer`, producers outside the container or of the wrong kind, a `per` that does not suit the container, a task contract that conflicts with the gather schema, and `rerun_missing` where re-running is not allowed; it also type-checks references to a gathered container's outputs. `rerun_missing: 1` on a Parallel or Repeat gather contract re-runs each short child or iteration once, with the same item and `{{previous}}`, prefixing its tasks with why the first attempt fell short; the re-run's parts replace the first attempt's, a `unit_rerun` event names the units, and a unit still short fails the container with `contract_shortfall.rerun_attempted: true` (test `tests/test_gather_rerun_seam.py` (new, 7)). A re-run iteration is recorded as attempt 2 next to attempt 1 (`IterationSummary.attempt`, per-attempt artifact files in `app/storage/task_runs.py`), and resume, replay and run outcome read the highest attempt (`app/utils/iteration_attempts.py` (new)). The `task_cards` skill documents output contracts, so a model authoring a card learns them when it loads the skill. The run inspector draws a contracted `ziya:finding/*` part, or one whose project schema extends it, as a finding card instead of JSON: verdict, impact rows, mechanism, cross-validation and evidence, with each number marked as a ledger claim (status and claim id on hover) or a literal, and the raw JSON one click away (`frontend/src/components/TaskCard/FindingCard.tsx`, `frontend/src/utils/findingView.ts` (both new), `ArtifactViewer.tsx`, parts stamped with `schema_lineage`; test `FindingCard.test.tsx` (new, 7)). Ordinary conversations get the same check through a new `validate_output` tool (`app/mcp/tools/output_tools.py` (new), `app/mcp/builtin_tools.py` (`output_contracts` category, on by default), `app/utils/output_contracts.check_value`; test `tests/test_validate_output_tool.py` (new, 6)): give it a schema and a value and it returns the field-level errors, cross-field checks and resolved ledger claims a card would apply, or with no value the schema's fields; `store_output` does the same check and, when it passes, appends the value to the project's output store, `.ziya/outputs/<family>.jsonl`, one file per schema family (`app/utils/output_store.py` (new); test `tests/test_output_store.py` (new, 6)). Findings emitted under a contract inside a Task Card run land in the same file, so one `analyze` query (`inputs={"f": ".ziya/outputs/finding.jsonl"}`) covers a run's findings and a conversation's, with nested access (`data.impact.fleet_total.mid.value`); re-storing a value with the same `data.id` is a new version. Fixed on the way (`app/utils/dataset_store.py` (`ensure_parquet`)): a JSONL file with nested objects registered as a dataset lost its structure when converted to Parquet (every object became a string, so `data.verdict` would not bind); conversion now goes through DuckDB's `read_json` when a column holds objects, and flat data keeps its former types. Live run 33d141aa then showed a lane emitting `alpha_finding` for a required `finding`, being told "recorded", and staying short through its correction turn and re-run; a part emitted under a name no contract declares now says which required outputs it does not satisfy and that the exact name is what counts, the prompt section says the same, the store's timestamp column is `stored_at` (`at` is a DuckDB reserved word, so `ORDER BY at` failed), and the run-side store hook writes to the card's project (the ledger root) and never to the process cwd, which test collectors opened without a request had been doing (`app/utils/task_artifacts.py`, `app/utils/output_contracts.py`, `app/utils/output_store.py`). Research harness M2 complete.
- **Evidence ledger: where a reported number came from, and citations that export from it** (`app/utils/evidence/{store,verify,refs,service,citations}.py` (new), `app/mcp/tools/ledger_tools.py` (new; `ledger_record`, `ledger_query`, `ledger_verify`), `app/mcp/builtin_tools.py` (`evidence` category), `app/utils/document_ir.py` (`evidence:` front-matter kept), `app/services/pdf_exporter.py`, `app/routes/export_routes.py`, `app/data/built_in_skills.py` (document_authoring rule 6); tests `tests/test_evidence_ledger.py` (23), `tests/test_evidence_service.py` (6), `tests/test_ledger_tool_seam.py` (3), `tests/test_evidence_citations.py` (11); `Docs/DocumentAuthoring.md`, `Docs/Capabilities.md`; `design/evidence-ledger.md` Decisions 5–13). In a long investigation run the numbers were the product, but nothing linked a figure in the report to the query or file that produced it, a stale total survived next to its correction, and a per-incident statistic was computed from the wrong column with no record of which. The ledger keeps observations (a query, file or command and the SHA-256 of its result), claims (a named `namespace.key` value with its population, the column and expression it came from, and a tolerance) and verdicts, as HMAC-signed append-only JSONL under `<project>/.ziya/ledger/` (a per-user key in `~/.ziya/ledger.key`; a hand-edited or unsigned line is skipped). A claim must name a source that exists; recording an id again appends a version that supersedes the old one; status (recorded, verified, drifted, refuted, superseded) is derived from the latest verdict. `ledger_verify` re-hashes a claim's sources (`rehash`) or re-evaluates its expression from the current input claims (`computed`); `rerun` waits for the job runner. Documents declare `evidence: {namespace, policy}` and cite `{{claim:key|fmt=.2%|unit}}`; export substitutes the value, appends an Evidence appendix, records each citation, reports hand-typed numbers in `X-Ziya-Evidence`, and under `policy: strict` refuses (400, listing ids) to publish an unknown, drifted or refuted claim. The write policy already refused tool writes into `.ziya/ledger/`; parallel writers are serialised with `flock` (10 processes × 200 records: no duplicate versions, 878 without the lock). Numbers produced inside shell scripts can be recorded too (`app/utils/evidence/scope.py`, `app/api/ledger.py` (new; `/api/ledger/record|query|verify`), `app/mcp/manager.py`, `app/mcp_servers/shell_server.py`, `app/utils/ziya_ledger_client.py` (new, stdlib-only), `app/cli.py` (`ziya ledger observe|claim|query|verify`); test `tests/test_ledger_shell_seam.py` (8)): every shell command the shell server runs gets `ZIYA_LEDGER_URL` and a signed, expiring `ZIYA_LEDGER_SCOPE` token naming the project and the conversation, run and block it was issued from; the routes accept nothing else and take provenance from the token, so a claim recorded by `ziya ledger claim` inside a Task Card names its block. Inside a Task Card (`app/agents/task_executor.py`, `app/agents/block_executor.py`, `app/context.py` (`evidence_namespace`, `ledger_root`), `app/utils/evidence/service.py`; test `tests/test_ledger_task_provenance.py` (5)): claims carry the run, block and enclosing loop iteration; a State variable `evidence_namespace` is the default namespace for the tools and the shell token; a task whose scope sets `cwd` still records into the project's ledger rather than a stray `<cwd>/.ziya/ledger/`; and each task's artifact gains an "Evidence ledger claims" data part listing the claim versions it recorded. Acceptance (`tests/test_analyze_ledger_acceptance.py` (new, 4)): per-entity figures computed with `analyze` over synthetic CSVs are recorded as attested file and `analyze`-result observations plus claims, the reconciliation table is rebuilt from ledger claims alone, every claim re-hashes unchanged, and a changed raw CSV drifts exactly the claims derived from it. Research harness M1 complete.
- **`ziya-install-extras --data/--stats` explains a failed install behind a private package mirror** (`app/utils/install_extras.py` (`_configured_index`, `_index_failure_hint`, `index_failure_hook`); tests in `tests/test_install_extras_data.py`). When pip is configured to use a private mirror whose login has expired, pip fails with a 401 followed by "no matching distribution", which reads like a missing package. When `pip install` fails, the installer now checks which index pip was set to use (`PIP_INDEX_URL`, otherwise pip's config). If that is not public PyPI, it names the mirror's host and prints the `PIP_INDEX_URL=https://pypi.org/simple/ ziya-install-extras …` retry command. Only the host is printed, because such URLs often contain the token. An edition can set `index_failure_hook` to replace that advice with its own. If the hook reports that it renewed access, the install is retried once. Successful installs are unchanged.
- **`analyze`: read-only SQL over datasets, with primitives for telemetry analysis** (`app/utils/analytics/{__init__,guard,engine,primitives,analyze}.py` (new), `app/mcp/tools/dataset_tools.py` (`AnalyzeTool`), `app/mcp/builtin_tools.py` (`datasets` category), `app/server.py` (dataset routes mounted), `app/utils/dataset_store.py` (Parquet storage, lineage), `app/utils/install_extras.py` (`--data`, `--stats`), `pyproject.toml` (`data`, `stats` extras); tests `tests/test_analytics_{guard,engine,primitives,analyze,no_engine}.py`, `tests/test_analyze_tool_seam.py`, `tests/backend/test_dataset_parquet.py`, `tests/test_install_extras_data.py` (all new); `design/local-analytics.md`). Computation over registered data used to happen in the model's head, a Vega-Lite transform, or a `python3 -c` one-liner that was never kept. `analyze` runs one DuckDB query per call on a fresh connection that can read only the query's inputs: a sqlglot allowlist accepts a single `SELECT`, and DuckDB itself runs with external access off, the input files in `allowed_paths` and its configuration locked, with 120 s, 2 GB and 500,000-row limits. Eight table macros encode rules that are easy to get wrong by hand: `bin_with_coverage` (a silent bin is unknown, not zero), `counter_deltas` (a decrease is a reset), `intervals_union`/`_intersect`/`_subtract`/`interval_duration` (overlaps count once), `runs`, and `entity_distribution`. Every result is stored as a dataset whose `meta.json` records the SQL, each input's id and SHA-256 and the engine version; small results are returned inline, and a result with one entity column carries a per-entity distribution of each numeric column. Datasets are now stored as Parquet when DuckDB is installed; existing JSONL datasets convert on first use and the JSONL is removed only after the Parquet copy reads back with the same rows and columns. Needs `ziya-install-extras --data`; without it `analyze` says so and every other dataset tool works as before. This also wires up the phase-1 `dataset_*` tools and `/api/datasets` routes, which existed but were never registered or mounted. Acceptance: `tests/test_analyze_ledger_acceptance.py` recomputes per-entity distributions and totals from synthetic CSVs with `analyze` and checks that each distribution reconciles to its total. `ziya-install-extras --data` is handled in `install_extras.run()`, the entry point the internal edition's wrapper calls; it was first handled only in `main()`, which that wrapper never calls.
- **Fork children carry a fork marker in the sidebar** (`frontend/src/components/ForkGlyph.tsx` (new), `frontend/src/components/MUIChatHistory.tsx` (`isForkChild`); test `frontend/src/components/__tests__/forkGlyph.test.tsx` (new, 6)). A plain fork (`lineageKind: 'fork'`) is nested under its source the same way a handoff continuation is, but only continuations were marked (⛓), so a fork read as an ordinary conversation one level down. Fork rows now show a small fork glyph before the title. It is an inline SVG drawn in the text colour, so it follows the theme and the selected-row colour. Bead branches (`lineageKind: 'branch'`) are not marked.
- **`render_diagram` reports the rewrites preprocessing applied to the spec** (`app/utils/chart_rewrites.py` (new), `app/services/diagram_renderer.py` (`_classify_console_log` → `rewrites`, diagnostics `console_rewrites`), `app/mcp/tools/diagram_render.py`; `tests/test_chart_rewrites.py` (new, 6); `Docs/Capabilities.md`). The frontend fix-up passes log each change they make as a `🔧 TAG: …` console line, and the headless renderer already captured the console, but only warnings and errors were reported, so after a render the model could only guess why the image differed from its spec (the 2026-10-02 log-scale investigation spent several renders on exactly that). Those lines are now listed in the tool result under "Preprocessing changed the spec before rendering", with execution traces (`*-DEBUG`, `ENHANCER-INPUT`, `VEGA-EMBED`) and progress lines dropped, duplicates removed and the list capped at 25. Rendering is unchanged. Not yet done: listing the rewrites in the chart's own UI for the user. Research harness M0 (`design/harness-small-items.md` item 3).
- **`show_document`: show a file to the user without the model retyping it** (`app/mcp/tools/show_document.py` (new), `app/utils/show_document.py` (new), `app/mcp/builtin_tools.py`, `app/tool_execution.py`, `app/streaming_tool_executor.py`, `app/utils/tool_history_rewrite.py`, `frontend/src/utils/renderToolPart.ts`, `frontend/src/components/MarkdownRenderer.tsx`; tests `tests/test_show_document.py` (new, 15), `frontend/src/utils/__tests__/showDocumentRender.test.tsx` (new); `Docs/Capabilities.md`). Asked to render a report inline, the model's only option was to retype it, which the parroting detector correctly interrupted. The server now reads the file (same path rules as `file_read`), the user sees it as an expanded rendered block captioned with path, line range, size and SHA-256, and the model receives only a summary (path, lines, sha256, headings). The document is taken off the result after signature verification, so it never reaches the model, the sanitizer or the parroting index, and replayed history carries a one-line note instead of the body. `heading` shows one section and `start_line`/`end_line` a range; selections over 512 KB are refused with the heading list; lines of four or more backticks are rewritten to tildes so they cannot close the chat's tool fence. PDFs show name and page count; images are refused. Research harness M0 (`design/harness-small-items.md` item 1).
- **`memory_delete` tool, with consent decided by who created the memory** (`app/mcp/tools/memory_tools.py` (`MemoryDeleteTool`), `app/mcp/builtin_tools.py`, `app/utils/consent_gate.py` (`op_for_call` gains a `consent_op_for(args, conversation_id)` tool hook and a `conversation_id` argument), `app/tool_execution.py`; tests `tests/test_memory_delete_tool.py` (new, 16); `Docs/ArchitectureOverview.md`). The agent could save and propose memories but not remove one, so a memory it had just made redundant stayed until the user found it in the panel. A memory saved with `memory_save` in the current conversation is deleted without a prompt (the agent undoing its own unreviewed action, as `memory_retract_proposal` does for proposals). Any other memory — promoted from the proposal queue, auto-extracted, REM-synthesised, or from another conversation — opens the consent panel with the memory's text. Ownership is not an argument, so the gate's op is now derivable by a tool hook; with no conversation id the op is the least-trusted one, so a broken gate refuses rather than deletes. Deletion is the UI's `MemoryStorage.delete`, which also drops mind-map refs and the embedding.
- **Task rail and task update feed: running tasks come out of the thread** (`frontend/src/components/TaskCard/TaskRail.tsx`, `TaskUpdateFeed.tsx`, `taskSurfacing.ts`, `task-surfacing.css` (all new), `frontend/src/components/Conversation.tsx`; tests `frontend/src/components/TaskCard/__tests__/taskSurfacing.test.ts`, `taskSurfacingWiring.test.ts` (new); `design/task-cards.md` §In-flight state). An inline tile auto-collapses to a one-line receipt and scrolls away with its turn, so in a long conversation a run that finished — or stopped waiting on a decision — was lost among the noise. A sticky **task rail** under the chat header now shows one chip per task of this conversation that is live or waiting on you (attention first; an open Ask on a reconciled-`held` run reads as "waiting on you"); a chip scrolls to the tile and flashes it. A **task update feed** posts a card at the bottom of the thread when a bound run moves into done / partial / failed / cancelled / held / awaiting_input while you are there, with the status hint and **Go to task**; one card per run, withdrawn when the run goes live again, never for the first status seen on load or for a tile already at the bottom. The rail shows standing state and never finished work; the feed shows transitions and never nags about a run that is merely still running. Every tile mount carries a `data-task-binding-id` slot as the scroll target.
- **Explicit `git <sub>` shell grants now warn about what they admit and point to the `git` tool** (`app/utils/git_grant_hazards.py` (new), `app/mcp_servers/shell_server.py` (startup warning in `_build_safe_command_patterns`, replacing the bare-`git` line), `app/cli.py` (`_print_git_grant_warning` on `/shell git <op>`, `/shell git all`, `/shell add`; help text names the risky flags), `app/routes/mcp_routes.py` (`POST /api/mcp/git-grant-hazards`), `frontend/src/components/ShellConfigModal.tsx` (warning before Save); tests `tests/test_git_grant_hazards.py` (new, 68), `frontend/src/components/__tests__/shellGitGrantWarning.test.ts` (new, 3)). design/git-tool.md step 6, revised from "deprecate" to "warn and redirect": the grants are signed and kept. A grant like `git push` becomes `^git\s+push(\s+.*)?$`, so it also admits `--force`, `--delete` and `:<branch>`; `git checkout` admits `checkout -- .`. One catalogue of 35 subcommands feeds every surface, and `TestClaimsAreTrue` checks the headline claims against the pattern the shell server really builds, with a control showing the forms are refused without the grant. Explicit `log`/`diff`/`show`/`blame` grants are flagged because they bypass the new `--output` guard. The read-only tier's own entries are not flagged.
- **`git` tool: the first approval panel no longer waits ~6.5 s** (`app/mcp/tools/git_tool.py` `_model_alias`). The provenance trailer's model name was read via `app.agents.models`, whose import (LangChain included) ran inside the consent preflight in a fresh process. It now reads `ZIYA_MODEL` and uses `ModelManager` only if already loaded.
- **Per-project tool modes: a project can run `git` disabled, gated (approve each call) or autonomous** (`app/utils/project_tool_modes.py` (new), `app/models/project.py` (`ProjectSettings.toolModes`), `app/utils/consent_gate.py` (project mode consulted before `permissions.json`), `app/streaming_tool_executor.py` (per-project add/remove in the tool loader), `frontend/src/types/project.ts`, `frontend/src/components/ProjectManagerModal.tsx` (Git tool control); `tests/test_project_tool_modes.py` (new, 16, incl. a seam through `_load_and_prepare_tools`), `frontend/src/api/__tests__/projectToolModesSave.test.ts` (new, 4)). design/git-tool.md step 5. Stored as `settings.toolModes = {tool: mode}`; unset inherits. `disabled` removes the tool from that project's tool list and refuses any call that still arrives. `gated` asks for every op the tool considers mutating (`status` still runs unprompted) and overrides a global `always`. `autonomous` runs unattended, including from task-card steps, which previously couldn't use `git` at all. Gated and autonomous make `git` available in that project even when the category is off globally. No signature is required (decided 2026-09-25). Documented as accepted residual risk 7 in `Docs/ThreatModel.md`: the agent can reach the local API from the shell, so approval and project mode protect against a model that makes mistakes, not a hostile one. The tool's structural limits hold regardless.
- **`git` builtin tool: the model can stage, commit, stash, push and switch branches, with each mutating call approved by you** (`app/utils/git_ops.py` (new), `app/mcp/tools/git_tool.py` (new), `app/mcp/builtin_tools.py` (category `git`, off by default), `app/config/env_registry.py` (`ZIYA_GIT_PROVENANCE_TRAILER`), `app/utils/consent_gate.py` (`declared_mode`, `consent_op_defaults`, `fails_closed`, preflight receives the execution project root), `app/tool_execution.py` (fail-closed gate, `project_root` passed to the gate); tests `tests/test_git_ops.py` (new, 50, real temporary repos + bare remote), `tests/test_git_tool_seam.py` (new, 4), `tests/test_consent_gate.py` (+6)). design/git-tool.md step 2, the first consumer of the consent runtime, and the replacement for the retracted shell `WRITE_GIT_OPERATIONS` regex tier. Structured arguments only: `--force`, `--amend`, `--no-verify` and `-c` overrides can't be expressed, and reset / clean / restore / rm / mv / rebase / merge / cherry-pick / ref deletion / force push aren't offered at any level. They remain behind the shell's signed bare-`git` grant. `add` expands the paths and checks each file against the same write gate `file_write` uses. Permitted files are staged and the rest are returned as `refused`; a still-conflicted file is refused. `commit` requires a message, commits the index (or, with `paths`, exactly those tracked files, all-or-nothing against the policy), reports `partially_committed` files, always runs hooks, and appends `Co-authored-by: Ziya` / `Ziya-Model:` trailers. `push` pushes only the current branch with a non-`+` refspec and warns about uncommitted work. `switch` refuses a dirty tree. Git runs with `shell=False`, literal pathspecs, prompts, editors and pagers disabled, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_CONFIG_*` stripped. Every op except `status` is `ask`. The approval panel shows the op's real plan, computed against the conversation's project root (previously a tool preflight would have resolved against the server's cwd), and the plan is recomputed at execution. Two gate changes apply to every tool: a permissions read error now falls back to the tool's declared mode instead of `off`, and a gate failure for an op the tool declares `ask` now refuses the call instead of dispatching it unattended. Enable with `ZIYA_ENABLE_GIT=true` or the builtin-tools toggle. This needs no signature, because no mutating call runs without an approval. Known gaps: a task-card step calling `git` is refused (no conversation to ask in), and "Always" is still a session grant.
- **Files a conversation writes now join its context, or are tracked when too large** (`app/utils/auto_context.py` (new), `app/mcp/tools/fileio.py`, `app/mcp/tools/context_management.py`, `app/routes/diff_routes.py`, `app/server.py`, `frontend/src/apis/chatApi.ts`, `frontend/src/components/MarkdownRenderer.tsx`, `tests/test_auto_context_writes.py` (new, 18), `design/auto-context-tiers.md` phase 1). Before this, neither the apply button (its request carried no `conversation_id`) nor the `file_write` tool told the conversation anything, so the files a conversation changed got no change markers. On 2026-09-24 that led the model to report an already-fixed `pyproject.toml` as broken. Now a successful write of a project file under the project's `auto_add_token_limit` (default 12,500 tokens) adds it to the chat's `additionalFiles` with a new owner, `auto` (`_autoAddedFiles`), so the existing model-pinned merge puts it in the next prompt. Auto-added files live only on that conversation's chat record and are deliberately NOT pushed into the file tree's selection: that selection is per browser tab, not per conversation, so syncing into it would send the file with every conversation opened in the tab. A larger file is not added: `_autoTrackedFiles` records its pre-write and post-write hashes and size, and each turn the model gets one line per tracked file (when this conversation last wrote it, and whether it changed on disk since), never the content. That notice goes on the final user message with the handoff turn part, not in the cached system prompt. Files the user pinned or the model added keep their owner. `.ziya/` and paths outside the project are ignored. No chat record is created as a side effect. `context_remove_file` can now remove `auto` files, and `context_list_files` reports the `auto` owner and the tracked list. Recording is best-effort and never turns a successful write into a failure. Not yet: decay (an `auto` file stays until removed), the file-index "tracked" badge, and the CLI diff applicator path.
- **Server instance identity, and no more phantom projects from a stale `~/.ziya`** (`app/utils/instance_id.py` (new), `app/utils/bind_check.py` (new), `app/middleware/instance_header.py` (new), `app/models/project.py`, `app/api/projects.py`, `app/server.py`, `app/main.py`, `tests/test_instance_identity.py` (new, 18)). Everything the browser persists — IndexedDB, localStorage, BroadcastChannel names, Web Locks — is keyed by origin (`localhost:PORT`), and an origin says nothing about which server is behind it. Two incidents: a port that had served a local Ziya was later reached through `ssh -L` to another host, and `lsof` showed *both* still listening — the tunnel had bound `[::1]:8888` after its `127.0.0.1` bind failed against the local server, with only a one-line ssh warning — so `localhost:8888` was two backends selected per connection, and a stale tab's `knownServerConversationIds`, dual-write pushes and shared `ziya-project-<id>` broadcast channel operated across both; and a user returning after a year was dropped into a project whose directory no longer existed, because nothing on the server ever checked `project.path` before handing it out, so every `X-Project-Root` the tab then sent was silently rejected while the UI claimed to be in that project. This is the server half of the fix. Each installation gets a stable id in `~/.ziya/instance_id` (created on first boot; it lives with the data, not the package, so an upgrade or a reinstall that keeps `~/.ziya` is a non-event and a wiped `~/.ziya` correctly reads as a different installation); every HTTP response carries it as `X-Ziya-Instance` via a raw-ASGI middleware that touches only `http.response.start` (SSE bodies pass through byte-identical), and `/api/v1/projects/startup` reports it as `instanceId`. `/last-accessed` now skips records whose directory is gone and returns 404 rather than auto-creating when none remain; `/{id}` refuses a project whose directory is gone (the list endpoint is unchanged so the record stays visible to repoint or delete). At startup the server probes the other loopback family and prints a warning when `127.0.0.1:PORT` and `::1:PORT` are about to be served by different processes, naming `ssh -L 127.0.0.1:PORT:...` as the fix. The client half — namespacing all origin-scoped storage by instance id with no adoption path for an unowned cache, and a per-response latch that halts a tab whose backend changes underneath it — is the entry below.
- **Browser storage is scoped to the server instance; a tab whose backend changes underneath it stops writing** (`frontend/src/utils/instanceIdentity.ts` (new), `frontend/src/index.tsx`, `frontend/src/context/ProjectContext.tsx`, `frontend/src/context/ChatContext.tsx`, `frontend/src/utils/db.ts`, `frontend/src/utils/projectSync.ts`, `frontend/src/utils/retentionPurge.ts`, `frontend/src/types/project.ts`, `frontend/src/utils/__tests__/instanceIdentity.test.ts` (new, 14), `projectSyncInstanceScope.test.ts` (new, 5), `noUnscopedOriginNames.test.ts` (new static guard, 4)). Every origin-shared name the client uses — the IndexedDB database and its promotion key, `ZIYA_LAST_PROJECT_ID`, the per-project lens keys, the `ziya-project-<id>` BroadcastChannel, and the three `ziya-db-*` Web Locks — now carries the connected server's `instanceId` as a suffix. Two tabs on the same port talking to different installations therefore share nothing: not a database, not a broadcast (the most plausible mechanism for the 7123 tab's conversations vanishing live and returning on refresh was a `conversation-deleted` crossing the shared `ziya-project-<id>` channel from the half-bound 8888 tab), and not a lock (previously a hung foreign tab could stall this one into the 30s "another Ziya tab is blocking" path). There is deliberately **no adoption path** for an unowned pre-upgrade `ZiyaDB`: a stale cache from a dead server and one's own pre-upgrade cache are indistinguishable, and that ambiguity is exactly the door both incidents walked through — so the first load after upgrade is a re-pull from the server per browser per instance, and the old database is left untouched for a later explicit-review surface. A `window.fetch` wrapper installed before any module fetches latches the first `X-Ziya-Instance` seen and, on any response carrying a different one, halts the tab: non-GET `/api` requests are refused, the periodic sync loop bails before reading a possibly-mixed list, and one persistent error names both ids and the likely cause (a port conflict such as an `ssh -L` tunnel). Reads still flow so the UI can render. A server that reports no id keeps every name unsuffixed — byte-identical to pre-feature behaviour — and `null` never overrides a real id. `db.init()` now resolves its names asynchronously (awaiting the id) while still assigning `initPromise` synchronously so concurrent callers share one init; `projectSync.join()` likewise waits, with an epoch guard so a project switch during the wait opens nothing for the old project. `ProjectContext` shows the first-run picker whenever startup context exists and no usable project was found (the server now refuses projects whose directory is gone, so "projects exist but none is usable" is a real state that previously fell to `/projects/current` → 400 → no project and no picker). The static guard fails on any literal lock/channel/DB name outside `instanceIdentity`, and was confirmed red against the unpatched tree with exactly the 13 sites this change rewrote.
- **Closing a tab inside the 2-second dual-write debounce no longer strands the last writes in IndexedDB** (`frontend/src/utils/pagehideFlush.ts` (new), `frontend/src/context/ChatContext.tsx`, `frontend/src/utils/__tests__/pagehideFlush.test.ts` (new, 10)). ChatContext writes to IndexedDB immediately but pushes to the server on a 2s debounce; `beforeunload` only warned about an in-flight stream and flushed nothing, and the code's "will retry next sync" assumed a tab that no longer existed. With browser storage now scoped per server instance and never adopted, "it reached the server" is what makes a cache droppable, so this was the most common remaining way a conversation could end up cache-only. A `pagehide` listener now cancels the timer and sends the pending set with `fetch(..., { keepalive: true })`. Scope is deliberately narrow: the debounce filter (active, not a shell, not an empty "New Conversation") is extracted to `selectDirtyConversations` and both existing timers call it, so the flush cannot push anything the timer would not have pushed two seconds later, and the server's version/regression guards apply unchanged. It refuses when the instance latch has halted the tab, and when the body exceeds the browser's 64 KiB keepalive cap it logs and leaves the ids pending rather than claiming success. The anchor test is a stale record in the pending set producing zero requests; a seam test asserts the inline filter copy is gone from ChatContext so a partial apply fails loudly.
- **Shadow line control: "approve all" at the confirm banner, and plain-English grant/confirm prompts** (`app/shadow/lease.py`, `app/shadow/sock_server.py`, `app/shadow/pty_host.py`, `tests/test_shadow_approve_all.py` (new, 3)). Under a `gated` or `supervised` lease every not-allowed command produced a y/n banner with no way to say "yes to all of them", and the banners read as protocol (`requests line control · gated · policy builtin · [g] grant · [n] deny`) rather than telling the human what they were deciding. The confirm banner now offers `[y] run it · [a] run it and stop asking for this chat · [n] refuse · [x] refuse and revoke control`; `a` sets a lease-scoped `auto_approve` flag (journaled as `control_auto_approve`; each command it waves through as `control_auto_approved`), which ends with the lease and shows as "active, approve-all" in the `C-x C-z` menu where `[r]` still revokes. The grant banner explains the request in words (`describe_restriction`: "read-only commands run freely; anything that writes or is off the allowlist asks you first") with `[g] allow it · [n] refuse`. Authority is unchanged: both are keystrokes at the physical terminal, never socket ops.
### Fixed
- **Until-condition judge now reads emitted text parts, and treats "is committed / is listed" as a state, not an action** (`app/agents/until_evaluator.py`, `app/utils/self_improve.py` (`render_text_parts_for_judge`, `JUDGE_TEXT_CHARS_CAP`), `tests/test_until_evaluator_evidence.py` (+5)). The judge's prompt showed data parts' contents but only *counted* text parts ("1 text"), while its rules of evidence said a condition naming a list or report is true only if DATA or OUTPUTS shows it. A body whose contracted deliverable is a `ziya:text` part therefore could never pass a condition about that list: sweep run cli-c72ef56d7591's P5 loop was refused three times for "DATA is empty" with the 46-path residue accounting sitting in the `residue` text part, and once for "no commits were made this iteration" against a condition that says "is now committed". Text parts are now rendered (labelled, capped at 6 KB) in a TEXT PARTS section, the evidence rule admits them, and the prompt says a state-shaped condition is judged on the end state, so a no-op pass satisfies it when the state already holds.
- **The release sweep no longer leaves hundreds of files uncommitted after a file is edited between runs.** Two leaks combined. First, `release_hunks.py reconcile` counted a contended file as attributed when its hunk count was unchanged and its saved patches still passed `git apply --cached --check`. An edit made after the split inside or beside an existing hunk passes both checks, since `--cached` checks against HEAD, not the working tree. So the file was skipped by re-attribution, its patches covered only part of its diff, and the commit plan then dropped every group depending on it. One run lost 31 groups and about 300 paths this way from 4 such files. Reconcile now also requires each patch to hold exactly the current hunks attributed to its entry. Second, `release_plan.py batches` counted an `exclude` verdict on a tracked modification as triaged, although the commit plan rejects that verdict, so those paths were never triaged again. They are now re-batched and listed under `rejected_excludes`, and the sweep card's triage prompt states that only untracked files can be excluded. (`scripts/release_hunks.py`, `scripts/release_plan.py`; tests `tests/test_release_hunks_reconcile.py`, `tests/test_release_plan_batches.py`, `tests/test_release_tooling.py`)
- **Card-level permissions set in the deck now save.** Skills (or any other field) added under a card's **Card Permissions** showed as "n skills" on the card, then disappeared after switching to another card and back, even after pressing Save in the dialog and on the card. The editor put the new scope on the in-memory draft, but the deck's three save calls (Save, Add to deck, and the save before launching to a chat) sent name, description, root and tags and left out `scope`. Updates are partial, so the server kept the old scope, and reopening the card loaded it back. All three now send `scope`. The save from the chat's task-card button already did. (`frontend/src/components/TaskCard/TaskCardsLibrary.tsx`; test `frontend/src/components/TaskCard/__tests__/deckSavesCardScope.test.ts`)
- **A response that cites a diff it never emitted is now sent back for the diff before the turn ends.** The pattern: a turn writes its tests and changelog through direct-write tool calls, the one change that has to be a *text* `diff` fence (a non-writable path) is decided in reasoning and never written, and the prose — even the changelog entry — says "(diff above)". It recurred in eight conversations since 2026-09-19 and was caught every time by the user asking "did you apply it?". The harness already catches the two neighbours of this failure (a narrated tool call that was never made; a diff that does not apply) but nothing covered a narrated diff. The post-stream validation hook now also checks for a diff *reference* ("the diff above/below", "apply the diff", "the diff I provided") in prose outside fences and reasoning when the response carries no diff fence at all, and uses the existing feedback-and-restart pass to ask for the fence — or for one sentence saying the change was a direct write, so a false positive costs a sentence. The restart prompt is worded for this case rather than claiming a diff "failed to apply". The system prompt's pre-diff checks keep their "silent" wording but now say the diff itself is never silent, give the "do not generate the diff yet" rule a resumption (read the file, then generate it in this response), and exempt a merely-described diff from the "do not re-emit" rule. (`app/utils/phantom_diff.py` (new), `app/utils/diff_validation_hook.py`, `app/server.py`, `app/agents/prompts.py`; tests `tests/test_phantom_diff_reference.py` (new, 23), including the stored turn that motivated it)
- **A headless task run no longer freezes on a tool that is waiting for a human to approve it.** `ziya task sweep` sat four hours after printing `⚙ mcp_run_shell_command`: the model had reached for the `[DIRECT] git` builtin to commit, that tool declares `consent_default = "ask"`, and the consent gate parked the call in the ledger (24 h TTL) for an answer the terminal has no way to give — the CLI event sink has no branch for `consent_opened`, so nothing was printed. Every prior CLI sweep run had the same open `git add`/`git commit` request. Rather than teach the gate to refuse, a run with nobody on it — the CLI card runner (`ExecutionContext.headless`) or a scheduler fire persisted as `launch_context="headless"` — now withholds every `ask`-mode builtin from the tool payload, so the model never sees a `git` tool and uses the shell `git` the task instructions prescribe. Applied where the payload is built (`tool_denylist` into `StreamingToolExecutor._load_and_prepare_tools`, before the scope allowlist, so a scope naming `git` cannot restore it) and recorded in the task's `decisions`. A project that set the tool `autonomous` keeps it. GUI task tiles, which have an approval panel, are unchanged. (`app/utils/task_tool_floor.py` (`unattended_denied_tools`), `app/agents/task_executor.py`, `app/agents/block_executor.py`, `app/cli_card_runner.py`, `app/streaming_tool_executor.py`; tests `tests/test_task_executor_headless_tool_denial.py` (new, 8))
- **The model no longer sees dispatch-routing keys in its own prior tool calls, so it stops inventing them.** `execute_single_tool` adds `_workspace_path`, `conversation_id` and `_task_scope` to a call's arguments for dispatch; it did so in place on the dict the streaming executor later serialises as the assistant's `tool_use.input`. From the second turn on the model was shown parameters it never wrote and imitated them, and weaker models mangled them turn by turn — the release sweep's triage tasks sent `conversion_id`, `project_root`, `workspace_path_alt2`, `task_scope_3` on every shell call, each an `Unknown parameter` warning and, for a schema-validated tool, a rejected call. The args are now copied before any routing key is added; a human's approval-panel edit (a rewritten commit message) is applied before the copy, so the recorded call still shows what actually ran. (`app/tool_execution.py`; tests `tests/test_tool_args_not_leaked_to_model.py` (new, 2))
- **A task run that is waiting for a human now says so on the terminal, and each finished task stream releases its feedback monitor.** Two findings from the same lldb dump of the frozen sweep. (1) The consent gate's `consent_opened` chunk had no branch in `task_executor`'s chunk loop, so it reached no sink: a CLI run blocked on an approval printed nothing. It is now forwarded as a `task_hold` event (tool, op, request id, plus a `waiting for approval: git commit` progress note), and the CLI sink prints `⏸ waiting for approval: git commit` with the ledger location, and `⏵ … : approve|reject` when answered; `ask_opened`/`ask_answered` from an Ask block render the same way. (2) 46 `_feedback_monitor` tasks were alive, one per completed task iteration: the monitor's cancel sites all sat on explicit return paths after the iteration loop, and a consumer that breaks on `stream_end` (the CLI, `task_executor`) never reaches them. The cancel moved to `stream_with_tools`'s `finally`, fed by a holder the impl fills, and `task_executor` now closes the generators it breaks out of (`contextlib.aclosing`) so that `finally` runs on the way out rather than whenever the asyncgen GC hook gets to it. (`app/agents/task_executor.py`, `app/cli_card_runner.py`, `app/streaming_tool_executor.py`; tests `tests/test_task_hold_event.py` (new, 2), `tests/test_feedback_monitor_lifetime.py` (new, 4)). Closing the stream promptly exposed a third: the Bedrock parser stays suspended after `message_stop` with the HTTP body still open past the last event, so a prompt close finalized urllib3's generator un-drained and printed `Exception ignored in: <generator object HTTPResponse.stream>` / `ValueError: I/O operation on closed file` to stderr once per model call. `_parse_stream` is now a wrapper that closes the body before the parser's frame unwinds (the ordering the CancelledError branch already used), and `stream_response` closes the parser in the consumer's own task; the body is also released on a normally exhausted stream, which it never was. (`app/providers/bedrock.py`; tests `tests/test_bedrock_stream_prompt_close.py` (new, 2))
- **A `VAR=$(…)` whose output has more than one line (or spaces, quotes or `>`) no longer runs the second line as a command.** `LB=$(aws elbv2 describe-load-balancers … --output text); aws … --load-balancer-arn "$LB"` with two matching load balancers failed the whole tool call with `[Errno 2] No such file or directory: 'arn:aws:…'`. The executor splices a substitution's stdout back into the segment text and re-tokenizes it, so `LB=arn1\narn2` was peeled as `LB=arn1` and `arn2` became argv[0]. Output is now re-quoted when the substitution sits in an assignment value (`LB=$(…)`, `A=1 B=pre$(…) cmd`) or inside double quotes (`"$(…)"`), matching bash, where neither position word-splits; an unquoted argument position still splits as before. Separately, a missing executable now reports `name: command not found` with exit 127 (126 for a permission error) and the pipeline continues, instead of the `FileNotFoundError` escaping as an opaque tool error. (`app/mcp_servers/shell_server.py` (`_subst_context`, `_quote_subst_output`, `_resolve_substitutions`, `_execute_pipeline`); tests `tests/test_shell_substitution_output_quoting.py` (new, 14))
- **`task_card_write` edits can now set any scope field, so a model no longer has a reason to rewrite the deck file by hand.** `edits` accepted only `scope.cwd`; every other `scope.*` key, including `tools`, `skills`, `model_tier`, `service_tier` and `shell_timeout_secs`, which the signing hash does not even cover, was refused with "ask the user". A model that needed to change a block's scope read that as "the tool can't", and reached for a `python3` one-liner against the deck JSON. That gains nothing (grants are authorized by a root-signed approval matching the block's current scope, not by the card file), but the refusal was teaching the workaround. `scope.<field>` now works for every `TaskScope` field, validated through the model; the merged tree is then checked by each leaf task's *escalation block* (own scope plus ancestors, the same union the runtime gate hashes) rather than full-scope equality. A grant may stay the same or narrow but never widen: a new `shell_command` or out-of-floor writable path is refused with a message that states the mechanism and the sanctioned path (`ziya-approve` via the editor's escalation panel) and says outright that editing the deck file elsewhere gains nothing. A narrowing edit saves and reports `resign_required` with the block ids whose approval no longer matches. The whole-`root` path keeps the existing exact-scope guard, with its remedy text now pointing at `edits`. The tool-side half (calling `escalation_changes` on the edited tree instead of the whole-root guard, reporting `resign_required`, and correcting the tool description that still said scope fields other than `cwd` were not editable) landed later. Until it did, a model told "scope.cwd only" concluded that adding `document_authoring` to two stages' `scope.skills` meant reserializing a ~114 KB card it could not read in full. (`app/utils/task_card_edits.py`, `app/utils/task_card_write_guard.py`, `app/mcp/tools/task_card_tools.py`; tests `tests/test_task_card_edits.py`)
- **`task_card_read` can return one block or an outline instead of the whole card.** `block_id` returns just that block's subtree. `outline=true` returns the skeleton: each block's id, type, name, non-empty scope fields and a 120-character preview of its instructions. On a card too large to read in one response, a model can now find the id it needs and read or edit that block alone (`app/mcp/tools/task_card_tools.py`; tests `tests/test_task_card_edits.py`).
- **`ziya shadow --allow-control` is the grant; the per-lease `[g]` banner is gone.** Running with `--allow-control` (or setting the ceiling from the `C-x C-z` menu) already meant "chats may drive this terminal", yet a chat's `shadow_control` then put up a second prompt — *chat X wants to type commands… [g] allow / [n] refuse* — and under `gated` the first off-list command asked a third time. The second prompt also only accepted `g`: `y` counted as "any other key" and *refused*, and a chat that remembered a session id kept requesting in a window the user had left, so `shadow_send` sat on `lease_pending` and the model waited on a journal that could never move. The lease is now active on acquire for every session with a ceiling (`control_disabled` for none, as before). The terminal announces it instead of asking — *chat 41c2 now has control of this terminal (gated: read-only commands run freely; anything that writes or is off the allowlist asks you first; allowlist: builtin) — C-x C-z, r to revoke* — and deepens the tint, so delegation is visible without capturing the keyboard. The per-command confirm (`y` / `a` stop asking / `n` / `x` revoke) is unchanged: it is what `gated` means, and `--allow-control`'s help now says so, with `supervised` and `unrestricted` as the alternatives. `pending_grant` is dropped from the wire reply and the tool's note. The earlier `[Unreleased]` entry describing tty-naming, sibling-window announcements and a `g`/`y`/`a` grant banner shipped only its tests and design text; those tests are replaced by this behaviour's. (`app/shadow/sock_server.py`, `app/shadow/pty_host.py`, `app/cli.py`, `app/mcp/tools/shadow_tools.py`, `Docs/design/shadow-sessions.md` §6.1; tests `tests/test_shadow_grant_confirm.py` (rewritten), `tests/test_shadow_control_tools.py`, `tests/test_shadow_approve_all.py`, `tests/test_shadow_tint.py`, `tests/test_shadow_security_control.py`, `tests/test_shadow_control_audit.py`)
- **Stray blank lines no longer appear mid-word after a diagram block** (`app/utils/streaming_optimizer.py`, `tests/test_whitespace_displaced_across_viz_block.py` (new)). A whitespace-only streaming delta (the `\n\n` before a fence) was left in the content optimizer's buffer because `flush_remaining()` only returned a buffer with visible text. The viz path drains the optimizer with that call before emitting a `mermaid`/`vega-lite`/… block from its own buffer, so the blank line vanished from before the fence, sat there while the block streamed past, and was prepended to the next word — `lives:```mermaid … Guard\n\nian`. The same drain runs at a tool_use start. `flush_remaining()` now returns whatever the buffer holds; every caller already guards on a non-empty result.
- **A colon in a gantt task name no longer breaks the whole chart** (`frontend/src/plugins/d3/mermaidGanttTaskColons.ts` (new), `frontend/src/plugins/d3/mermaidEnhancer.ts`, `frontend/src/plugins/d3/__tests__/mermaidGanttTaskColons.test.ts` (new)). Mermaid ends a task name at its first colon, so `In service (contacts from 04:29) :10-10 02:16, 10-11 07:00` failed with "Invalid date:29) :10-10 02:16". Colons before the real separator (the first colon followed by valid task data) are now rewritten to `#58;`, which renders as `:`. Lines that already parse are left unchanged.
- **The model no longer tells you the context is filling up, or winds its work down because of it.** Two prompt injections produced that behaviour. The handoff feature's prelude nudged the model from 70% of the context limit, on every turn until a draft existed, with "consider starting a handoff draft … a suggestion to raise with the user"; and the streaming executor staged a "Context Size Notice" at 85/90/95% ("consider wrapping up large reads", "stop reading new content and summarize now") that it injected into the conversation. Together they made every long conversation end with the model announcing context pressure and refusing ordinary reads in exactly the last stretch of the window where the work gets done. The Context Size Notice is removed. The handoff trigger is kept but made late and silent: at 90% of the limit (`ZIYA_HANDOFF_NUDGE_RATIO`, was 0.7) the model is told to write the draft that turn and explicitly not to mention the draft, context size or wrapping up, and to carry on exactly as it otherwise would; the same instruction is in the living-draft prelude once a draft exists. The pressure figure still feeds the drawer's "context N%", but `handoff_read` no longer hands it to the model. (`app/utils/handoff_prelude.py`, `app/mcp/tools/handoff_tools.py`, `app/streaming_tool_executor.py`; tests `tests/test_handoff_draft_prelude.py`, `tests/test_handoff_tools.py`, `tests/test_context_size_notice_tiers.py`)
- **Vega-Lite charts that format a string date with `timeFormat` no longer collapse to "NaN".** A `calculate` such as `timeFormat(datum.t,'%m-%d')` over an ISO-string field produced `NaN-NaN` for every row, because Vega-Lite only parses a field into a date when it is encoded `temporal`; a heatmap's 30 columns merged into one. The preprocessor now wraps the argument of `timeFormat`/`utcFormat` in `toDate()` inside expression strings (`calculate`, `filter`, `labelExpr`, `test`, `expr`, nested views included), normalises `+datum.t`, `toNumber(datum.t)` and `datetime(datum.t)` there, leaves epoch-number fields alone, and switches to `utcFormat` when every value of the field is a date-only string (which `timeFormat` would show as the previous day west of UTC) or states UTC explicitly with a trailing `Z` or zero offset. Offset-less, mixed and non-zero-offset values keep `timeFormat`. (`frontend/src/plugins/d3/vegaDateExpr.ts`, `frontend/src/plugins/d3/vegaLitePlugin.ts`, `frontend/src/plugins/d3/__tests__/vegaDateExprRepair.test.ts`)
- **A wide top/bottom legend in a narrow hconcat panel no longer pushes the panels apart.** Vega sizes each concat column by its cell's full bounds, legend included, so a 14px colour stripe with a three-column bottom legend got a 525px column, and the panels beside it started 500px late. The renderer now narrows the legend's column count to fit the cell. If even one column won't fit, it lays the row out with `bounds: "flush"` and fits the legend into the space before the next panel's own legend. Flush is skipped if a neighbouring panel has a side axis or side legend that would overlap, and an authored `bounds` is never changed (`frontend/src/plugins/d3/vegaLegendPlacement.ts`, `fitNarrowConcatCellLegends`).
- **Sweep triage stages no longer overflow Haiku's 200k context.** The two P3 triage tasks in `design/sweep-orchestrated.task-card.json` run at `model_tier: small` (haiku-4.5 on Bedrock, 200k). With no tools allowlist, every task started at about 126k tokens of tool definitions, and one batch failed with `prompt is too long: 200825 tokens > 200000` (run cli-7b1daa542f37). P3a is now limited to `run_shell_command` and `file_read`, and P3b to those two plus `file_write`. The always-available floor (`emit_artifact` and similar) is still added on top. `tests/test_sweep_orchestrated_card.py` checks that every small-tier task has an allowlist and that the allowlist keeps the tools each stage's instructions use, using the real filter.
- **Token-calibration saves no longer stall the event loop.** `TokenCalibrator.record_actual_usage` and baseline establishment saved `~/.ziya/token_calibration.json` inline on every early sample. Each save took a cross-process file lock (up to 10 s) and rewrote the whole file, from `StreamingToolExecutor._handle_usage_event` on the server's event loop. They now call `request_save()`, which coalesces requests onto one daemon writer thread and flushes at exit. `_save_calibration_data()` stays synchronous for explicit callers. (`app/utils/token_calibrator.py`, `app/streaming_tool_executor.py`, `tests/test_calibration_save_off_loop.py`)
- **A model stream that closes without a terminal event is re-issued instead of ending the turn** (`app/streaming_tool_executor.py` (`_decide_no_tool_outcome`: new `dropped_stream_retry` branch); tests `tests/test_decide_no_tool_outcome.py` (`TestDroppedStream`, 8), `tests/test_stream_loop_recovery_seam.py` (`TestDroppedStreamRetry`, 3)). `last_stop_reason` is reset each iteration and set only from a `StreamEnd`, and every provider's `StreamEnd` carries a concrete reason, so `None` means the transport closed with no terminal event. It used to be treated as a clean `end_turn`. After tool results that cost the single empty-after-tools nudge, or ended the turn once the nudge was spent: iteration 48 of a 48-round Bedrock run returned one metrics chunk then EOF, and the turn ended with no answer. A drop with no text is now re-sent unchanged, sharing the two-attempt empty-completion budget, so a provider that drops every request falls through to the normal ladder. A drop after text has streamed is not re-sent, since that text is already on screen. The tests were committed on 2026-09-11 without this executor change, so they failed until now.
- **An interrupted release sweep re-attributes only the files it did not finish** (`scripts/release_hunks.py` (`reconcile` mode), `design/sweep-orchestrated.task-card.json` (P2a), `design/sweep-orchestrated.md`; tests `tests/test_release_hunks_reconcile.py` (new, 7), `tests/test_sweep_orchestrated_card.py` (+1)). A sweep that died during hunk attribution had to redo every contended file on rerun, and the commit plan read the whole patch directory, so patches from an earlier run (renamed entries, hunks already committed) could reach the commit loop. `release_hunks.py reconcile` counts a file as done only when its merge record names current entries, or its attribution maps every current hunk to a current entry and every patch passes `git apply --cached --check`. P2a fans out just the pending files and moves every other artifact under `.ziya/sweep/stale`. On the run that prompted this, 68 of 130 files were done and 62 pending: 13 of those had attributions from an earlier run whose patches no longer applied. The next run (cli-81921cb9b5eb) exposed four more resume faults, now fixed (`scripts/release_plan.py` (`batches` mode), `scripts/release_hunks.py` (whole-file and `new:` attributions); tests `tests/test_release_plan_batches.py` (new, 4), `tests/test_release_hunks_reconcile.py` (+3), `tests/test_sweep_orchestrated_card.py` (+4)). Triage batches were built by the model, which dropped 25 paths and put 8 in two batches, and verdict files were named by position, so one iteration overwrote another's; `release_plan.py batches` now batches every untriaged path exactly once and names each file by a hash of its paths, so a rerun triages only what is missing. The commit-plan gate refused but P4a reported done, so P4b ran an empty roster and P5 committed 13 entries outside the plan; P4a now has an output contract requiring a ready plan with at least one group, and P5 refuses to commit without one. A new untracked file mapped to one entry no longer fails `split` (it ships whole), and a hunk no entry describes is mapped to `new:<slug>` with a drafted entry instead of an invented slug that `reconcile` rejected on every rerun. A third run stopped at the commit plan over one entry with no code while ~100 entries were committable; an item that cannot be resolved (missing code, pending attribution, no verdict, a failing patch, or an exclusion of a tracked or imported file) is now deferred, the groups depending on it are dropped, the rest commits, and the fan-outs and commit loop are `required: false` so one bad iteration no longer halts the run. P6 refuses to finalize the release while anything is deferred. A fourth run (cli-462ad587602c) committed almost nothing for a different reason: 16 paths were staged in the index before it started, and every commit iteration refused a dirty index, so all 160 of them failed; the residue stage then landed only whole-file groups because it had no `git apply` grant. The staged hunks were also invisible to `git diff`, so two contended files were recorded as unsplittable merges. P0 now unstages a leftover index at the start of a run and re-queues those files for attribution, the commit loop clears a dirty index instead of failing and leaves the index empty on every exit, and both it and the residue stage carry `git apply` plus an anchored index-only grant, `re:^git restore --staged -- \S.*$` (the one `restore` form admitted: it never touches the working tree). Stages are also told to query the ledger with a script rather than `file_read` it whole, which was costing ~160K tokens per request. A fifth run (cli-6815dceb40c7) then stopped in P0 after P0 had done its work: it rated itself `partial` for skipping one re-check, and a required block with no output contract fails on `partial` alone - the warning the validator had raised on nine blocks since the card was written. Every stage now carries an output contract on the artifact it already emits (`resume`, `release`, `contended`, `unclaimed`, `residue`, `headings`, `bump`, `tag`, `push`), so a satisfied contract overrides the self-assessment, and the card validates with no warnings.
- **Saving the shell-config modal no longer demands a re-sign for edits that add no privilege** (`app/routes/mcp_routes.py` (`update_shell_config`, `_carry_file_signature`), `app/config/scope_canonical.py` (`rebind_signature`, `bundle_signature`, `delta_is_subset`; `verify_delta_signature` accepts bundles), `frontend/src/components/ShellConfigModal.tsx` (banner text); tests `tests/test_shell_config_save_floor_edit_keeps_signature.py` (new, 20; 14 fail on the old code)). With a signed escalation in the config, ticking a git operation already in the default floor voided `ZIYA_SCOPE_SIG`: the save compared raw field values, while the signature covers only the delta beyond the floor. It now compares deltas. Removing one signed command also forced a re-sign, because a signature verified only against its exact delta; such a save now stores a bundled signature (`v2.<signed delta>.<signature>`) that the verifier accepts for any subset of what root signed. Nothing new is signed — a bundle repackages a signature that already verifies, and fails if its delta was not the one signed. Adding anything beyond what was signed still requires `sudo ziya-approve`, and restoring the full signed set restores the plain signature. `ziya-approve` output is unchanged.
- **Output that merely quotes `🚫 SECURITY BLOCK` no longer renders as a "Command Blocked" alert** (`frontend/src/utils/blockedCommand.ts` (new), `MarkdownRenderer.tsx` (both sites); tests in `utils/__tests__/terminalPane.test.ts`, `components/__tests__/terminalPane.test.tsx`). The renderer tested for the shell-policy sentinel anywhere in a tool result, so a `file_write` echoing a test fixture that contained it, or a grep over the renderer's own source, was replaced wholesale by the amber alert. The check is now anchored at the start of the message — the whole result, or the `message` of an `{'error': True, …}` dict — which is where the shell server puts a real refusal.
- **A diff that patches a fenced markdown file streams through intact** (`app/streaming_tool_executor.py` (`_update_code_block_tracker`, `_normalize_fence_spacing`); test `tests/test_fence_chunk_boundary.py` (new, 15; eight fail on the old code, including the end-to-end `process_text_delta` drive)). A ```` ```diff ```` whose context lines held ` ```bash` / ` ``` ` came out with one of those lines rewritten to ` ` + blank line + column-0 ```` ```bash ````, which the frontend's diff-aware fence scanner could not pair, and the turn ended with a spurious "unclosed fence" continuation ("The code block is already closed; nothing remains to emit"). Two causes. The content optimizer re-chunks on whitespace, so the tracker received `"\n "` and then ```` "```bash" ````: measured alone the second fragment is a column-0 fence, the diff-body guard missed it, and the tracker wandered out of the diff — ending on a phantom open bare fence at the diff's real closer. The tracker now remembers each chunk's unterminated last line with the state it was read from, rewinds, and re-reads the whole line when the next chunk completes it (single-call behaviour is unchanged; an external reset between chunks is honoured). With the tracker wrongly closed, the fence-spacing normaliser then treated the single leading space of ` ```bash` as prose glued to an opener and inserted the blank line; an indented fence line (or diff context line) is now left alone, while `layers:```mermaid` is still normalised. The same normaliser's single-newline rule used a lookbehind that holds trivially at the start of a chunk, so `"file.\n"` | `"\n```diff"` — already a blank line — gained a third newline; it now consults the preceding text (tests `TestNormalizerSingleNewlineAcrossChunks` (3) and an every-split-point `process_text_delta` drive, 2 of which fail on the old code).
- **Memory tools, extraction, feedback and maintenance no longer make Bedrock embedding calls on the event loop** (`app/mcp/tools/memory_tools.py`, `app/memory/{extractor,feedback,organizer,rem}.py`, `app/agents/run_memory_harvest.py`, `app/storage/memory.py`, `app/server.py`, `app/api/memory.py`, `app/services/embedding_service.py`; test `tests/test_memory_embed_not_event_loop_blocking.py` (new, 22); `tests/test_memory_zeros_fixes.py`, `tests/test_memory_rem_phase.py` updated). Follow-on to the lifecycle fix below: an audit of every coroutine that reaches an embedding call found the same defect in many more places. All seven memory MCP tools were `async def` with no awaits, so `memory_search`, `memory_save` and `memory_propose` embedded on the loop; extraction's `deduplicate` embedded every candidate there; three call sites ran the first-call provider smoke test there. Those now run on worker threads. Two storage changes cut the number of calls: `save`/`save_many` no longer re-embed when content is unchanged and a vector is cached (a `memory_search` returning 10 results made 11 Bedrock calls, now 1; per-response feedback re-embedded every loaded memory, now 0), and a new `MemoryStorage.patch_many` updates counter and status fields on the stored record under the file lock, so a bump made off the loop cannot write back a stale copy and resurrect a memory deleted in between.
- **Proposal-store reads and writes in extraction and run harvest moved off the event loop** (`app/memory/extractor.py`, `app/agents/run_memory_harvest.py`; guard extended in `tests/test_memory_embed_not_event_loop_blocking.py`). `ProposalsStore.get`, `record_signal` and `corroborate_by_id` read, decrypt and rewrite the proposals event log; both coroutines called them inline. They now run under `asyncio.to_thread`. The extractor's UPDATE branch also no longer embeds the new content twice (`store.save` already re-embeds on a content change).
- **The memory lifecycle sweep no longer stalls the server on embedding calls** (`app/memory/lifecycle.py`; test `tests/test_lifecycle_not_event_loop_blocking.py` (new, 3)). `run_lifecycle_pass` was `async def` with no awaits, so the whole sweep ran on the event loop, including one synchronous Bedrock `invoke_model` per promoted proposal (`_promote_proposal -> embed_and_cache`). The stall watchdog caught the loop parked in the SSL read. The sweep now runs under `asyncio.to_thread`. Because passes can now overlap (the post-extraction pass and the 30-minute system job), a process-wide lock serializes them; without it two passes could both save an active memory for the same proposal before either marked it promoted. One test runs two passes concurrently and fails with the lock removed.
- **`chat_search` / `chat_read` / `chat_list` no longer stall the server while they read chat files** (`app/mcp/tools/chat_history_tools.py`; test `tests/test_chat_history_tools_not_event_loop_blocking.py` (new, 4)). The three tools are `async def` but awaited nothing, so a search walked every chat file in scope (read, decrypt, scan) on the event loop; the stall watchdog caught the loop inside `search_chats -> _read_chat_data -> read_bytes`, with every other request parked behind it. The disk-bound call in each tool (`search_chats`, `_load_chat`, `ChatStorage.list_summaries`) now runs under `asyncio.to_thread`. The test drives each tool's real `execute()` with the callee swapped for a probe that records its thread, and fails against the previous file.
- **Inline flowchart edge labels on dotted, thick and hyphenated solid edges render instead of failing the lexer** (`frontend/src/plugins/d3/mermaidEnhancer.ts` (`pipe-syntax-fix`); test `frontend/src/plugins/d3/__tests__/mermaidDottedEdgeInlineLabel.test.ts` (new, 14; the positive cases fail on the old pass)). Mermaid ends an inline label `A -. text .-> B` at the first `.->`, and its lexer reads a literal `.` in the text as the start of that terminator: `C2 -. PG-table miss<br/>+38.5k drops .-> D2` failed with "Lexical error on line 7 … +38.5k" while the dot-free sibling edge rendered. The pass rewrote only `-- text -->`, and even there excluded `-` from the label, so a hyphenated label stayed inline. It now rewrites `-. text .->`, `== text ==>` and hyphenated solid labels to pipe syntax, and every rewrite emits a quoted, `#quot;`-escaped label — this pass runs after both quoting passes, so a bare `|say "hi"|` it produced was never quoted later and is a parse error in mermaid 11.17.2 (verified against the real parser). Idempotent; unlabelled `-.->` / `==>` / `-.-` are untouched.
- **Startup no longer serves nothing while memory embeddings backfill** (`app/services/embedding_service.py` (`backfill_embeddings`); test `tests/test_backfill_embeddings_not_event_loop_blocking.py` (new, fails on the old code: the loop ticks once); `tests/test_memory_embedding_integration.py` (assertion follows the 2026-09-11 proposals-backfill call)). Attributed with `py-spy dump` on a live server: the MainThread — the asyncio event loop — was inside `ssl.read` under botocore under `embed_text`, called inline from `_initialize_memory_background` once per memory lacking a vector (thousands on a mature install). While parked there no request was served at all — not `/chats`, not any config GET — which was the startup "limbo" long attributed to the folder scan (not running in either dump): the sidebar lived off its IndexedDB shells, config dialogs would not open, and the eventual sync flashed in minutes later. Each batch now runs in a worker thread (`asyncio.to_thread`) and the loop only does the bookkeeping. The same function backs the manual `POST /api/memory/.../backfill`, so that path is covered too.
- **Three more request handlers no longer park the event loop under startup load** (`app/routes/folder_routes.py` (`get_default_included_folders`: the documentation walk now runs via `asyncio.to_thread`), `app/api/task_runs.py` (13 zero-await handlers `async def` → `def`; `cancel` and the two `resume-from` launches stay async), `app/api/bench.py` (9 zero-await handlers `async def` → `def`; `get_browse` / `put_place` stay async); tests `tests/test_default_included_folders_not_event_loop_blocking.py` (new, 2), `tests/test_task_runs_api_not_event_loop_blocking.py` (new, 7), `tests/test_bench_api_not_event_loop_blocking.py` (new, 6) — each fails on the unpatched file, naming the offending handlers). Found one dump at a time with the in-process stall watchdog (below) after the embedding fix above: `GET /api/default-included-folders` ran `os.walk` over the whole project tree inline on the loop on every load and project switch; `GET /task-runs` read, decrypted and parsed every run record (508 files / 483 MB on one install) on the loop, and the frontend polls it; `GET …/bench` read every skill file on the loop on every conversation open. The bench store is only 252 KB, which is the point: the stall is not volume but GIL contention — with two folder scans and two AST indexers running, a coroutine that never awaits cannot get out of their way, so any zero-await `async def` doing disk I/O is a multi-second stall under startup load regardless of how cheap it looks. Symptom was the log's "nothing, then everything at once": no `200 OK` for up to three minutes, then every queued response in one burst, during which config modals would not open and the file browser appeared to block everything.
- **Every remaining zero-await async route handler now runs off the event loop** (`app/routes/ast_routes.py` (`get_status` / `get_resolutions`, caught by the stall watchdog regenerating the full AST context on every `/api/ast/status` poll while indexing ran), `app/api/task_cards.py` (`get_signature_summary`, re-polled on every window focus, opening every approval record); then the sweep: 134 zero-await handlers across 26 files (`app/api/backlog.py`, `beads.py`, `consent.py`, `contexts.py`, `delegates.py`, `drag_helper.py`, `handoff.py`, `memory.py`, `projects.py`, `skills.py`, `task_bindings.py`, `tokens.py`, `app/routes/builtin_tools_routes.py`, `cache_routes.py`, `conversation_routes.py`, `debug_routes.py`, `export_routes.py`, `file_validation.py`, `folder_routes.py` (its remaining 8), `graph_routes.py`, `mcp_registry_routes.py`, `mcp_routes.py`, `misc_routes.py`, `page_routes.py`, `token_routes.py`, `transcription_routes.py`) converted from `async def` to plain `def`, with `asyncio.to_thread` kept only where a handler genuinely needed the running loop; 26 new per-module guard tests (`tests/test_<module>_not_event_loop_blocking.py`) plus `tests/test_no_zero_await_handlers.py` (new: scans every file under `app/api/` and `app/routes/`, non-vacuous floors of 20 files / 150 handlers)). Finishes the class the stall watchdog opened above: every one of these handlers was `async def` with no `await` in its body, so under the GIL contention of a folder scan or AST indexer any disk read on it — 252 KB or 483 MB, size never mattered — parked the event loop and served no request at all until it returned. The repo-wide guard now fails if a zero-await `async def` handler is added anywhere under `app/api/` or `app/routes/`, so the class cannot recur unnoticed.
- **Stall watchdog for attributing "the server serves nothing" episodes** (`app/utils/stall_watchdog.py` (new), `app/server.py` (started from the lifespan; also registers a `faulthandler` all-thread dump on `SIGUSR1`); tests `tests/test_stall_watchdog.py` (new, 4)). Set `ZIYA_STALL_DUMP=<seconds>`: a daemon thread pings the loop once a second and reads the default executor census; when the loop does not answer within the threshold, or the `to_thread` pool is saturated with queued work for that long, it logs one report per episode — the trigger, the census, and every thread's Python stack via `sys._current_frames()`, with the event-loop thread tagged. Exists because the two external options both crashed the server on macOS: `py-spy dump` segfaulted the process on attach before it reached the stalled state, and `faulthandler`'s all-thread dump segfaulted on its second use while formatting another thread's frame. `sys._current_frames()` takes the GIL like any Python code, so it cannot crash the process and cannot miss the stalls that matter (inline I/O releases the GIL). Known gap: on uvicorn's loop the executor census reports `created: 0` even while the `to_thread` pool is busy, so only the loop-stall half is currently reliable.
- **The handoff drawer's document editor fills the viewport** (`frontend/src/components/HandoffDrawer.tsx`). The textarea had `autoSize={{ maxRows: 24 }}`, so on a tall screen a long handoff document scrolled inside a ~24-line box while the lower half of the drawer sat blank. The drawer body is now a flex column; the document section is the one that grows (`minHeight: 0` so it yields to the fixed-size sections below instead of overflowing), with a 10-row floor for short viewports.
- **Scheduled fires record `card_snapshot` and `permissions_snapshot`** (`app/agents/task_scheduler.py` (`_fire_one`); test `tests/test_task_scheduler.py::test_fire_one_records_card_and_permissions_snapshot`, fails on the old code at its first assertion). The interactive launch path captured both at launch; the scheduler captured neither, so a scheduled run had no record of which card definition it executed (the scheduler re-reads the card each tick, so the live card may since have changed) and resume-from-block — which walks `card_snapshot.root` — could not work on scheduled runs at all. The snapshot tree is the schedule's resolved body, the same tree `block_states` are keyed by, with `schedule_block_id` naming the wrapper that is absent from it. The permissions snapshot folds the Schedule block's own `scope` into the card layer, because `_go()` seeds that scope onto the executor's stack even though the block is not in the body tree.
- **Reload Config and the MCP server status panel are reachable from the Bench again** (`frontend/src/components/Bench/BenchPanel.tsx` (`bench-mcp-servers` button), `frontend/src/hooks/useBench.ts` (`MCP_STATUS_CHANGED_EVENT` listener); tests `Bench/__tests__/benchActions.test.tsx`, `hooks/__tests__/useBenchMcpReload.test.tsx` (new); `Docs/UserConfigurationFiles.md`). When the Bench absorbed the MCP Servers toolbar button (slice 6b) the status panel — config path, per-server startup logs, permissions and **Reload Config** — kept only one way in: the "logs, permissions, reauthorize ▸" link inside a server card's drawer. A server just added to `mcp_config.json` has no card yet, so after editing the config there was no way to say "reload the servers" or to see why one failed to start. The board now has its own *MCP servers · status, logs, reload config* button beside *Find more*, and `useBench` refetches on the panel's `mcpStatusChanged` event, so a reloaded server gets a card without closing and reopening the Bench.
- **A patch on a file that an earlier diff in the same response creates is refused with the right correction** (`app/utils/diff_validation_hook.py` (`created_in_response`, `reader_reason`), test `tests/test_diff_validation_pending_new_file.py` (new, 4)). The refusal was already correct — every fence must apply on its own against the live file — but the feedback said "mark as new file creation", which would recreate the file from a fragment, so the model re-emitted the same patch and the turn was wasted. The hook now remembers the files a response's `--- /dev/null` fences create and, for a later patch on one of them, tells the model to re-emit one complete new-file diff; the chat notice says the target is created by an earlier, unapplied diff. An ordinary missing target keeps the old wording.
- **A second window on the same project no longer ends, or re-ends, a turn it doesn't own; a new send no longer aborts itself** (`frontend/src/context/ChatContext.tsx` (`handleStreamingEnded`, `removeStreamingConversation` gains `{ broadcast }`, eager `streamingConversationsRef` sync in add/remove and the chunk/state handlers), `frontend/src/apis/chatApi.ts` (`sendPayload` abort handshake and `finally`); tests `frontend/src/context/__tests__/streamingEndedEcho.test.ts` (new, 6), `frontend/src/apis/__tests__/sendPayloadAbortOrdering.test.ts` (new, 3)). When tab A finished a turn and broadcast `streaming-ended`, a mirroring tab B released its copy by calling `removeStreamingConversation`, which re-broadcast the same event, so A received an echo of its own end-of-turn. If A had already begun its next turn (feedback recovery resubmits in the same tick) the echo deleted that turn's streaming mark: the reply streamed with no spinner or Stop button, the question above it got the orange needs-response bar although it was being answered, and at completion the idempotency guard returned before clearing `streamedContentMap`. The next send then saw "Stream already active" and dispatched `abortStream` — after registering its own listener and after removing the old one — so it aborted its own `AbortController`, `POST /api/chat` failed at once, and the message was left unanswered. The guard also read a ref refreshed only in a `useEffect`, so while B was busy every queued echo passed it and re-broadcast, and the tabs ping-ponged dozens of times per turn. `handleStreamingEnded` now acts only on a stream the tab is mirroring and releases it without re-broadcasting; the ref is updated when the set changes, not at the next commit; `removeStreamingConversation` clears the content map before the set guard; `sendPayload` aborts the previous stream before registering its listener, finalizes the aborted stream so a queued flush can't re-insert its content, and only clears the shared `window.abortListener_<id>` slot when it still owns it.
- **Shell heredocs: a compound command may end in a heredoc, and what the shell runs from an unquoted body is validated** (`app/mcp_servers/write_policy.py` (`_heredoc_spans`, `_heredoc_body_commands`, `_strip_heredoc_bodies`, `ShellWriteChecker._check`), `app/mcp_servers/shell_server.py` (`_validate_compound_body`, `is_command_allowed`); test `tests/test_shell_heredoc_bodies.py` (new, 32)). Three defects at one seam. `for …; done; python3 - <<'EOF' … EOF` was refused with `'import' is not allowed`: the compound route validated the raw command, so every Python line was checked as a shell command, and the model had to spend extra turns writing the script to a file. It now strips heredoc bodies as the plain heredoc route already did. In the other direction, both gates stripped bodies wholesale, but with an unquoted delimiter (`<<EOF`) the shell expands `$(…)` and backticks inside the body, so `cat <<EOF\n$(cmd)\nEOF` ran `cmd` past both the allowlist and the write gate; those substitutions are now extracted and validated as commands (`(in heredoc substitution) …`). A quoted delimiter (`<<'EOF'`) is pure data and is never scanned. And the `<<-` form strips leading tabs from the terminator too, so `\tEOF` ends the body; the stripper demanded column 0, ran on to a later bare `EOF`, and skipped the commands between.
- **Memory export asks where to save and says where it went** (`app/utils/memory_export.py` (`default_export_dir`, `resolve_save_path`, `write_export_file`), `app/api/memory.py` (`POST /api/v1/memory/export` accepts `save_path` / `overwrite`; options return `default_save_dir`, preview returns `suggested_filename`), `frontend/src/api/memoryApi.ts` (`saveExport`, `ExportSaveError`, `defaultSaveTarget`), `frontend/src/components/MemoryExportPanel.tsx`; tests `tests/test_memory_export_save_path.py` (new, 11), `frontend/src/api/__tests__/memoryExportSaveTarget.test.ts` (new, 3)). Export used to hand the bundle to the browser as a download, and the browser never tells the page where it saved the file, so users couldn't find it. The Export button now opens a "Save to" dialog prefilled with `~/Downloads/<suggested name>`. The field takes a folder or a file path, and `~` or a relative path resolves against home. The server writes the file and returns its absolute path, which appears in a warning toast at the top of the screen for 20 seconds. An existing file is not overwritten unless you confirm it (the server returns 409 `file_exists` first), a missing folder returns 422, and the saved path is logged as `saved_to` on the `memory_exported` audit event. The browser download is still available in the dialog, and its toast names the file and the browser's download folder.
- **A model pinned to one conversation no longer inherits a session region it can't be served from** (`app/utils/model_region.py` (new, `region_for_model`), `app/streaming_tool_executor.py` (`StreamingToolExecutor.__init__`); test `tests/test_pinned_model_region.py` (new, 5)). Requests were sent to the server's single session region, which is resolved for the process-wide `--model`. On 2026-10-07 the session model opus5.5 was running in eu-west-1, and a conversation pinned to fable5.1 was sent there too. fable5.1 has only `us`/`global` profiles and is available in us-east-1/us-west-2, so Bedrock rejected it with `data retention mode 'default' is not available`, because eu-west-1's retention switch had never been set. The executor now uses the model's `preferred_region` when the session region is outside the model's `available_regions` and the model has no profile for that geography. It logs `🌍 REGION` when it does. Models with an `eu` profile keep EU regions.
- **A region-router failover no longer moves the whole server to the failover region** (`app/utils/custom_bedrock.py` (`CustomBedrockClient.__init__`); test `tests/test_custom_bedrock_region_isolation.py` (new, 2)). `CustomBedrockClient` wrote its client's region to `os.environ["AWS_REGION"]`, and `BedrockRegionRouter` builds one for every failover region. `get_current_region()` reads that variable, so after a failover, any later code that read it got the failover region. On 2026-10-07 an opus5.5 `ServiceUnavailable` in us-west-2 failed over to eu-west-1 and left `AWS_REGION=eu-west-1` in the server. Later, some (not all) fable5.1 requests were sent to eu-west-1 and rejected with `data retention mode 'default' is not available`, because the retention switch there had never been set. CloudTrail shows fable calls in us-west-2 between those failures, so the region is resolved per request. Whether this write is what fed those requests is not yet confirmed.
- **Data-retention rejections name the region and account** (`app/providers/bedrock.py`, `app/utils/retention_diagnostics.py`; tests `tests/test_bedrock_retention_heal.py`, `tests/test_retention_diagnostics.py`). The provider's error names neither the region nor the account. CloudTrail logs the rejected call only in the region it was sent to. A rejection now logs `🔐 RETENTION_GATE model=… region=… profile=…` and a `🔐 RETENTION_DIAG` line with the account, the regions the inference profile can route to, and the switch in each of them. The user-facing error names the region the call was sent to and lists the regions below the required mode, instead of asking the user to restart.
- **`shadow_wait(cmd_seq=…)` no longer sleeps to its timeout on shells without OSC 133** (`app/shadow/segmenter.py` (`_finish_command`), `app/shadow/journal.py` (`JournalWriter.exit`, `searchable_text`), `app/shadow/client.py` (`format_records`), `app/mcp/tools/shadow_tools.py` (`ShadowWaitInput.until`); test `tests/test_shadow_heuristic_exit.py` (new, 7); `Docs/design/shadow-sessions.md` §4.1, `design/shadow-wait.md`). In prompt-heuristic segmentation (any ssh'd shell, the common case) the segmenter detected each command's end but journaled nothing, and only an OSC 133 `D` produced an `exit` record. A wait on `until=['exit']`, which is the default with `cmd_seq` and the call `shadow_send`'s `next` hint asks for, could therefore never fire. Every wait ran the full 240 s default, even on commands that returned at once. When the prompt returns the segmenter now writes `exit` with `code: null`, so the wait wakes about 0.5 s after completion and reports `exit_code: null`. The same happens when osc133 skips `D` before the next `A`. Transcripts show `→ done (exit code unknown)`.
- **Raw-segmentation shadow sessions no longer fold every command into the first, or send `shadow_wait` to its timeout** (`app/shadow/segmenter.py` (`_heuristic_enter`, `_prompt_returned`), `app/mcp/tools/shadow_tools.py` (`ShadowSendTool` hint, `ShadowWaitTool` `no_exit_signal`); test `tests/test_shadow_raw_mode.py` (new, 11); `Docs/design/shadow-sessions.md` §4.1, `design/shadow-wait.md`). With no learnable pro