# Changelog

All notable changes to OwlCoda are documented here.

Runtime version truth does **not** come from this file.

- Authoritative runtime version: [`package.json`](package.json)
- Runtime display source: [`src/version.ts`](src/version.ts)
- The labels below are **historical milestone labels**, not authoritative runtime versions
- Top entries (semver `0.13.x`) are the live release thread; everything below the
  semver block is historical milestone ledger.

## [0.14.50] — 2026-06-01

Evidence-ledger fidelity observability and stream fallback hardening.

### Added

- Added shadow-only evidence-ledger fidelity telemetry for hard-anchor claims in
  assistant output and exact path facts in LLM compaction summaries.
- Added `/fidelity`, `/fidelity json`, and `/fidelity review` for bounded,
  read-only inspection and manual review packet export of recent fidelity
  telemetry.
- Added deterministic review keys for fidelity samples so human reviewers can
  compare labels across runs without making labels runtime truth.

### Fixed

- Prevented ordinary user prompt bait from becoming retained-turn grounding
  evidence for claim matching.
- Improved streaming fallback handling for pre-first-token socket closes,
  including translated OpenAI-compatible routes.

## [0.14.49] — 2026-05-31

Terminal.app Enter hotfix.

### Fixed

- Guarded macOS Terminal.app modifier-key detection so a missing or failing
  optional native modifier addon cannot terminate the REPL when pressing Enter.
- Terminal.app now degrades Shift detection to "not pressed" when the addon is
  unavailable, preserving normal Enter-to-submit behavior.
- Added a regression test for the optional modifier addon failure path.

## [0.14.48] — 2026-05-30

Terminal/PTY Ctrl+C input hotfix.

### Fixed

- Filtered parsed Ctrl+C input (`input: "c"`, `key.ctrl: true`) out of the
  composer so Terminal.app, Windows Terminal, and PowerShell-style key events do
  not insert a literal `c` into the draft while the parent REPL handles the
  interrupt.
- Treated same-chunk Ctrl+C bursts such as `\x03\x03` as one exit-arm event
  instead of an immediate exit confirmation. The normal "press Ctrl+C again to
  exit" behavior is unchanged for separate keypresses.
- Cleared stale exit confirmation state on normal typing or submit, and added
  interrupt trace source metadata for exit diagnostics.

## [0.14.47] — 2026-05-30

Project Map runtime control plane default-on and Permission Modes default-on
cutover.

### Added

- Enabled Permission Modes by default. `/mode` is available without
  `OWLCODA_MODES=1`; OwlCoda still starts in `normal` mode unless a different
  mode is selected, and `OWLCODA_MODES=0` remains the rollback override.
- Enabled the Project Map runtime control plane by default with
  `OWLCODA_PROJECT_MAP=0` as the rollback override. Project Map provides a
  bounded project snapshot, `/project-map`, headless JSON evidence, and
  TaskCreate verification-profile expansion.

### Fixed

- Isolated Project Map snapshot build failures so a default-on scan degrades to
  no Project Map instead of breaking the conversation loop.
- Made Project Map freshness use full-content hashes while keeping `bytesRead`
  as bounded evidence metadata.
- Clarified Project Map boundaries and verification profiles: Project Map does
  not grant write permission; boundary enforcement is block-only/advisory today,
  and package-script command checks still pass through TaskVerify's safe-readonly
  classifier.

## [0.14.46] — 2026-05-30

Opt-in permission modes, adaptive admission observability, and clearer
no-progress diagnostics.

### Added

- Added opt-in Permission Modes behind `OWLCODA_MODES=1`, including `/mode`,
  `--mode`, config-backed initial mode, plan/normal/auto mode state, mode-gate
  telemetry, and low-risk auto approvals in auto mode. Default behavior is
  unchanged while the flag is unset.
- Added opt-in daemon-side admission coordination for adaptive sub-agent
  concurrency when `OWLCODA_AGENT_ADAPTIVE_CONCURRENCY=1`, including the
  `GET /v1/admission` observability surface and adaptive per-key admission
  signals. Cross-process admission remains off unless adaptive concurrency is
  enabled.
- Surfaced prompt-cache read/write accounting in `/cost` for OpenAI- and
  Anthropic-compatible usage paths.

### Fixed

- Tightened PowerShell mutation classification for plan-mode safety, including
  `Export-Csv`, `Tee-Object`, and `-OutFile` write paths.
- Improved `task_no_progress` hard-stop diagnostics so the user sees why the
  guard stopped and which budget knob is relevant.

## [0.14.45] — 2026-05-29

Conversation prefix prompt caching and CI gate speedup.

### Added

- Conversation-prefix prompt caching for Anthropic-compatible requests. OwlCoda
  marks the latest cacheable conversation block with an ephemeral cache
  breakpoint, improving cache reuse across multi-turn sessions without
  mutating `conversation.turns`.

### Fixed

- Removed an oversized self-hosted npm cache restore from the main CI gate so
  install, typecheck, build, full test, clean build, smoke, and release smoke
  run inside the normal verification budget.

## [0.14.44] — 2026-05-29

Opt-in adaptive sub-agent concurrency.

### Added

- Opt-in adaptive sub-agent concurrency
  (`OWLCODA_AGENT_ADAPTIVE_CONCURRENCY=1`): the existing `MAX_CONCURRENCY`
  becomes a ceiling while an AIMD controller slow-starts the effective limit
  (halve on throttle, +1 after three consecutive successes, 5s cooldown),
  keyed per proxy. Sub-agent retries honor `Retry-After` with a 1→3 budget and
  a 12/60s throttle ledger. Default behavior is unchanged when the flag is
  unset; cross-process daemon coordination is deferred to its own spec.

## [0.14.43] — 2026-05-29

Native LSP client activation and unified token-count display.

### Added

- Native LSP client (`lsp-provider.ts`): stdio JSON-RPC, lazy
  per-workspace-root language-server spawn, actions
  diagnostics/hover/definition/references/symbols/completion. TS/JS via
  typescript-language-server; graceful no-server reporting for other
  extensions.

### Fixed

- The LSP tool was registered with a permanent no-op provider and an
  unimplemented activation path; it is now wired to a real client.
- Status-bar CTX and model-window labels share one canonical token formatter
  so >=1M context windows render as "2M"/"1M" instead of large-k numbers like
  "2097.2k".

## [0.14.42] — 2026-05-28

Chat-completions routing, WebFetch fallback recovery, StructuredOutput
validation, and TUI rendering patch.

### Added

- WebFetch can recover eligible 404 responses by using a site `llms.txt`
  fallback when available.

### Fixed

- `/v1/chat/completions` now preserves per-model routing so cloud and local
  model choices resolve through the intended backend.
- StructuredOutput now validates schema payloads before execution so malformed
  schemas fail with a compact validation error instead of reaching the model
  path.
- Tool-start summaries now render without raw JSON, and wrapped tool output
  keeps its indentation.

### Tests

- Aligned Kimi model and daemon lifecycle tests with the shipped runtime
  expectations.

## [0.14.41] — 2026-05-28

Agent watchdog timeout handling, DuckDuckGo Lite parsing, and sub-agent
telemetry path-sample patch.

### Added

- Added touched-path and expected-artifact path samples to `agent_invocation`
  telemetry so file-collision investigations can identify the relevant
  sub-agent surfaces directly from JSONL events.

### Fixed

- Cooperative sub-agent watchdog exits now share the same timeout result path
  as thrown watchdog failures, preserving a single timeout schema.
- WebSearch now parses the current DuckDuckGo Lite result HTML, including
  single-quoted class attributes and `/l/?uddg=` redirect URLs.

## [0.14.40] — 2026-05-28

Daemon version-drift guard, sub-agent runtime-failure isolation completion, and
compact validation-error rendering patch.

### Fixed

- Startup now hard-blocks when the CLI and resident daemon versions drift, so an
  upgraded CLI does not silently talk to an older daemon. Restart with
  `owlcoda stop --force && owlcoda`; emergency bypass remains available with
  `OWLCODA_ALLOW_VERSION_DRIFT=1`.
- The `runConversationLoop` runtime-failure path now preserves the sub-agent
  isolation contract instead of leaking isolated sub-agent failures into the
  parent loop as terminal failures.
- Fast-validation tool errors now render as compact one-line diagnostics.

## [0.14.39] — 2026-05-28

Sub-agent failure contract hardening and observability patch.

### Added

- Added `agent_invocation` JSONL telemetry for sub-agent runs, including
  inferred-completion classification.

### Fixed

- Loop guards no longer count isolated sub-agent failures as repeated parent
  failures of the same class.
- Isolated sub-agent failure output is structured for the parent model, with
  routing guidance it can read and act on.
- Sub-agent failure handling remains scoped to short-window provider pressure
  and isolated tool failures; this release does not claim cross-process quota
  enforcement or an orchestrator layer.

## [0.14.38] — 2026-05-28

Sub-agent isolation and provider retry patch.

### Fixed

- Isolated sub-agent failures no longer terminate the parent loop, with
  isolated-failure metadata normalized across catch paths.
- HTTP 400 provider errors with rate-limit-shaped details are treated as
  retryable and covered by an end-to-end retry path.
- Sub-agent execution is throttled in-process by default to reduce bursty
  provider pressure, without claiming multi-process quota enforcement.
- Successful DeliveryAudit output remains footer-only.

## [0.14.37] — 2026-05-28

Sub-agent failure isolation hotfix.

### Fixed

- Isolated sub-agent failures no longer terminate the parent conversation loop
  as `terminal_tool_failure`; true terminal tool failures keep their existing
  terminal path unchanged.

## [0.14.36] — 2026-05-27

Post-0.14.35 UX/noise patch.

### Fixed

- Routed model-internal nudges to footer-only display so runtime guidance no
  longer pollutes transcript or the main user-visible message stream.

## [0.14.35] — 2026-05-27

Write-target provenance gate flagged release.

### Added

- Added opt-in write-target provenance gate coverage for native tool writes
  when `OWLCODA_GATE_PROVENANCE=1`, while keeping default behavior unchanged.
- Added release smoke fixtures and telemetry helpers for reviewing provenance
  admit/block events during shadow dogfood.
- Added public documentation for the flagged provenance workflow and settings
  rule shape.

### Fixed

- Settings-backed permission rules now flow through the provenance gate path
  with warning telemetry for unsupported `ask` rules.
- Task nudges are suppressed while OwlCoda is awaiting an explicit user
  decision, avoiding misleading progress pressure during permission review.

## [0.14.34] — 2026-05-26

Streaming transport-interruption hotfix.

### Fixed

- Streaming response body socket closures after partial output are now reported
  as retryable `stream_interrupted` diagnostics with `partialOutputSeen: true`.
- Mid-stream provider transport closes are no longer surfaced as
  non-retryable unknown fetch failures after usable partial output has already
  been emitted.

## [0.14.33] — 2026-05-26

Headless runtime policy and diagnostics hardening.

### Added

- Headless runs now expose explicit `--allow-tool` and `--deny-tool` filters
  that narrow the normal unattended approval policy without bypassing bash risk
  checks.
- Headless prompts now include an explicit runtime approval-policy context so
  unattended runs know which tools are available and which tools will be denied.

### Fixed

- Non-JSON piped headless output now preserves raw stdout without duplicating
  final text or injecting terminal chrome.
- Headless runs with emitted final content no longer fail solely because the
  loop stopped on a narration guard.
- Hard-stop headless failures now emit a structured diagnostic with stop reason,
  task status, recent assistant text, recent tools, and todo state.
- Kimi Code is marked as sustained-work capable in model recommendation and
  display surfaces.

## [0.14.32] — 2026-05-25

Gate V2 action-permission state machine preview.

### Added

- Added an opt-in `OWLCODA_GATE_V2=1` gate path that tracks proposed tool calls,
  permission decisions, execution starts, settlements, and post-grant evidence.
- Added structured risk classification for read-only, internal-state,
  mutating, destructive, and external-effect tools.
- Added the first `edit_now` intervention path for abandoned risky grants while
  keeping the default gate behavior unchanged.

### Fixed

- Review/chat-style turns are no longer forced through classifier-era
  `task_no_progress` assumptions when Gate V2 is enabled.

## [0.14.31] — 2026-05-23

SWE-bench task-progress recovery hotfix.

### Fixed

- SWE-bench generation now makes one bounded recovery attempt when an
  unattended run stops with `task_no_progress` and a zero-byte patch.
- Recovery prompts now preserve already inspected files even when macOS reports
  `/private/tmp/...` paths for `/tmp/...` workspaces.
- Benchmark summaries now report `task_no_progress` recovery attempts,
  recovered patches, and timeout-empty-stdout failures separately.

## [0.14.30] — 2026-05-21

SWE-bench prediction hygiene hotfix.

### Fixed

- Empty SWE-bench patches are now kept in runner records and summaries but are
  no longer written as official prediction rows.
- `task_no_progress` empty patches now fail sanity gates without contaminating
  downstream harness input.

## [0.14.29] — 2026-05-21

Benchmark runner hygiene patch.

### Fixed

- Benchmark generation failures are now separated from evaluation prediction
  artifacts.
- Failed generation attempts are kept out of prediction output files.
- Local daemon port collisions are reduced during unattended benchmark batches.
- Unattended benchmark prompts now steer toward bounded, early code edits.

## [0.14.28] — 2026-05-18

SWE-bench unattended runner throughput and approval hardening.

### Added

- The SWE-bench Lite runner can reuse shared bare repository mirrors and run
  instance generation with configurable concurrency.
- Benchmark records now include per-instance duration and post-patch timeout
  fields so slow runs are measured instead of guessed.
- A post-patch timeout can stop an instance after its repository diff stays
  non-empty and stable, preserving the patch instead of waiting indefinitely
  for final model narration.

### Fixed

- Headless auto-approval now permits workspace-local Python version probes,
  local Python test scripts, Django `manage.py test`, `py_compile`, safe
  `python -c` probes, and safe `python - <<'PY'` heredoc probes while still
  rejecting installs, network access, file deletion, and shell writes.
- The SWE-bench prompt now tells unattended runs to make a minimal edit after
  localization and to stop after at most one focused verification attempt.

## [0.14.27] — 2026-05-17

SWE-bench unattended scoring hardening.

### Fixed

- Headless JSON runs now exit non-zero when the conversation stops under
  task-guard conditions such as `task_no_progress`, preventing empty patches
  from being recorded as successful benchmark predictions.
- The SWE-bench runner now declares the active checkout as the bounded edit
  workspace and raises the no-progress iteration window for coding tasks.
- Headless auto-approval now permits workspace-local Django `tests/runtests.py`,
  pytest, unittest, and read-only diff checks while still rejecting installs,
  network commands, shell writes, and out-of-workspace execution.

## [0.14.26] — 2026-05-17

SWE-bench and headless automation hardening.

### Added

- Added a SWE-bench Lite batch runner for isolated benchmark prediction
  generation and official harness evaluation.

### Fixed

- Headless JSON runs no longer block on interactive `AskUserQuestion` prompts;
  unattended runs now deny interactive questions instead of waiting on stdin.
- Headless JSON output waits for stdout flush callbacks, preventing truncated
  result JSON during large tool-output runs.
- Code-change benchmark tasks can run focused workspace-local test commands
  under `--auto-approve` while preserving the workspace boundary.
- Traceback `File ".../dist-packages/..."` references no longer cause
  deliverable-mode misclassification as file-artifact output tasks.

## [0.14.25] — 2026-05-17

Headless runtime approval fix.

### Fixed

- High-confidence workspace-scoped code-change tasks can use structured
  edit/write tools under `--auto-approve`.
- Structured mutations stay inside the active workspace boundary before they can
  run unattended.
- Explicit-path approval behavior remains unchanged for artifact-scoped tasks.

## [0.14.24] — 2026-05-17

Release validation and benchmark-methodology hardening.

### Added

- Benchmark evaluation methodology packets make runtime maturity checks easier
  to review and compare.

### Fixed

- The full-suite slow lane and release-smoke path are more stable before npm
  publication.
- Task-execution validation now has tighter coverage around command risk, native
  tool maturity boundaries, and release gates.

## [0.14.23] — 2026-05-15

Progress ledger hotfix.

### Fixed

- Successful TaskCreate, TaskUpdate, TaskVerify, RunWorkspace, and ArtifactVerify
  calls now refresh the long-task progress window, preventing false
  `task_no_progress` stops while structured execution is advancing.
- Bash artifact detection now recognizes env-expanded paths, brace-created
  directories, `mkdir`, `npm --prefix`, and `tee $VAR/file` progress evidence.
- Durable touched paths remain limited to explicit artifact outputs, while
  execution-only progress still keeps the watchdog honest.

## [0.14.22] — 2026-05-15

Runtime maturity and artifact verification release.

### Added

- Benchmark, workspace, route-preview, and verification tools make
  artifact-oriented tasks more observable.
- HTML deck verification packs flow through task verification so generated
  deliverables can carry structured checks.

### Changed

- Admin model setup, provider probing, runtime truth, and fresh-install version
  surfaces are tighter for npm users.

## [0.14.21] — 2026-05-15

Long-running stream and handoff contract precision.

### Fixed

- Native streaming requests no longer let the local client wall-clock timeout
  abort an already-active response body; first-token and idle stream watchdogs
  continue to own stalled-stream detection.
- Handoff-style deliverable filename lists and wrapped Markdown links are no
  longer inferred as cwd write targets unless the prompt explicitly declares an
  output location.

## [0.14.20] — 2026-05-15

Long-task gate precision.

### Fixed

- Suggested artifact filenames are no longer treated as authorized write scopes
  unless the prompt also declares a concrete output location.
- Production nudges avoid naming reference or input files as task write targets.
- Durable artifact tasks without a structured task plan now get a TaskCreate
  nudge before broad reading turns into a no-progress hard stop.

## [0.14.19] — 2026-05-15

Task Execution Mode integration.

### Added

- Structured task execution steps can now drive long-running native tasks
  through TaskCreate, task-step nudges, and deterministic TaskVerify checks.
- Task plans expose the next executable step so the conversation loop can nudge
  open task work without forcing chat-only review tasks into artifact mode.
- Agent runs now enforce artifact contracts, keeping declared outputs,
  verification, and progress evidence aligned.

### Fixed

- Read-only review and chat-shaped tasks are split from deliverable tasks before
  progress gates run, reducing false `task_no_progress` stops.
- Plugin dynamic imports now use file URLs, which keeps local plugin loading
  portable across path shapes.

## [0.14.18] — 2026-05-14

Long-task progress guard hardening.

### Added

- User-declared external deliverable paths can now become explicit task
  contract outputs when the prompt names them with output/save/write wording.
- Gate telemetry records structured events for production-gate, no-progress,
  tool-loop, and write-scope-block decisions.
- Contract confidence now lets low-confidence write-required inference fail
  open with telemetry instead of hard-stopping uncertain tasks.

### Fixed

- External reference paths stay separate from authorized write targets, so bash
  artifact detection cannot turn a cited input path into a writable output.
- File and directory scopes are preserved across the fs-policy bridge for
  user-declared external outputs.
- Bash artifact progress, exact failing-signature deduplication, and external
  deliverable tracking now work together without authorizing unrelated sibling
  paths.

## [0.14.17] — 2026-05-13

Progress-signal v2 for native runs.

### Added

- Bash and PowerShell artifact writes now count as run progress when they
  produce in-scope workspace outputs.
- Scratch artifacts outside the workspace refresh progress without being
  treated as final deliverables.

### Fixed

- Explicit write scopes now cover bash-produced workspace artifacts before
  dispatch, so shell writes cannot bypass the same guard used by write/edit
  tools.
- Repeated failing bash commands now use exact command signatures and are
  deduplicated at the call level without regressing the broader bash loop-guard
  behavior.

## [0.14.16] — 2026-05-13

Endpoint-first Admin onboarding and local runtime setup.

### Added

- Admin Add Model now groups cloud providers, local runtime presets, and custom
  endpoint channels as creation paths for the same endpoint-backed model
  configuration.
- Local runtime discovery surfaces common localhost presets such as Ollama, LM
  Studio, vLLM, and owlmlx when their default endpoints are reachable.
- Endpoint health samples and credential-source facts are shown in Admin model
  details after connection tests.

### Changed

- `owlcoda init --endpoint` is the canonical CLI setup flag. `--router` and
  `-r` remain supported as legacy aliases.
- Runs is no longer a product UI tab; old `#/runs` Admin links fall back to
  Start.
- Public install docs and product copy now describe local runtime endpoints and
  custom endpoints instead of teaching router-first setup.

### Fixed

- Admin display version is synced to the package version with a dedicated
  version-sync test.

## [0.14.15] — 2026-05-12

Provider onboarding and transcript rendering cleanup for the public npm path.

### Changed

- Admin cloud-provider setup is reduced to the supported brand presets:
  Kimi, DeepSeek, GLM, MiniMax, GPT, Claude, Gemini, Grok, plus explicit
  OpenAI-compatible and Anthropic-compatible custom channels.
- Saved cloud models now preserve provider/protocol hints and template headers,
  so a route that passes Admin dry-run testing keeps the same provider semantics
  when used from the CLI.
- Transcript output now renders structured status summaries and report-style
  key/value rows more consistently, while preserving inline-code literals.

### Fixed

- Bash tool output now strips cursor movement, erase, OSC title/link sequences,
  and carriage-return progress redraws before surfacing captured output. SGR
  color escapes are preserved.

## [0.14.14] — 2026-05-12

Admin version-truth hotfix for the public npm onboarding path.

### Fixed

- Admin header version now comes from the running daemon's `/admin/api/snapshot`
  response instead of a hardcoded frontend bundle constant. This prevents stale
  historical report versions from appearing as the current Admin/runtime
  version.
- The Runs tab now labels its `0.13.21` stress-report package value as a
  historical report package version, not as the current Admin package version.

## [0.14.13] — 2026-05-12

Admin bilingual UI polish for the public npm onboarding path.

### Added

- Admin now includes an English / 中文 language toggle in the header, persisted
  in browser localStorage without changing the active hash route.
- The first-run Start page, Models page, model drawer, add-model dialog, batch
  result surface, and advanced Alias / Orphans / Catalog shells now route common
  user-facing labels through a shared i18n layer. Technical identifiers such as
  model IDs, endpoints, provider payload text, and evidence/report strings stay
  literal.

### Changed

- The Admin bundle display version is aligned to 0.14.13 while retaining the
  0.14.12 first-run config isolation hotfix.

## [0.14.12] — 2026-05-12

Public npm first-run state isolation hotfix.

### Fixed

- A fresh global CLI no longer silently claims an orphaned OwlCoda daemon just
  because host, port, and router URL match. The daemon health identity now
  includes a config fingerprint, and orphan reuse requires that fingerprint to
  match. This prevents a new install from showing stale private models from an
  old proxy process with no PID file.
- The Admin `/models` overview now shows the active config source directly, so
  users can tell whether models came from their local profile instead of the npm
  package.
- Admin header version display is aligned to the package release line.

## [0.14.11] — 2026-05-12

Public npm install UX hotfix.

### Fixed

- `/models` and startup/config surfaces now identify the active local config
  file (`~/.owlcoda/config.json` by default) as a **local user profile, not a
  bundled npm default**. This makes it clear why an upgraded or freshly
  reinstalled global CLI on the same machine can still show previously
  configured private models.
- Browser Admin handoffs triggered from the `/models` picker now always print
  a copyable `Admin URL` even after OwlCoda asks the OS to open the browser.
  macOS `open` can report launch success even when the browser window does not
  visibly appear; the URL fallback is now always visible.

### Packaging hygiene

- The npm package manifest keeps the public package publishable and excludes
  `dist/**/*.map` from the published tarball.

## [0.14.10] — 2026-05-11

bash 从 loop-guard 的 signature-/intent-/window-based 检测中整体豁免。

### 触发事件

2026-05-11 aliyun 测试环境 smoke session（kimi-code 在 sieracMes-AI 项目跑
P0 Round 3-A）。模型在 ~24 轮 SSH 命令中两次撞上 soft loop intercept：
所有命令共享 `ssh -o ConnectTimeout=10 aliyun-sieracclaw '...'` 前缀，
`buildToolSignature` 把 bash 的目标截到前 80 字符，远端真正在执行的
docker compose / docker logs / curl / find 等不同命令塌成同一签名；
其中两次失败（compose timeout + env 未加载导致的 401）触发 `sameFailures
>= 2`，soft intercept 注入 `buildLoopInterceptPrompt` tool_result，模型
读到"Stop calling tools. Your next reply MUST be plain text..."后两次
都把执行模式切成 A/B/C 元叙事，把正在前进的诊断工作打断。

### 历史

bash signature 这条轴自 0.13.x 起反复调整：

- 0.13.34 加 multi-sig rotation + failureCategory 语义层
- 0.13.55 hard terminate 降级为 soft intercept
- 0.13.63 加 same-kind 3-strike + output-bloat nudge
- 0.13.66 / 0.13.67 / 0.13.68 narration-loop + no-progress hard ceiling
- 0.13.69 read_repetition_nudge_v1 引入 tool-result 侧信道 nudge
  （industry survey: Cursor 的硬停被用户要求关闭，SWE-agent 撤销了它的
  runtime detector，上游 Claude Code 没有等价机制）

每一次都是"抓得更狠 → 误判 → 再放松"的循环。0.14.10 之前 bash 一直留在
signature-based 检测里，等同于把"在 80 字符内能区分两条 bash 命令"作为
detector 的前提 —— wrapper-shaped 工具（ssh / kubectl exec / docker exec
/ heredoc）从设计上不满足这个前提。

### 改动

`detectToolLoop` 五个 signature-/intent-/window-based 分支
（A-B-A-B-A、sameSuccessful、sameFailures、sameIntentFailures、
multi-signature rotation、failure-rate fallback）全部在 `next.category
=== 'bash'` 时早返回。语义层 `failureClassCounts` 保留，因为它要求工具
自报 `failureCategory` —— 这是 opt-in 信号，bash 工具不上报时是 no-op，
不会误伤。

bash 的成本兜底交给本来就有的两层：单条命令 timeout（默认 120s / 上限
600s）+ turn budget。这跟 0.13.69 industry survey 引用的上游设计一致。

### 不再发生

- "Loop intercept (soft): repeated failing bash attempts on /Users/..."
  这条 tool_result 不会再进入模型上下文。
- 模型读到上一条后切到"A/B/C 你选哪个"元叙事的 failure mode 从机制上消除。
- multi-sig rotation / failure-rate guard 上加了 `sameCategory` 子集过
  滤，跨 tool 类别累计的 false positive 也一并堵了。

### 不保证

bash detector 在未来某种奇形 wrapper 上的"零误报"做不到 —— signature
本身是启发式。但 false-trip 的代价从"derail 一次会话"降到"不发生"，因
为这条路径整个被关掉了。

### 测试

- `tests/native/conversation-loop-guard.test.ts` 新增 ssh-wrapper 回归
  覆盖（混合通过/失败的 12 条 ssh 命令链跑到完成，不触发 intercept）。
- 现有 strict-hard "repeated failing bash attempts" 测试改名为 "does
  not stop"，验证新豁免。
- soft/hard intercept 测试（三条）从 bash 切到 Skill，仍覆盖 intercept
  机制本身。
- multi-sig rotation 与 failure-rate 测试的断言放宽 message format，跟
  上 sameCategory 分类后的 wording 变化。

108/108 native conversation suite 全绿。

## [0.14.9] — 2026-05-11

完整 timeout 链路审计的最后一处补漏：`/v1/chat/completions` endpoint。

### 0.14.8 之后做的全量 timeout 链路 grep 发现的盲区

用户 0.14.8 ship 后追问"这种问题反复出现，你就没有全面的去审视吗"。
做了一次完整 grep（`src/native/ src/endpoints/ src/middleware/` 三个目录的
`AbortSignal.timeout | setTimeout.*abort | requestTimeoutMs | routerTimeoutMs`），
找到 **10 层 timeout**，其中 **9 层** 0.14.1-0.14.8 已对齐到 adaptive，
**第 10 层** `src/endpoints/chat-completions.ts:60` 漏修——

```ts
// 0.14.8 之前一直是
signal: AbortSignal.timeout(config.routerTimeoutMs || 120000),
```

这个 endpoint 是 owlcoda 给非 Anthropic 客户端用的 OpenAI Chat
Completion 兼容入口（`POST /v1/chat/completions`），用 flat 120s
budget，**完全没经过 0.14.1 的 input-size aware 升级**。

### 影响

- `owlcoda` REPL 自家**不**走这条 endpoint（走 `/v1/messages`），所以
  用户实际场景没撞到
- 但如果有第三方 OpenAI-SDK-style 客户端用 owlcoda 当 proxy（`base_url
  = http://localhost:8019/v1`），long-context 请求会撞 0.14.0-之前那套
  老问题

### 修

跟 `/v1/messages` 同一 helper、同一公式：

```ts
import { computeAdaptiveTimeoutMs } from '../middleware/adaptive-timeout.js'

const baseTimeoutMs = config.middleware?.requestTimeoutMs ?? (config.routerTimeoutMs || 120_000)
const adaptiveBudget = computeAdaptiveTimeoutMs({
  baseMs: baseTimeoutMs,
  body: body as { system?: unknown; messages?: unknown },
  middleware: config.middleware,
})
// ...
signal: AbortSignal.timeout(adaptiveBudget.timeoutMs),
```

OpenAI body shape（`messages[*].content` 是 string 或 text-block 数组）
直接被 `estimateInputChars` 已有的 walk 逻辑处理，**无需特殊 case**。
adaptive helper 同样按 input tokens scale 这条 endpoint 的 budget。

### Lesson 落 memory

新增 memory entry `feedback_timeout_chain_full_audit_first.md`：

> 多层 timeout 故障**先做完整链路审计，别在症状端打补丁**。
> 0.14.1-0.14.7 全在 proxy 端打了 7 个 release，结果 root cause 在
> REPL 端 hardcode 180s（0.14.8 修）；audit log 三角对账（proxy duration
> vs REPL displayed elapsed vs audit fallbackUsed）30 秒能定位的事，
> 不应该等到第 7 个 release 才查。

完整 10 层 timeout 清单写进 memory body，下次任何 timeout-shape 报告
直接对照。

### 0.14.x 治理弧线最终收口

```
0.14.1: adaptive budget (60s/10K)          [proxy 外层]
0.14.2: streaming → non-streaming fallback  [proxy streaming 入口]
0.14.3: extension 60s → 90s                [proxy 外层]
0.14.4: non-streaming → streaming fallback  [proxy 非流式入口 path ①]
0.14.5: 同 fallback for path ②             [proxy withFallback catch]
0.14.6: audit shape bugfix                 [proxy audit]
0.14.7: protocol-aware OpenAI Chat fallback [proxy + translate layer]
0.14.8: REPL adaptive client-side timeout   [REPL 端]  ← 真 root cause
0.14.9: chat-completions endpoint adaptive  [proxy 第二入口]  ← 审计盲区补漏
```

9 个 release 之后 timeout 链路上 10 层全部对齐到 adaptive 公式。

---

## [0.14.8] — 2026-05-11

**0.14.x timeout 治理弧线的真 root cause**：REPL 客户端 timeout 跟
proxy 端不对齐。

### 用户实测 0.14.7 又失败的诊断

用户 0.14.7 跑 deepseek-v4-pro 10 iter session，**第 5 iter 显示 error
wording**，但 audit log 实锤完全相反：

```jsonc
{
  "requestId": "09382e83-...",     // 跟用户截图错误的 requestId 完全一致
  "model": "deepseek-v4-pro",
  "inputTokens": 75007,
  "outputTokens": 7430,            // proxy 实际产出 7.4K tokens
  "durationMs": 259144,             // proxy 跑了 259s
  "status": 200,                    // HTTP 200 SUCCESS
  "streaming": true,
  "fallbackUsed": false             // 没走 fallback —— 因为 proxy 本身没失败
}
```

proxy 完整成功送出 7430 output tokens / 259s / status 200，**但 REPL
端 180s 时把 fetch 砍了**，然后 REPL display 套用 `kind: 'timeout'` 的
"non-streaming server budget" wording（讽刺：这条 wording 对应的是非
流式预算，但此请求实际是流式的，wording 套错也只能套错——REPL 那条
classify 路径对所有 AbortError 都归一为 `kind: 'timeout'`）。

### Root cause

`src/native/conversation.ts:2517` 和 `:2680`：

```ts
// 0.14.7 之前
const requestTimeoutMs = opts.requestTimeoutMs ?? 180_000 // 3 minutes default
```

REPL 写死 180s 默认 client-side timeout。0.14.1-0.14.7 我做的所有 7
个 release 都在 **proxy** 端调 budget（adaptive 60→90s/10K、cap 600s、
path ① / path ② / OpenAI fallback、SSE 合成、audit shape 修复），
**但 REPL 端的 180s 客户端 timer 从来没动过**。

结果：proxy budget 拉到 600s 也没用——REPL 在 180s 就先 abort 了
fetch 连接，给用户看 error，与此同时 proxy 还在认真跑，直到自然完成
并写 status:200 audit。两端各说各话。

### 修法 (`src/native/conversation.ts`)

REPL 复用 proxy 端的 `computeAdaptiveTimeoutMs` helper，但加 30s 安全
margin 让 proxy 始终先于 REPL 触发：

```ts
// 0.14.8
import { computeAdaptiveTimeoutMs } from '../middleware/adaptive-timeout.js'

const baseBudgetMs = opts.requestTimeoutMs ?? 180_000
const adaptiveBudget = computeAdaptiveTimeoutMs({
  baseMs: baseBudgetMs,
  body: request as { system?: unknown; messages?: unknown },
  middleware: undefined,
})
const requestTimeoutMs = adaptiveBudget.timeoutMs + 30_000
```

两个 callsite (sendRequest:2517, fetchNonStreamingResponse:2680) 同一
patch。

### 新 REPL budget 表

| input tokens | proxy adaptive (0.14.7) | REPL adaptive + 30s margin (0.14.8) | 谁先 fire |
|---|---|---|---|
| 5K | 120s | 210s | proxy ✓ |
| 17K (你之前的) | 210s | 240s | proxy ✓ |
| 33.7K (33K 评审 case) | 390s | 420s | proxy ✓ |
| 50K | 570s | 600s | proxy ✓ |
| 75K (本次失败 case) | 600s cap | 630s | proxy ✓ |
| 100K+ | 600s cap | 630s | proxy ✓ |

任何 input size 下，REPL ceiling 永远比 proxy 慢 30s，proxy 触发的
timeout / fallback / 503 都能正常 propagate 回 REPL 才到 REPL 自己的
timer。

### 你 75K-deepseek 那条现在的处理

| 阶段 | 0.14.7 行为 | 0.14.8 行为 |
|---|---|---|
| T+0 | REPL 发请求，挂 **180s** AbortSignal | REPL 挂 **630s** AbortSignal |
| T+0..259 | proxy 流式接收 chunks（部分送到 REPL） | proxy 流式接收 chunks（全部送到 REPL） |
| **T+180** | **REPL abort → 显示 error wording** | REPL 仍在监听 |
| T+259 | proxy 完成 status:200 audit（REPL 已断开） | REPL 完整接收 7430 tokens，正常 transcript |

### 与之前 release 的关系

| Release | 修了什么 |
|---|---|
| 0.14.1 | proxy adaptive budget input-size aware（外层 wall-clock） |
| 0.14.2 | proxy streaming → non-streaming fallback |
| 0.14.3 | extension 60s → 90s per 10K |
| 0.14.4 | proxy non-streaming → streaming retry (path ①) |
| 0.14.5 | 同 fallback for path ② (per-route timeout) |
| 0.14.6 | audit shape bug fix |
| 0.14.7 | protocol-aware fallback (OpenAI Chat Completion) |
| **0.14.8** | **REPL client-side timeout 与 proxy adaptive 对齐** ← **真 root cause** |

0.14.1-0.14.7 都在 proxy 端绕，本质上是因为 root cause 在 REPL 端，
但没有 audit log 数据时分不清是哪边的事。这次 audit log 一查就实锤
了。

### 测试

`tests/native/conversation.test.ts` + `conversation-loop-guard.test.ts`
+ `adaptive-timeout.test.ts` 126/126 全绿。

### 已知边界

- `opts.requestTimeoutMs` 现在的语义变成 **base budget**（被 adaptive
  scale），不是绝对 ceiling。`slash-commands.ts:2538` 那条
  `requestTimeoutMs: 60_000` 用于 side-question 的会被 adaptive 提到
  `60+90s/10K + 30s` 上限——但 side question 量小（<10K input），实际
  budget 仍是 90s。不影响 UX。
- legacy callers 没传 `opts.requestTimeoutMs` 的，base 180s 会按 input
  size 扩——pre-0.14.8 callers 体验**只会变好**，不会变坏

---

## [0.14.7] — 2026-05-11

P0-4.3：fallback **protocol-aware** —— 修复 0.14.4/0.14.5 在
OpenAI-Chat 中继路由（DeepSeek / Kimi / Qwen / Moonshot 全家桶）上
**双重失效**的 bug。

### 用户实测 0.14.5 失败的真因

用户截图显示 0.14.5 `25ebbb9-dirty` 跑 deepseek-v4-pro，6 iterations、
77K total tokens、241.3s elapsed，**仍命中 "non-streaming server
budget" recovery wording**。

诊断 (`jq` ~/.owlcoda/config.json)：

```json
{
  "id": "deepseek-v4-pro",
  "endpoint": "https://api.deepseek.com/v1",
  "apiKey": "..."
}
```

DeepSeek 官方 `/v1` API 是 OpenAI Chat Completion 完全兼容。
所以 owlcoda 内部 `modelRoute.translate=true`。0.14.4/0.14.5 我的
`fetchStreamingFallback` 直接 fetch 上游 endpoint，**双重错位**：

1. **body 形状错** —— `buildStreamingRetryBody` 输出的是 Anthropic
   body（`{messages, system, max_tokens, ...}`），但 POST 到了
   `/chat/completions` 期望的是 OpenAI body
   （`{messages, max_completion_tokens, ...}`）
2. **response 解析错** —— 上游返 OpenAI delta SSE
   （`{choices:[{delta:{content:"..."}}]}`），但 `assembleJsonFromSseStream`
   只认 Anthropic 事件（`message_start` / `content_block_delta` 等）
   → 整流走完返 null → caller fall through → 504 wording

graceful-degrade（没恶化），但 fallback **完全没救到任何 OpenAI 路由**。

### 修法

`fetchStreamingFallback` 签名重构为接 `FallbackRouteInfo` 一个对象，
内部按 `route.translate` 分派：

```ts
interface FallbackRouteInfo {
  endpointUrl: string
  headers: Record<string, string>
  translate: boolean           // ← 新
  backendModel: string         // ← 新
}
```

- **body 双协议**：
  - `translate=true` → 走 `translateRequest(body, backendModel)`
    （和 `handleMessagesNonStream` 行 342-347 同一 helper，保证
    fallback 重发的 body 跟原 non-streaming 请求 wire-format 一致）
  - `translate=false` → `{ ...body, model: backendModel }`
- **response 双协议**：
  - `translate=true` → 调用新加的 `assembleJsonFromOpenAiSseStream`
  - `translate=false` → 原 `assembleJsonFromSseStream`（Anthropic 路径）

### `assembleJsonFromOpenAiSseStream` 新增

复用 owlcoda 既有 `StreamTranslator` (`src/translate/stream.ts`)——
**同一个组件 `handleMessagesStream` 现场转发时用**，OpenAI SSE 直接
进 translator，输出 Anthropic SSE 帧，我的 assembler 读 Anthropic 帧。

零新模块、零新算法、零新协议假设。

### Refactoring: 共享 assembler 核心

把 `assembleJsonFromSseStream` 的核心循环抽出为 internal
`assembleJsonFromEventPayloads(payloads: AsyncIterable<string>)`，让
两条入口（Anthropic 直收、OpenAI 经翻译后）共用同一份事件→JSON 合成
逻辑。两个 public 函数都返回相同 `AnthropicMessagesResponse | null`。

### Strict completion mode（重要 bug 防护）

OpenAI 路径写完发现一个**潜在 corruption bug**：StreamTranslator 的
`flush()` 在被调用时**无条件**发 `message_stop`。如果 source SSE
stream 在收到 `[DONE]` 之前 EOF（网络断、上游 kill 等），原 0.14.7
draft 会无脑调用 `flush()` → synthesizer 拿到 message_stop → 把残缺
内容当成成功响应返回 client。

**修复**：在 OpenAI adapter 内部 track 是否真正看到 terminator——
要么 `[DONE]`、要么 chunk 携 `finish_reason: string`。两者都没看到
就**不调 flush()** → assembler 因为缺 message_stop 返回 null → caller
走原 504 wording 路径（正确）。

### 测试覆盖

`tests/middleware/non-stream-fallback.test.ts` 新增 4 case：

- text-only OpenAI delta 累加 + finish_reason: stop + [DONE]
- thinking-content (kimi/moonshot `reasoning_content` extension) →
  Anthropic thinking 块
- tool_calls (function-calling delta) 累加 → Anthropic tool_use 块
- 流提前关 (no [DONE], no finish_reason) → 返回 null

加上原 Anthropic 路径 18 case，总 22 case 全绿。

### messages.ts 两个 callsite 同步更新

Path ① (`handleMessagesInner` outer adaptive catch) 和 Path ②
(`handleMessagesNonStream` withFallback catch) 都改成传新的
`FallbackRouteInfo` 对象（含 `translate` 和 `backendModel`）：

```ts
await fetchStreamingFallback({
  endpointUrl: failedRoute.endpointUrl,
  headers: failedRoute.headers,
  translate: Boolean(failedRoute.translate),
  backendModel: failedRoute.backendModel,
}, ...)
```

### 你 deepseek-v4-pro 那条现在的处理

| 阶段 | 行为 |
|---|---|
| iter 1-5 | normal pass, cumulative ~200s |
| iter 6 启动 | input ~30-50K，触发 deepseek 上游自家非流式 budget (~80s) |
| upstream returns timeout error | proxy 收到 504，分类 `kind: 'timeout'` |
| path ② fallback fire (0.14.5) | 进入 fetchStreamingFallback |
| **0.14.7 protocol detection** | route.translate=true → 进入 OpenAI 路径 |
| body 翻译为 OpenAI 形状 | `translateRequest(body, "deepseek-v4-pro")` |
| POST `/chat/completions` with stream:true | upstream 接受，返 OpenAI SSE |
| StreamTranslator 在 fly 翻译 | OpenAI delta → Anthropic event payloads |
| `assembleJsonFromOpenAiSseStream` 合成 | 等到 `[DONE]` 或 finish_reason: stop |
| 200 + JSON 写回 client | `x-owlcoda-fallback-source: streaming-assembled-innercatch` |
| `logAuditEntry` SUCCESS shape | (0.14.6 修过)，正常审计 |

### 已知限制 / 边界

1. **Strict completion**：upstream 必须发 [DONE] 或 finish_reason。
   不发就 null。绝大部分上游遵守，只有部分本地 MLX runtime 偶发
   不发 [DONE] —— 但 MLX 一般不是 translate=true 路由，走 Anthropic
   原生路径不受影响
2. **tool_use input 流式 partial_json**：StreamTranslator 处理
   `tool_calls[*].function.arguments` 拼接，到 finish_reason 时
   一次性写入。my assembler 在 content_block_stop 才 parse JSON，
   如果 arguments JSON 截断了拼不完整 → input 退化为 `{}`（容错）
3. **kimi `reasoning_content` extension**：被 translator 转 thinking
   block；如果上游 thinking 内容很长（>10K），message_start 的
   `inputTokenEstimate`（我从 body 估的 chars/4）可能偏低，但 message_delta
   会带 upstream 真实 usage 覆盖，不影响最终 `out.usage`

### 信任度

- ✅ Unit tests：22 case 覆盖 text/thinking/tool_use × 完整/截断 ×
  Anthropic/OpenAI 协议
- ✅ Type-check + 全量 test suite：4713/4714 (1 pre-existing headless
  WIP fail，与本 slice 无关)
- ⚠️ **未做** end-to-end smoke 测试（mock OpenAI-protocol server +
  非流式触发 timeout + 验证 fallback 走 OpenAI 路径）—— 实测验证留给
  你用真实 deepseek-v4-pro 跑 MES-AI 评审

---

## [0.14.6] — 2026-05-11

P0-4.5 bugfix：0.14.5 path-② fallback 成功后**误打 failure audit**。

### Bug

0.14.5 在 path-② catch 块的 fallback 成功分支调用了 `logFailureAuditEntry`，
本意是写入审计但 **shape 错了**——这条记录会把 `kind: 'timeout'` 写进
failure 字段，但客户端实际收到的是 200 + assembled JSON（成功）。审计
日志会自相矛盾：duration / fallbackUsed 显示成功，failure.kind 显示
timeout。

### 修

照 `messages.ts:533` 正常 success-path 的 audit shape 改用 `logAuditEntry`：

```ts
logAuditEntry({
  timestamp: new Date().toISOString(),
  requestId,
  model: body.model,
  servedBy: failedModelId,
  inputTokens: assembled.usage?.input_tokens ?? 0,
  outputTokens: assembled.usage?.output_tokens ?? 0,
  durationMs: traceResultOk.totalMs,
  status: 200,
  fallbackUsed: true,
  streaming: false,
}).catch(e => logWarn('audit', `Failed to log audit entry: ${e}`))
```

### 已知限制（P0-4.3 留作下一 slice）

**0.14.4/0.14.5 fallback 对 OpenAI-protocol 路由是 graceful-degrade 但
无效**。

具体路径：
- `fetchStreamingFallback` 直接 fetch 上游 endpoint，不走 owlcoda
  proxy 自己的翻译层
- 路由如果是 `translate: true`（多数云中继 like kimi/deepseek
  Chat Completions 协议），上游返回 **OpenAI delta SSE 事件**
  （`{choices:[{delta:{content:"x"}}]}`），不是 Anthropic 事件
- `assembleJsonFromSseStream` 只认 Anthropic 事件（`message_start`/
  `content_block_delta` etc），看到 OpenAI 事件 → 返回 null
- caller 看到 null → 继续走原 504 + recovery wording 路径

**用户体验：** 同 0.14.3，没有恶化，但 0.14.4/0.14.5 fallback 不顶用。

**为什么不在这条 release 修：** 不知道用户 kimi/deepseek 路由是 OpenAI
还是 Anthropic 协议。如果他们配的是 Anthropic-native 路由，0.14.4/0.14.5
已经工作；如果是 OpenAI-translated，需要 P0-4.3 加 protocol-aware fallback
（pipe through `StreamTranslator` 或 loopback 到 owlcoda 自己的 streaming
endpoint）。这是新模块级 surgery，应在 user direction 下做。

### Loop pause point

5 release ship 完毕：

- 0.14.1: adaptive budget (input-token scaled, 60s/10K)
- 0.14.2: streaming → non-streaming fallback (path ① of streaming)
- 0.14.3: extension 60s → 90s per 10K
- 0.14.4: non-streaming → streaming fallback (path ① of non-streaming)
- 0.14.5: same fallback for path ② (per-route / upstream-own timeout)
- 0.14.6: audit-log shape bugfix

下一 slice 待 user-direction（验证 0.14.5/0.14.6 是否解决 deepseek 那条；
或确认路由协议，启动 P0-4.3）。

---

## [0.14.5] — 2026-05-11

P0-4.2 补漏：把 P0-4 streaming fallback 延伸到第二条 timeout 起火路径。

### 0.14.4 留的洞

P0-4 (0.14.4) 的 fallback hook 只装在**外层 adaptive wall-clock**（
`messages.ts:178` `timeoutController`）的 catch 里。但 owlcoda proxy
有两条独立的 non-streaming timeout 起火路径：

| 路径 | 触发位 | 0.14.4 是否接 |
|---|---|---|
| ① 外层 adaptive wall-clock | `messages.ts:178` `timeoutController` | ✅ |
| ② 内层 per-route `AbortSignal.timeout` / 上游 504 / 上游自家非流式 budget | `messages.ts:340-356` `withFallback` catch | ❌ **0.14.4 未接** |

用户 deepseek-v4-pro 8-iteration 评审 session 命中路径 ②：cumulative
elapsed 262.4s（远低于 0.14.3 adaptive 390-570s budget），但**上游
deepseek API 自家有更短的非流式 budget**，返回 timeout 错误后 owlcoda
proxy `classifyEndpointRequestFailure` 标 `kind: 'timeout'`，原 0.14.4
fallback 完全没启动，直接 504 + recovery wording。

### 修法

在 `messages.ts:356` 的 `withFallback` catch 块里，**diagnostic 分类后、
504 writeHead 前**，加同一个 fallback gate：

```ts
if (!res.headersSent && diagnostic.kind === 'timeout' && isNonStreamFallbackEnabled(...)) {
  const localAdaptive = computeAdaptiveTimeoutMs({ baseMs: requestTimeoutMs ?? 120s, body, middleware })
  const fallbackBudgetMs = Math.min(localAdaptive.timeoutMs * 2, cap * 2)
  const assembled = await fetchStreamingFallback(failedRoute.endpointUrl, failedRoute.headers, buildStreamingRetryBody(body), signal, fallbackBudgetMs)
  if (assembled) {
    res.setHeader('x-owlcoda-fallback-source', 'streaming-assembled-innercatch')
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(assembled))
    return
  }
}
```

trace marker 用 `nonstream_fallback_*_innercatch` 区别于路径 ① 的
`nonstream_fallback_*`，便于排障分清是哪条路径起火。

### 复用既有零件

复用 0.14.4 的：
- `isNonStreamFallbackEnabled(middleware)` —— 同一 config 闸（`middleware.nonStreamFallbackToStreamingEnabled`）
- `buildStreamingRetryBody(body)` —— 同一 `{ ...body, stream: true }`
- `fetchStreamingFallback(...)` —— 同一 SSE→JSON 合成路径
- `computeAdaptiveTimeoutMs(...)` —— 同一 budget 公式

**0 新模块、0 新 export、0 新 config 字段**。纯粹是"另一只眼睛在同一只
房间里"。

### 测试

`tests/messages-integration.test.ts` 的 `returns error when upstream
hangs beyond per-model timeout` 用例之前默认 mock 永远不响应。0.14.4 的
`streamFallbackToNonStreamingEnabled: false` 已经在测试 fixture 里关
streaming→non-streaming 方向；0.14.5 在同一 fixture 加
`nonStreamFallbackToStreamingEnabled: false` 关 non-streaming→streaming
方向，保留该用例验证 wording-emission 路径的原意。

无新增 unit test —— `non-stream-fallback.test.ts` 的 18 case 已覆盖
assembler 全部分支，路径 ② 只是触发点不同，可复用同一 contract。

### deepseek-v4-pro 这条现在的处理

| 阶段 | 行为 |
|---|---|
| iter 1-7 | normal pass，cumulative ~180s |
| iter 8 启动 | 上游 deepseek 内部非流式 budget 起火（~80s） |
| upstream returns timeout error | proxy 收到，分类 `kind: 'timeout'` |
| 0.14.5: 进入 path ② fallback | 用 `stream: true` 重发 deepseek，最多再跑 2× adaptive budget |
| SSE chunks 进 assembler | 与 0.14.4 同一路径 |
| assembled success | 200 + `x-owlcoda-fallback-source: streaming-assembled-innercatch` |

只要 deepseek 自家**streaming 路径不存在同样短的 budget**，这条几乎一定救回来。
（部分云提供商的 streaming budget 远比非流式宽松，因为 streaming 不需要
他们自家 server 内存里缓住完整 response。）

---

## [0.14.4] — 2026-05-11

P0-4 阻塞深度修：non-streaming wall-clock timeout 时**反向 fallback 到
streaming**，消费 SSE 事件流，把结果合成回 JSON 写给 client。0.14.2 (P0-2)
的对称项，但**这条才是治本**。

### 真实修法逻辑

诊断 (0.14.3 时找到)：

`src/native/conversation.ts:2692` 把 native REPL 每条 turn 都 hardcode 成
`stream: false`。所以无论 0.14.1/0.14.2/0.14.3 在 proxy 层怎么调 budget，
REPL 这条流量永远撞 non-streaming wall-clock。模型对 33K 输入真要 ≥400s
出 final JSON 的话，扩 budget 永远追不上模型实际耗时。

streaming 路径不存在这个问题——chunks 一到就传给 client，per-chunk idle
timer 才是上限（reset on each chunk）。所以治本是把 non-streaming 转成
streaming 跑。

**不动 REPL 代码**（streaming path 历史上猜过 bug，kimi-code 700K context
OOM / `write({})` 8x 重复），改在 proxy 层做透明转换：

```
client sends   stream:false
       ↓
proxy hits     wall-clock budget (0.14.3 已扩到 390s for 33K)
       ↓
0.14.4:        retry upstream with stream:true
               consume SSE events
               assemble back to AnthropicMessagesResponse JSON
       ↓
proxy writes   200 + Content-Type:application/json (像非流式那样)
       ↓
client sees    normal non-streaming response (高延迟但成功)
```

### `src/middleware/non-stream-fallback.ts` 新文件 (~270 行)

- `isNonStreamFallbackEnabled(middleware)` — 默认 ON，`nonStreamFallbackToStreamingEnabled = false` 关
- `buildStreamingRetryBody(body)` — `{ ...body, stream: true }`，幂等覆盖原 stream 字段
- `assembleJsonFromSseStream(stream)` —— **load-bearing**。走 `parseSSEStream` 一帧一帧建结果：
  - `message_start` → 提取 id / model / role / usage
  - `content_block_start` × index → 初始化 block accumulator（text/thinking/tool_use）
  - `content_block_delta` 累加：
    - `text_delta.text` → block.text 拼接
    - `thinking_delta.thinking` → block.thinking 拼接
    - `signature_delta.signature` → block.signature 拼接
    - `input_json_delta.partial_json` → block._partialJson 拼接（loop 结束统一 JSON.parse）
  - `content_block_stop` → 落地（tool_use input parse 在 finalize 阶段，容错：parse 失败回空 `{}`）
  - `message_delta` → stop_reason / output_tokens
  - `message_stop` → 终止合成
  - `error` 事件 → 返回 `null` 让 caller 走原 timeout 路径
  - 未知事件（ping 等）→ ignore
  - 流提前关 / 无 message_stop → `null`（不返回半成品）
  - 缺 model 字段 → `null`（load-bearing，宁缺勿假）
- `fetchStreamingFallback(endpointUrl, headers, retryBody, signal, budgetMs)` — 单次 best-effort，自管 AbortController + AbortSignal.any，accept: text/event-stream

### 接入 `src/endpoints/messages.ts`

`handleMessagesInner` 的 catch 块（`timeoutController.signal.aborted` 分支）
里插入 fallback 尝试，**在 diagnostic 文案 emit 之前**：

```ts
if (!res.headersSent && isNonStreamFallbackEnabled(config.middleware)) {
  const fallbackRoute = resolveModelRoute(config, body.model)
  const fallbackBudgetMs = Math.min(timeoutMs * 2, (cap) * 2)  // 双倍预算
  const assembled = await fetchStreamingFallback(...)
  if (assembled) {
    res.setHeader('x-owlcoda-fallback-source', 'streaming-assembled')
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(assembled))
    return
  }
}
// 否则继续原 504 + recovery wording
```

### 你那条 33K MES-AI 评审现在的处理

| 阶段 | 行为 |
|---|---|
| 0-390s | 0.14.3 adaptive non-streaming budget (120 + 3×90) |
| 390s | wall-clock fire → 0.14.4 fallback 启动 |
| 390s+ | upstream 用 `stream:true` 重试，最多再跑 780s（双倍 budget） |
| chunks 进 SSE | assembler 累 deltas |
| upstream 发 message_stop | assembler 返回完整 JSON |
| client 收到 | 200 + `x-owlcoda-fallback-source: streaming-assembled` |

只要 streaming 路径 model 不彻底 OOM，**这条肯定救得回来**。

### config surface

```jsonc
{
  "middleware": {
    "nonStreamFallbackToStreamingEnabled": true  // 默认 true，关掉回 0.14.3 行为
  }
}
```

validator 加 checkBoolean，watcher 通过整 `middleware` blob 热重载。

### 测试

`tests/middleware/non-stream-fallback.test.ts` 18 case：

- assembler text-only path (33K user case 直接对应)
- thinking + signature delta 累加
- tool_use input_json_delta 累加 + parse + malformed 容错
- 多 block 索引顺序
- 失败模式：stream closed before message_stop / error event mid-stream / model 缺失 / id 缺失合成
- usage propagation（input + output + cache tokens）
- ping / unknown event 忽略
- `isNonStreamFallbackEnabled` 默认 + 显式开关
- `buildStreamingRetryBody` 字段保真 + 幂等覆盖

### 设计约束（locked）

- **不**做 route-config knob 层（per-route 关闭 streaming fallback）。中间件全局闸即可
- **不**对已经 stream:true 的 request 走这条（那是 0.14.2 的领域）
- **不**多 shot retry。assembler 返 null → 直接走原 timeout 文案
- **不**返回半成品 JSON。message_stop 未到就 null

### 与 OwlCC 上游对比

OwlCC 的 `claude.ts:2334` 是反方向 fallback（streaming idle → 非流式重试），
我们 0.14.2 已抄过。**这个 0.14.4 方向上游没有对应实现** —— OwlCC native
REPL 默认就走 streaming，所以不需要从 non-stream 反向救。owlcoda 因为
native REPL 历史走 non-stream，反方向 fallback 是 owlcoda 独有需要。

---

## [0.14.3] — 2026-05-11

P0-3 hotfix: bump default adaptive extension 60s/10K → 90s/10K.

用户 0.14.1 真实 failure：33.7K-token MES-AI 架构评审请求在
**精确 300.5s 撞 timeout**，模型在 background 仍在产出（截图里能看到
报告大部分已经合成），但 0.14.1 adaptive budget = 120 + floor(33.7/10) ×
60 = 300s 恰好掐在临界，无 headroom。

0.14.2 fallback 救不了这条——recovery 文案明确写 "non-streaming server
budget"，是非流式 wall-clock 触发；0.14.2 fallback 只在流式 first-token
watchdog 触发时启动。

### 修法

`DEFAULT_ADAPTIVE_EXTENSION_PER_10K_MS = 60_000` → `90_000`。

新 budget 表：

| input tokens | 0.14.1 / 0.14.2 budget | 0.14.3 budget |
|---|---|---|
| 5K | 120s | 120s |
| 17K | 180s | 210s |
| 33.7K（本次失败点）| **300s** ✗ | **390s** |
| 50K | 420s | 570s |
| 53K+ | → cap | → cap |
| ≥66K | 600s cap | 600s cap |

`tests/middleware/adaptive-timeout.test.ts` 加 1 case 锁 33.7K → 390s
回归（防止未来误把 90s 改回去）。已有 case 中 17K / 30K / 50K 的预期
budget 同步更新。

### Loop queue 还在的项

- **P0-4 (next loop)**：non-streaming wall-clock timeout 触发时反向 fallback
  到 streaming 重试一次（P0-2 的反向）。即使 budget 再宽，模型真要 30
  分钟跑也兜不住；streaming 能边产边送，避开 wall-clock 总额。
- **P0-3.b**：用户的 kimi-code 路由当前走的是**非流式**——重新检查模型
  路由配置 / REPL 客户端是不是 send `stream: true`，如果路由层面强制非
  流式那需要单独修

### 用户使用提醒

升级到 0.14.3 后 **必须重启 owlcoda REPL** 才能生效。Banner 显示的版本
是进程启动时锁的；`/quit` 后再 `owlcoda` 才会拿到新 binary。

---

## [0.14.2] — 2026-05-11

P0-2 阻塞防御深度：流式 first-token watchdog fire 时（0 chunk 到达），
**单次自动 fallback 到非流式**，成功就合成 Anthropic SSE 事件序列写回
已开的 SSE 通道。用户看到的是"高 first-token 延迟的正常流"，不是
recovery wording。

设计动机来自 OwlCC 上游 `claude.ts:1934` 的 streaming→non-stream
fallback（我们之前不抄是因为 OwlCC 那条缠了 tengu auth/feature 闸；
这次只抄触发点 + 取消条件，不抄上游协议）。

### `src/middleware/stream-fallback.ts` 新文件 (~190 行)

- `isFallbackEnabled(middleware)` — 默认 ON，`streamFallbackToNonStreamingEnabled=false` 关
- `buildNonStreamingRetryBody(body)` — drop `stream` flag 后浅拷贝，保留 model / messages / max_tokens / temperature / tools / system / etc 全部其他字段
- `fetchNonStreamingFallback(endpointUrl, headers, retryBody, signal, timeoutMs)` — 单次 best-effort fetch，AbortController 自管 timeout，combined signal 串入原 request signal（用户 Ctrl+C 仍能砍掉 fallback）。失败返回 `null` 让 caller 走原 wording 路径
- `synthesizeSseFromResponse(res, json)` — 把非流式 JSON 合成 6 段标准 Anthropic SSE 序列：
  - `message_start` — id / model / role / input usage
  - 每个 content block：`content_block_start` + `content_block_delta` (text_delta | thinking_delta | input_json_delta) + `content_block_stop`
  - `message_delta` — stop_reason + output_tokens
  - `message_stop`

  支持 text / thinking / tool_use 三种 block 类型（tool_use 走 `input_json_delta` with full `JSON.stringify(input)`）。未知 block 类型静默跳过

### 接入 `src/endpoints/messages.ts`（catch block 新增 ~50 行）

触发条件：

```ts
const fallbackEligible = isFallbackEnabled(mwForStream) &&
  (firstTokenWatchdogFired || (isPerChunkIdle && !streamPartialOutputSeen))
```

—— 流式 first-token watchdog 触发 OR 流式 per-chunk idle 触发但 0 chunk 到过。Partial output 已经流出去的情况**不 fallback**（client 已经在累积内容，二次 fetch 会重复输出，引入 corruption）。

Fallback 预算：`computeAdaptiveTimeoutMs({ baseMs: requestTimeoutMs ?? 120s, body, middleware })`——和 0.14.1 非流式入口同一公式，17K input 给 180s。

Fallback 成功：写 SSE 序列 + `addTokenUsage(input, output)` 记账 + trace marker `stream_fallback_ok`。**不调** `recordFailure` —— 从客户端看是成功的（高延迟）流。

Fallback 失败：trace marker `stream_fallback_fail` + 走原 recovery wording 路径，行为同 0.14.1。

### config surface

`MiddlewareConfig.streamFallbackToNonStreamingEnabled?: boolean`（默认 true）。validator 加 `checkBoolean` 项。watcher 通过整 `middleware` blob 重载，自动 hot-reload。

### 测试

`tests/middleware/stream-fallback.test.ts` 18 case 锁 `synthesizeSseFromResponse`：

- 6-event canonical sequence（text-only path、user 17K case）
- 多 block 索引正确
- thinking block → thinking_delta
- tool_use block → input_json_delta with full JSON
- empty content → 仅 start/delta/stop
- missing usage 容错
- unknown block 静默跳过
- cache_creation / cache_read tokens 透传

`isFallbackEnabled` × 4，`buildNonStreamingRetryBody` × 2。

### Regression 修复

`tests/messages-stream-timeout.test.ts` 和 `tests/messages-integration.test.ts` 旧用例期望"first-token watchdog → 直接 recovery wording"，新 fallback 默认 ON 会**先**尝试 fallback（再次打同一个不响应的 mock，撞到 wall-clock 超时）。两份测试 fixture 默认 disable fallback；fallback 成功路径的覆盖在新文件单测里。

### 未做的事

- **不做**全量 fallback chain across model id（OwlCC 那部分牵动 circuit breaker + 健康检查 + retry middleware，单 slice 控制不住）。当前 fallback 是**同 route 单次重试**
- **不做** partial output 已流出后的 stream 继续（需要 prompt prefix continuation，prompt 不一定支持）
- **不做** OwlCC `SystemAPIErrorMessage` 30s 心跳

### 用户那次 kimi-code 截图 timeout

0.14.1 已经把 17K 非流式入口 budget 拉到 180s（直接命中）。0.14.2 是补上 **流式路径下的同症状**：如果用户用的是 streaming route 而 first-token 没在扩展后的 150s 内到达，不再立刻吐 recovery wording，而是 fallback 到非流式 180s budget 再试一次。两条路径的兜底现在都覆盖。

---

## [0.14.1] — 2026-05-11

P0 阻塞修复：长上下文 non-streaming 请求被 flat 120s budget 砍掉的问题。
用户跑 17.3K-token 输入的 kimi-code 非流式请求，163s 才出 259 输出 token，
但 budget 120s 已经把请求 abort 掉了。错不在 wording / fallback / retry
层，错在**budget 不感知 input size**——同一条 120s 桶给 1K 输入和 100K
输入用，明显不合理。

### `src/middleware/adaptive-timeout.ts` 新文件 (~180 行)

输入端 token 估算 + 线性扩展 + cap。**只调整 input-side budget**，不动
output-side（per-chunk idle + first-token watchdog 之外的层）。

- `estimateInputChars(body)` —— 走标准 Anthropic body shape（system / messages.content / tool_use.input / tool_result.content），可处理 string、text-block、tool_use、tool_result × string|array 四种内容形态，未知字段安全忽略
- `estimateInputTokens(body)` = `ceil(chars / 4)`，对英文/代码混合是过估，对 CJK 是欠估（**有意过估**——budget 宁多勿少）
- `computeAdaptiveTimeoutMs({ baseMs, body, middleware })`:
  - 默认 ON（`middleware.adaptiveTimeoutEnabled` 未设或 `true`）
  - 每满 10K input tokens 加 `adaptiveTimeoutExtensionPer10kMs`（默认 60_000）
  - 总值 clamp 到 `adaptiveTimeoutCapMs`（默认 600_000 = 10min）
  - 返回 `{ timeoutMs, estimatedInputTokens, extensionMs, cappedAtMaxMs }` 便于诊断
  - <10K input → 不加 extension（base 直接够用）

### 接入 `src/endpoints/messages.ts`（2 callsites）

1. **非流式 wall-clock**（messages.ts:155 区段）—— `requestTimeoutMs` 从 flat 120s 改为 adaptive。17K input → 180s budget（120+60），50K input → 420s budget（120+300），200K input → cap 600s

2. **流式 first-token watchdog**（messages.ts:676 区段）—— `streamFirstTokenTimeoutMs` 从 flat 90s 改为 adaptive。17K input → 150s（90+60），消除流式路径上"长上下文还没出 first chunk 就被砍"的对称问题

per-chunk idle（`streamIdleTimeoutMs`）**不动**——chunk 之间 idle 与 input size 无关，是 model/network 卡顿信号，不该被 input 大小放宽

### config surface

`MiddlewareConfig` 三个新字段（全部 optional，默认值即新行为）：

```jsonc
{
  "middleware": {
    "adaptiveTimeoutEnabled": true,           // false → 回 0.14.0 flat
    "adaptiveTimeoutExtensionPer10kMs": 60000, // 0 = 禁扩展但保留 telemetry
    "adaptiveTimeoutCapMs": 600000             // 10min 上限
  }
}
```

`src/config-validate.ts` + `src/config-watcher.ts`：validator 加 3 项检查（>=0 / >=1000），watcher 通过整 `middleware` blob 重载，新字段自动 hot-reload 生效。

### 设计取舍 & 未做的事

- **没做 model-aware tokens/sec 估算**：不同模型 token rate 差很多，但 owlcoda 没有可靠 per-route rate 遥测，瞎估反而是噪声。需要 per-route 调优用现有 `model.timeoutMs` override
- **没做"长任务模式" / heartbeat**：OwlCC 用 `SystemAPIErrorMessage` 30s 心跳保活，这是 tengu 客户端协议，owlcoda local-first 不抄
- **没做 source-aware retry (foreground vs background)**：留作 P0-2 之外的独立 slice
- **没做 streaming-idle → non-streaming fallback**（OwlCC `claude.ts:1934`）：作为 P0-2 留下一 loop 迭代

### 测试

`tests/middleware/adaptive-timeout.test.ts` 19 case 锁：body shape coverage (6) / token 估算除 4 ceiling (1) / disable & default (3) / scaling tier (4) / custom config (3) / streaming base path (1) / massive input cap (1)。

### Regression 验证

- `tests/messages-wiring.test.ts` 已绿（adaptive helper 不变 fetch wiring）
- `tests/middleware-integration.test.ts` 已绿（无 streaming-related 失败回归）
- `tests/native/repl.test.ts` 373/373（wording / footer / queue 行为不变）

### 用户那次 kimi-code 截图 timeout 是否被修复？

部分修复：
- 如果原请求确实是 17.3K input → adaptive 给到 180s budget，超过当时报的 163s。**这次会跑完**
- 若 input 更大或模型本身就更慢 → 600s cap 兜底
- **但 model 本身慢/网络问题 → 仍可能 timeout**，那是 P0-2 streaming-fallback 的范畴

---

## [0.14.0] — 2026-05-11

Slicing plan exit milestone — turn lifecycle 4-slice arc lands. 0.14.0 closes
Slice 3 (typed submission buffer) and rolls up three accumulated 0.13.100
hotfixes into a single tagged release. No version-number drip; "把了解的问题
做修复不能干持续更新版本号 — 统一打包到 0.14"。

### Slice 3 — typed submission buffer (`SubmissionQueue`)

Replaces the single-slot `queuedInputRef: useRef<string | null>` with a 4-slot
FIFO ring buffer. **Visible bug fixed**: typing a second prompt while a turn
is still running no longer drops the first one — both ride out the active
turn and drain in order. When the buffer is full, the OLDEST item is dropped
to make room (same ring-buffer semantics as the auto-compact telemetry log)
and a footer notice fires once via a one-shot `consumeOverflowFlag`.

#### `src/native/submission-queue.ts` 新文件 (~112 行)

- `MAX_QUEUE_SIZE = 4` (single source; bumping requires footer + vertical-
  space audit)
- `QueuedSubmission` shape locked small: `{ kind: 'user_turn', text,
  submittedAt, origin: 'user' | 'auto_drain' }`. Slash commands are NOT
  queued (current refuse-with-footer semantics stays — slicing plan locks
  Slice 4 multi-mode behind hard gates)
- `enqueue` returns `false` on overflow + sets a sticky `overflowOnLastEnqueue`
  flag
- `consumeOverflowFlag()` is one-shot read-and-reset; re-arms on each new
  overflow
- `snapshot()` returns a defensive copy
- `clear()` resets both items and overflow flag (used by `/clear` hard reset)

Origin discriminator is preserved round-trip so drain logic can later decide
whether to schedule auto-drained submissions with a stable-frame delay vs
send user-typed ones immediately.

#### 接入 `src/native/ink-repl.tsx` (8 callsites)

`submissionQueueRef = useRef(new SubmissionQueue())` replaces
`queuedInputRef`. All 8 read/write sites converted:

- declaration (~864)
- turn-finally peek for auto-drain (~1799): `queuedPeek = submissionQueueRef.current.peek()`
- drain pop after auto-restart fires (~1907): `dequeue()`
- `/clear` reset path × 2 (~2002, ~2183): `clear()`
- user-submit enqueue with overflow footer (~2242): `enqueue({ kind: 'user_turn', text, submittedAt, origin: 'user' })` → footer reads `"Queue full — oldest dropped"` vs `"Queued (N pending)"`
- Ctrl+C size guard (~2389): `submissionQueueRef.current.size`
- display preview (~2676): `submissionQueueRef.current.peek()?.text ?? null`

**Tests**: `tests/native/submission-queue.test.ts` 新增 14 case 锁 FIFO 顺序 /
overflow drops-oldest / one-shot flag re-arm / clear-resets-flag / snapshot
defensive copy / origin round-trip。所有 ink-repl callsite 改完后 387/387 在
`vitest run tests/native/submission-queue.test.ts tests/native/repl.test.ts`
中绿过。

#### Slice 4 (multi-mode unified queue) — explicitly deferred

OwlCC upstream 的 module-level pub-sub / priority tier / mode-aware queue
留在 Slice 4，等真有需要再做（slicing plan hard gate）。0.14.0 NOT 引入
useSyncExternalStore；UI re-render 仍走 `isLoading` 普通 state change，queue
size 在需要时同步读 ref。

### Rolled up — 0.13.100 hotfixes

三个 0.13.100 修复在 0.14.0 发版同一标签下被统一打包，避免每条 hotfix 都
bump 一次小版号（feedback_release_hygiene 配套：用户审版前不希望版号噪声）：

1. **`d6f92a5` formatExpensiveFailureGuidance wording cleanup 余漏**
   — 0.13.97 wording contract 只更新了 `buildLocalRecoveryGuidance`，漏掉
   `formatExpensiveFailureGuidance` (repl-shared.ts:138)。后者在 turn 失败时
   立即 fire，前者只在用户回短恢复短语时 fire。`formatExpensiveFailureGuidance`
   改写为 switch / kind 分派，与 `buildLocalRecoveryGuidance` 措辞对齐，禁
   "before a usable response" / "will not resend the same large context from
   a short recovery reply"。`tests/native/repl.test.ts` 新增 2 case 覆盖 4
   个 expensive kinds 全部不含 banned phrases。

2. **`5af7729` markdown CJK / Latin streaming wrap aggression + tryStructuralBreak p4**
   — fixture 07/10/11 path-equivalence 回归。CJK wrap 60 → 400，Latin
   120 → 800，`tryStructuralBreak p4` 关闭。streaming 流式段不再被过激
   换行导致与 final 不一致。

3. **`d290979` streaming fetch `AbortSignal.timeout(routerTimeoutMs)` 余漏**
   — 0.13.97 split stream lifecycle 3 timeout layers 时，streaming 路径上
   仍残留一个 `AbortSignal.timeout(routerTimeoutMs)`，把 body 流也一起杀。
   `withRetry` callback 改为 async arrow，自管 headers timer，body deadline
   交给 per-chunk idle 层处理。`tests/messages-wiring.test.ts` 同步放宽
   assertion，不再硬钉 arrow 形状。

### Migration notes

- `queuedInputRef` 完全删除，外部代码（无）和测试（仅本仓 ink-repl 内部）需
  改 `submissionQueueRef.current.{peek,dequeue,enqueue,clear,size,isEmpty}`
- 行为变化：现在 queue 满后会**丢最老一条** + 发 footer 提示；0.13.x 之前
  是直接拒绝新输入。auto-restart 流（Ctrl+C → 立即恢复同一 prompt）仍在
  `origin: 'auto_drain'` 路径上工作，未来 Slice 4 可据此引入 stable-frame
  延迟

---

## [0.13.100] — 2026-05-11

### Hotfix: `formatExpensiveFailureGuidance` missed in 0.13.97 wording cleanup

用户在 0.13.98 binary 实际跑出来截图，发现仍显示老 wording：

> "Last request timed out **before a usable response**. OwlCoda **will not resend the same large context from a short recovery reply**. Use /retry to force a resend..."

0.13.97 CHANGELOG 明确写了 wording contract："never use 'before a usable response' or 'will not resend the same large context from a short recovery reply' anywhere"。但**只更新了 `buildLocalRecoveryGuidance` (failure-recovery.ts)**，**漏掉了 `formatExpensiveFailureGuidance` (repl-shared.ts:138-147)**。后者在 turn 失败时立即 fire (`ink-repl.tsx:1599`)，前者只在用户回复短恢复短语时 fire (`ink-repl.tsx:2147`)。两个出口共享同一份 wording contract 但只改了一个。

**修法**：`formatExpensiveFailureGuidance` 改为完整的 switch / kind 分派，与 `buildLocalRecoveryGuidance` 措辞对齐。每条 kind 一条 wording，全部经过 banned-phrase 自查：

- `timeout`（非流式 budget exhausted）："Last request hit the non-streaming server budget without a response..."
- `pre_first_token_stream_close`（含 first-token watchdog + 早期 socket close）："Last request did not produce any output before the stream stalled..."
- `stream_idle_timeout`（chunk 之间 idle，partial 必为 true）："Last request stalled mid-stream after partial output (which is preserved in the transcript)..."
- `empty_provider_response`：原样保留
- `post_token_stream_close` × partial=true/false：分支
- `http_error` / `provider_error` / `abort`：原样保留

**新增 wording-contract 回归测试**：`tests/native/repl.test.ts` 加 2 case 覆盖 4 个 expensive kinds 全部不含 banned phrases，强约束未来重复犯错。

**反思 (lesson)**：0.13.97 的 wording change 只查了 `buildLocalRecoveryGuidance` 调用点；忘了用 grep "before a usable response" 全仓搜一遍。今后 wording / public-string change 必须用 grep 自查整个 src 树。

---

## [0.13.99] — 2026-05-11

### SubmissionGuard — turn lifecycle interlock (co-existing)

按 turn lifecycle slicing plan (project_turn_lifecycle_slicing_plan.md) 进 0.13.99 = SubmissionGuard 共存。**不取代** isLoading / runtimeRef.activeTask / outputGateToken / ReplPhase 任何一层；只在它们之上加一个原子 interlock，回答"是否有一个 turn lifecycle slot 被占用、当前 generation 是几"。

详细 design：调研 `/tmp/owl-queryguard-research.md` + slicing plan + hard rules。

#### `src/native/submission-guard.ts` 新文件 (~130 行)

3-state state machine，generation counter：

```
     idle ─reserve()─→ dispatching ─tryStart()─→ running
      │ ↑                  │             ↑          │
      │ │ cancelReservation│             │          │ end(gen)
      │ └──────────────────┘             │          │
      └────────tryStart() (direct)───────┘          │
      ↑                                             │
      └──────────forceEnd() (any state)─────────────┘
```

API:
- `reserve()` — idle → dispatching（queue/timer 路径前置占位）
- `cancelReservation()` — dispatching → idle（peek 后发现无可处理）
- `tryStart()` — idle/dispatching → running，原子 check-and-set。**已在 running 时返回 null**，调用者应静默 bail
- `end(gen)` — running → idle，**只在 generation 匹配时返回 true**；stale finally (cancel+resubmit) 看到 mismatch → 跳过共享状态清理
- `forceEnd()` — 任何状态 → idle，**bump generation**，让被取消的 turn 的 finally 失效
- `isActive` getter

**不引入 useSyncExternalStore**（保持 boolean projection path 简单）。

#### 接入 `src/native/ink-repl.tsx`（4 个 hunk，~30 行）

- `submissionGuardRef = useRef(new SubmissionGuard())` —— 单实例 ref，跨 render 稳定
- `runConversationTurn` 顶部：`tryStart()`；返回 null 静默 return（不动 UI，不动 spinner，不动 transcript）
- `runConversationTurn` finally 中部（`finishReplTask` + `activeAbortRef` 清理之后、共享状态写之前）：`end(turnGen)` 返回 false 时 `return`，跳过所有 `setIsLoading(false)` / `setSpinnerState(null)` / `setFooterNotice` / auto-retry 调度 / queued 抽取等会 clobber 新 owner 的副作用
- `/clear` 路径：`forceEnd()` —— 硬重置语义匹配
- **Ctrl+C 路径不调 forceEnd** —— 故意：cancelled turn 的 finally 仍需 run queued-message 自动抽取（用户 mental model "cancel current, run next" 来自 shell job control），forceEnd 会让 finally 失效断了这条 UX。Ctrl+C 只 `interruptReplTask` + `abortController.abort()`，finally 走正常路径

#### 解决症状

- **auto-retry timer × user submit race**：scheduled timer fire 进 runConversationTurn 时，user submit 已经把 guard 抢走 → timer 的 tryStart 返回 null → 静默 bail。反之亦然。
- **cancel + resubmit race**：turn A 被 forceEnd，generation 自增；turn B 启动；turn A 的 stale finally 调 `end(A_gen)` 返回 false → 不 clobber turn B 的 isLoading/spinner 等
- **queued drain race**：queued drain 的 setTimeout 进 runConversationTurn 前，如果上一 turn 还没真正 end，guard tryStart fail → 静默 bail。下一轮 queue 不丢

#### 共存原则（hard rules locked）

- 不动 `isLoading` —— 继续做 UI 投影
- 不动 `runtimeRef.activeTask` —— 继续做任务执行详情 + outputGateToken
- 不动 `outputGateToken` —— stream-write race 防御 scope 不同
- 不动 `ReplPhase` 7-state
- 不取代散落的 `clearScheduledAutoRetry()` —— 那是 timer-cancel optimization；guard 是 interlock invariant。两个共存（前者防 timer 浪费 callback，后者防真 race）

#### Tests

`tests/native/submission-guard.test.ts` 26 case：

- 初始状态、5 transitions × 法 / 非法
- generation 计数器契约（success increment / fail no-increment / stale end no-increment）
- 3 race patterns（cancel+resubmit / concurrent submit / queue-processor reserve→tryStart→end）
- reset helper（test-only）

集成测试通过 4634 个 canonical 测试（仅 1 个 working-copy WIP 测试 fail，与本 slice 无关）。

---

## [0.13.98] — 2026-05-10

Two independent batches:
  - **Batch A — Renderer Robustness v3** (renderer fixes, shipped 05-09)
  - **Batch B — Bounded LLM-summary auto-compact** (this entry)

### Batch B — bounded LLM-summary first, truncation fallback always

源于 0.13.95-97 之间多次"长任务把 conversation 撑爆"反馈。OwlCoda 现有 11 个 preflight-相关机制（auto-compact threshold/heap/hard_limit、context_pressure nudges、`/compact` slash 等），但 auto-compact 触发时**全用粗暴截断**——`/compact` 的 LLM summary 路径只在用户主动调用时才走，自动路径丢老 turns 时一并丢决策、文件路径、tool 输出。

Batch B 把 auto-compact 升级为 "bounded LLM summary first, truncation fallback always"。详细设计 `/tmp/owl-bounded-compact-design.md`，用户拍板 4 个值 + 2 个硬约束。

#### 新文件 `src/native/llm-compact.ts`

7-段 bounded compactor input，硬上限 8000 tokens：

| 段 | cap | 内容 |
|---|---|---|
| `system` | 2000 tokens | conv.system；超出截断 + 标 sha256:abc1234 hash marker（**永不丢**，只截+标记） |
| `latest_user_goal` | 1500 | 最近非 meta user turn（跳"继续"/`[Runtime ...]`/纯标点）；同样可截+标记，**永不丢** |
| `recent_turns_verbatim` | 2500 | 末尾 4 turns 完整（双份冗余：进 summary 输入也保留在 keptTurns） |
| `tool_call_summary` | 1000 | 老 turns 每 tool_use → 一行 `name(input) → ok\|err: 80 chars`；最多 50 条 |
| `error_evidence` | 500 | is_error tool_result + `[Runtime ...]` + timeout/failure 短语 |
| `file_paths_touched` | 300 | tool_use 输入抽 path-shape 字符串 dedup；最多 80 条 |

LLM compactor call 强约束：

- `max_tokens: 1500` `temperature: 0` `tools: undefined` `stream: false`
- `AbortSignal.timeout: 15_000` 硬超时
- 独立 system prompt（不继承 conversation.system）
- 输出 validation: 50-8000 chars + **必须含 4 个结构化 header**（"Current goal" / "Preserved evidence" / "Recent actions" / "Open risks"，case-insensitive）+ 不含 `<tool_use>` / `"type":"tool_use"`

#### tryCompact 编排（hard rules）

```
heap_pressure → 永不调 LLM (alloc 风险)
no apiBaseUrl → straight truncation
session 已 3 strike → straight truncation, reason='llm_compact_disabled_3strikes'
turn count <= 4 → no_op
其他 → ONE LLM 尝试，失败 fallback truncation
  成功 → conversation.options.llmCompactFailureCount = 0 (清零)
  失败 → llmCompactFailureCount++
```

**ONE attempt only** — 永不嵌套 retry。失败永不 throw（catch 转 truncation 路径）。

#### 不污染 transcript

`conversation.options.compactionLog` ring buffer（≤5 条），记 `{reason, method, fallbackReason?, ms?, at}`。事后 debug 用。

`onAutoCompact` callback 加 `method: 'llm_summary' | 'truncation' | 'no_op'` + `fallbackReason?` + `llmMs?`。前端继续走 footer notice 路径（`ink-repl.tsx` 老逻辑），不进 transcript。

#### 配置（middleware）

```
compactionModel?: string             // 默认 conversation.model
compactionInputMaxTokens?: number    // 默认 8000
```

写在 `MiddlewareConfig`，validation 加 `checkString` / `checkNumber`。`ConversationLoopOptions` 同步加两个字段，REPL（`ink-repl.tsx`）+ headless（`headless.ts`）从 `config.middleware` 透传。

#### 三个 caller 改造

| 触发点 | file:line | 改动 |
|---|---|---|
| threshold (`getAutoCompactDecision.shouldCompact=true`) | `conversation.ts:746-784` | `await tryCompact(reason: 'threshold')` 取代 `autoCompact()` |
| hard_limit (request > contextWindow) | `conversation.ts:843-905` | **第一次** attempt LLM via tryCompact；仍超出 → 走原 5 次 shrink loop（fallback path） |
| context_exceeded (provider 报 2013) | `conversation.ts:932-963` | LLM 一次 + retry；失败仍 retry，行为不变 |
| heap_pressure (V8 heap > 75%) | `conversation.ts:709-728` | **不动** — 同步 truncation，永不调 LLM（OOM 防御） |

#### 4 个用户拍板的值

1. bounded input cap = **8000 tokens**（可配 `compactionInputMaxTokens`）
2. recent_turns_verbatim N = **4 turns**
3. 3-strike 计数器 session-scoped (`conversation.options.llmCompactFailureCount`)，**成功一次清零**（不是永久惩罚）
4. heap-pressure 永远 truncation——硬约束，永不试 LLM

#### 用户硬约束（已落字）

- LLM compact 输出**必须**含 4 个结构化 header，否则 validation 判失败 → fallback truncation
- compact 失败只产生 onAutoCompact callback + compactionLog telemetry，**不进 transcript**、**不 retry**、**不污染 UI**

### Out of scope (明确推迟)

- Slice B2 — conversation 级 evidence ledger（hard fact 归档，超越 LLM summary）→ 0.13.99+
- Slice B3 — 分批 synthesis（请求 > limit 拆 N 段）→ hard gate（≥5 真案例 + 用户主动要求 + B1+B2 稳定 1 月）才启动

### Tests

- 新文件 `tests/native/llm-compact.test.ts` 42 case：
  - 5 extractor 单元测（findLatestNonMetaUserText / extractToolCallSummary / extractErrorEvidence / extractFilePaths / serializeTurnsForCompactor）
  - 5 buildCompactionRequest 测（边界 / 7 段 / system 截断+hash / latest_user_goal 保留 / no_op）
  - 6 validateCompactionOutput 测（4 header 强制 / 边界 / tool_use 检测 / 大小写不敏感）
  - 4 compactViaLLM 测（success / http error / validation fail / network reject）
  - 9 tryCompact 编排测（success / failure / 3-strike disable / 成功清零 / heap 跳过 / no apiBaseUrl / no_op / ring buffer / 常量契约）
- 全仓 4609/4609 ✓

---

## [0.13.98 batch A] — 2026-05-09

### Batch A — Renderer Robustness v3

源于用户截图反馈（"这种输出还是有点乱"）。建 4 fixture 锁住 baseline，逐个修。

#### Fix: 单下划线 italic 整体禁用

OwlCoda 渲染场景以 code-heavy + CJK prose 为主。`_word_` 写法触发 italic 几乎全是误报：`_admission_*`、`runtime_governance`、`_controls`、`cache_pre_gate` 这类标识符片段在 CommonMark 严格 flanking-boundary 检查下仍会命中（trailing `*` 或标点都满足"非 word char"）。把 `_word_` italic 整段砍掉，asterisk italic (`*word*`) 保留。同等处理在 0.13.86 era 已经对 `__bold__` 做过——这次跟进单下划线。

`renderInline` 删 `/(^|[^A-Za-z0-9_])_(...)_(?=$|[^A-Za-z0-9_])/g` 那条 replace；`tests/native/markdown.test.ts` 三个相关测试更新断言为"`_word_` 保持字面量"。fixture 12 `12-underscore-identifiers-in-prose` 锁回归。

权衡：失去 CommonMark `_x_` italic 兼容；得到 100% 标识符不被吃下划线。OwlCoda 没有 workflow 依赖单下划线 italic。

#### Fix: HR-glue pre-split (`...导入---` 形态)

模型偶尔把段落分隔 `---` 黏到上一行末尾（忘换行）。0.13.97 之前 `HR_RE` 要求整行就是 dashes，glued 形态作为字面文本一行显示。

两层修：
1. `MarkdownBlockNormalizer.feedLine` 加 HR-glue pre-split：`/^(.*[^\s-])(---+)\s*$/` (pipeCount===0 路径下) 拆成 prose token + dashes-only token。后者下游 `classifyTextKind` 自然识别为 'hr'。
2. `MD_SYNTAX_RE` fast-path 正则增 `-{3,}` 分支——之前没这个分支导致纯 prose 含 `---` 的输入跳 fast path 不进 normalizer，full-pass 与 stream 路径分歧。

边界（已在注释里点名，不要再回退）：
- 不切 `1.0--2.0`（数字范围，dashes 数 < 3）
- 不切 `--option`（CLI flag，prose 部分为空）
- 不切 `|---|---|`（pipe 路径）
- 不切 em dash `—`（U+2014 单字符）

fixture 14 `14-hr-glued-to-prose` 锁回归。

#### Fix: messages.ts streaming TimeoutError 分类（0.13.97 余漏）

0.13.97 的 catch 用 `errName === 'TimeoutError' && /Stream read timed out/i.test(message)` 识别 per-chunk idle。**漏了** Node fetch `AbortSignal.timeout(routerTimeoutMs)` 也产 `TimeoutError` 但 message 是 `"The operation was aborted due to timeout"`。两个 source 都是"流没在 deadline 内交付字节"语义。改为只看 `name === 'TimeoutError'`，message-text 约束去掉。

`tests/messages-integration.test.ts` 的 streaming-body-timeout 测试现在能稳定通过（之前是间歇 fail 取决于 fetch 信号 vs SSE 信号谁先 fire）。

#### Fixtures

- `11-cjk-numbered-list-spaceless`：模型一行内塞 3 个 list 项无换行。**未修**——full-pass 与 stream-token 行为各有道理，模型 intent 本身歧义。`meta.json` `skipPaths: ["stream-token"]` 标记已知分歧，rationale 写在 meta。
- `12-underscore-identifiers-in-prose`：上面 Fix 12 的回归。
- `13-ascii-pipe-frame-diagram`：纯锁 baseline，**未发现 bug**——多 pipe 输入被正确 hold 1 拍 lookahead 后 fall-through，最终输出干净。fixture 全 7 测试通过。
- `14-hr-glued-to-prose`：上面 Fix 14 的回归。

### Batch B — 大上下文 preflight

待研究。`messages.ts` 入口处对超阈值上下文的 compact / evidence ledger / 分批 synthesis。先 read-only 调研 + 用户拍板范围才动手。Tag 0.13.98 在 batch B 完成时一起 cut。

### Tests

- markdown 全套 285/285 ✓（含 4 新 fixture，1 条 stream-token skip）
- messages-integration 14/14 ✓（streaming timeout 测试稳定）
- 全仓 4561/4561 ✓

---

## [0.13.97] — 2026-05-09

Stream lifecycle P0 — 把 `/v1/messages` 的三类 timeout 拆开，活跃 stream 不再被 wall-clock 误杀。

### 根因 & 修复

`src/endpoints/messages.ts` 的 `handleMessages` 之前用一个 `middleware.requestTimeoutMs`（默认 120s）AbortController 套整条请求的 signal，**包括 streaming**。signal 透传到 `parseSSEStream` / `readStreamChunkWithDeadline` 的 abort 监听器（`src/utils/sse.ts:86`），导致 120s 一到无论流上是否还在出 chunk 都会被 abort，错误分类落到 `kind: 'timeout'` 然后前端打 "Last request timed out before a usable response" —— 即便表格已经流到一半。

参考 OwlCC upstream Anthropic Claude Code 的 stream watchdog 模式（`~/AI/gitrep/owlcc/upstream/claude-code/src/services/api/claude.ts:1868-1929`）+ Anthropic streaming docs / Vercel provider timeout 文档：streaming 不应当受 wall-clock 总闸限制。所有候选都不为 push-token 流设计，per-chunk idle 才是正确的活跃信号。

0.13.97 把三类 timeout 显式拆开：

| 类别 | 配置 key | 默认 | 应用范围 | 行为 |
|---|---|---|---|---|
| 整体 wall-clock | `middleware.requestTimeoutMs` | 120s | **non-streaming only** | 一次性 JSON 响应不来就 504 |
| 首字节看门狗 | `middleware.streamFirstTokenTimeoutMs` | **90s** | streaming | setTimeout，第一个 chunk 到达即 clear |
| chunk 间空闲 | `middleware.streamIdleTimeoutMs`（可选）→ route `timeoutMs` → `routerTimeoutMs` | 600s（路由默认） | streaming | per-chunk 重置；任意一次 `reader.read()` 超时整路 abort |

Streaming 不再继承 `requestTimeoutMs`。dispatcher 改为：

```
if (body.stream) {
  // 只挂 shutdown 信号，永不挂 wall-clock
  const streamController = new AbortController()
  shutdown && shutdown.addEventListener('abort', () => streamController.abort())
  return handleMessagesStream(req, res, body, config, streamController.signal)
}
// non-streaming 才装 120s 拦截
```

`handleMessagesStream` 内部新增首字节 watchdog：

```ts
const firstTokenController = new AbortController()
const firstTokenTimer = setTimeout(() => {
  if (firstChunkArrived) return
  firstTokenWatchdogFired = true
  firstTokenController.abort()
}, streamFirstTokenTimeoutMs)
const combinedSignal = AbortSignal.any([signal, firstTokenController.signal])
```

`combinedSignal` 喂进 SSE 解析器；首个 chunk 到达调 `markFirstChunkArrived()` 清 timer，watchdog 永不再 fire。`finally` 兜底 clear。

### 新错误 kinds

`src/provider-error.ts`：

- `ProviderFailureKind` 新增：
  - `stream_first_token_timeout`（首字节看门狗触发，`partialOutputSeen: false` 总是）
  - `stream_idle_timeout`（chunk 间空闲触发，`partialOutputSeen: true` 总是）
- `ProviderRequestDiagnostic` 新增 `partialOutputSeen?: boolean` 字段
- 新 helper 构造函数：`createStreamFirstTokenTimeoutDiagnostic` / `createStreamIdleTimeoutDiagnostic`

`src/native/conversation.ts`：

- `ConversationRuntimeFailureKind` 新增 `stream_idle_timeout`
- `mapRuntimeFailureKind` 处理：
  - proxy `stream_first_token_timeout` → frontend `pre_first_token_stream_close`（用户没拿到内容，恢复路径同：换模型 / retry）
  - proxy `stream_idle_timeout` → frontend `stream_idle_timeout`（partial 已落，继续往下接）

### 错误事件 partial-output 透传

`messages.ts` catch block 重写，按优先级分流：

1. `firstTokenWatchdogFired` → `createStreamFirstTokenTimeoutDiagnostic`
2. `TimeoutError` 来自 `readStreamChunkWithDeadline`：
   - `streamPartialOutputSeen=true` → `createStreamIdleTimeoutDiagnostic`
   - 否则等价于首字节超时 → `createStreamFirstTokenTimeoutDiagnostic`
3. 已带 `diagnostic` 字段的 ProviderRequestError → 取其 diagnostic
4. fallback：`classifyEndpointRequestFailure`

任何 diagnostic 缺 `partialOutputSeen` 时统一 stamp 当前 `streamPartialOutputSeen` 值。SSE error event payload 携带完整 diagnostic（含 partialOutputSeen）发给前端。

### 前端 guidance 重写

`src/native/failure-recovery.ts:buildLocalRecoveryGuidance` 签名改为接 `ConversationRuntimeFailure`（不只 kind），分支：

| kind | partialOutputSeen | 文案 |
|---|---|---|
| `timeout`（仅 non-streaming） | n/a | "Last request hit the non-streaming server budget without a response. ... or enable streaming for long-running tasks." |
| `pre_first_token_stream_close`（含 watchdog） | false（结构上） | "Last request did not produce any output before the stream stalled. ... /retry / /model / 缩小批" |
| `stream_idle_timeout`（新） | true（结构上） | "Last request stalled mid-stream after partial output (which is preserved in the transcript). ... 发后续指令从中断点接，不必重发大上下文。" |
| `post_token_stream_close` + partial=true | true | "partial output preserved ... 发后续接续" |
| `post_token_stream_close` + partial=false | false | "Last request closed mid-stream. ..." |
| `empty_provider_response` / `http_error` / `provider_error` / `abort` | — | 不变 |

**用户硬约束已落实**：除非明确 `partialOutputSeen=false`，文案不再用 "before a usable response"。

### 配置

`MiddlewareConfig`（`src/config.ts`）新增：

```ts
streamFirstTokenTimeoutMs?: number   // default 90_000
streamIdleTimeoutMs?: number         // optional override; falls back to route.timeoutMs / routerTimeoutMs
```

`src/config-validate.ts` 加 `checkNumber(..., 1000)` 验证。

### Tests

新文件 `tests/messages-stream-timeout.test.ts`，4 case 跑真 HTTP server + mock router：

1. **wall-clock 不再误杀活跃 stream**：`requestTimeoutMs=200ms` + 4 chunk 间隔 100ms。0.13.96 会被 200ms 杀；0.13.97 完成全部 chunk。
2. **first-token watchdog**：mock 只写 `: keepalive` SSE comment（让 Node fetch 返回 Response），从不出真 chunk；`streamFirstTokenTimeoutMs=300ms` → 期望 `kind=stream_first_token_timeout`、`partialOutputSeen=false`。
3. **idle watchdog**：mock 出 2 chunk 然后停；`streamFirstTokenTimeoutMs=2s`、`streamIdleTimeoutMs=300ms` → 期望 `kind=stream_idle_timeout`、`partialOutputSeen=true`。
4. **clean stream**：正常 message_stop → 无 error event。

每个 case 都断言 SSE error event JSON **不包含** "before a usable response"。

更新：

- `tests/provider-error.test.ts` 加 2 条新 helper diagnostic 单测
- `tests/native/conversation.test.ts` 加 2 条 mapper 测试覆盖新 kind
- `tests/native/failure-recovery.test.ts` 加 5 条 guidance wording 测试（含 `partialOutputSeen` 分支）+ 把 `stream_idle_timeout` 加入 `kinds[]` 让全矩阵覆盖到

全仓 4534/4534 ✓。

### Out of scope（明确推迟）

- **大上下文 preflight**（compact / evidence ledger / 分批 synthesis）—— 0.13.98+ 单独 slice
- **OwlCC upstream QueryGuard + messagesRef 真源 + unified queue 移植**—— 0.13.99+ 单独 slice
- **`routerTimeoutMs` 默认值降低** —— 不动，需要真实长任务 smoke 数据再说

---

## [0.13.96] — 2026-05-09

### Markdown: golden fixture suite + streaming blank-token bug fix

新增 `tests/native/markdown-fixtures/cases/`，10 条代表性 case 覆盖：heading-OL-glue / heading-fence-glue / heading-table-glue / 纯文本 fast-path / 多 heading 连续 / 软 heading（粗体冒号）/ CJK 长段 / 干净 fence / 混合列表 / inline code 含 markdown 字符。每条跑 4 路径（full-pass / 1-chunk stream / line-by-line stream / token-by-token stream）+ 3 个终端宽度（80/100/120）= 共 68 条 assert。

`OWLCODA_FIXTURE_REGEN=1` 重写所有 `out.txt`，每条 fixture 一对 `in.md` / `out.txt`。可选 `meta.json` 配 `widths` / `widthSensitive` / `skipPaths`。

立刻抓出**两类真 bug**：

1. **TokenRenderer.lastEmittedBlank（修了）**——`StreamingMarkdownRenderer.processLine` 每行调用都给 TokenRenderer 一个新的 `out: string[]`，blank token 处理里的 `out.length > 0` 检查永远为 false，**单独到来的 blank token 在流式路径下被吞**。结果：流式渲染相邻段落之间漏掉 blank。修法：在 TokenRenderer 类上加跨调用 state `lastEmittedBlank`，blank handler 和 boundary-insertion 都改用它，非 blank 行 push 时重置。`reset()` 清掉。
2. **Token-by-token CJK eager-wrap（已 skip，留给 0.13.97+）**——纯 CJK 长段在 token-by-token 流式下，buffer 长度超过 60 字时 streaming wrap 在 `\n` 到来前就 emit 部分行；full-pass 不 wrap，留给 Ink/终端层。两路径输出不一致。fixture `07-cjk-long-prose` / `10-inline-code-with-md-chars` 用 `meta.json` 加 `skipPaths: ["stream-token"]` 标记，后续在专门的 wrap 一致性 slice 里拉齐。

### Failure recovery: 整段匹配，不再"任意位置含关键字就拦"

0.13.95 的 `SHORT_FAILURE_RECOVERY_RE.test(trimmed)` 是 "any keyword anywhere" 命中——`超时怎么办`、`重试一下试试`、`为什么超时` 都被拦下来当短恢复处理，但这些是**真正的提问**，不是恢复短语。用户多次报告："我打的提问被拦了"。

0.13.96 把这条 fallback 整段砍掉。新规则：

- 多行 / 含 `\d+\.\s` 列表 marker / 含 `[:：]\s*\S` 结构冒号 / 长度 > 40 → 早 reject 走 long_fresh
- `normalized` 整段命中 `CONTINUATION_RETRY_PHRASES` set → block
- `normalized` 整段命中 `POLITE_CONTINUATION_RETRY_RE` 锚定正则 → block
- `trimmed` 整段就是标点（`SHORT_QUESTION_ONLY_RE` 锚定）→ block
- 其它一律 long_fresh

`CONTINUATION_RETRY_PHRASES` 增补常见 continuation 变体：`继续写`、`继续写下去`、`继续完成`、`接着写`、`接着说`、`keep going`、`keep writing`。

`src/native/repl-shared.ts` 里 `isFailedRequestRecoveryInput` 之前是和 `failure-recovery.ts` 重复实现两份；现在合并 delegate 到 `classifyRecoveryInput` 单一来源，且把 `SHORT_FAILURE_RECOVERY_RE` / `SHORT_QUESTION_ONLY_RE` 的本地副本直接删了。

`tests/native/repl.test.ts` 的 `guides instead of resending after expensive no-output failures` 测试更新断言：`继续` / `继续写下去` / `continue` / `keep going` 仍走 guide path；`为什么中断了？` / `那你既然知道这么大，应该分批处理啊` / `split into batches` 改为 long_fresh，全部发给模型处理。

### Tests
- markdown 全套 258/258 ✓（含 68 条新 fixture，2 条 stream-token skip）
- failure-recovery 52/52 ✓
- repl 423/423 ✓
- 全仓 4517/4517 ✓
- clean-checkout build 验证通过

---

## [0.13.95] — 2026-05-09

封板版本 (Renderer Robustness v1)。把 0.13.94 的 heading-OL-glue 修复 + 4 项独立改进合并发布。本次方向是**保留手撸 normalizer，加固渲染健壮性**，不切换外部 markdown 解析器。详见 `/tmp/owl-md-lexer-research.md` 的选型调研。

### Markdown: heading + ordered-list glue 拆分 (0.13.94 work)

第二种模型 glue：`## 建议修改清单1. 模型名核实` —— 模型把数字列表项粘到 heading 行尾，没换行。0.13.94 之前这会被识别成一个完整 heading，文本是整段 jam，列表边界丢失。

`classifyHeadingOrText` 现在用 `HEADING_OL_GLUE_RE = /^(.*?[一-鿿])(\d+\.\s+\S.*)$/` 对捕获的 heading 文本做后处理：当 CJK 字符紧接 digit-dot-space 时拆分。CJK 锚点是有意为之，避免错切英文 `## Section 1. Intro` 和小数编号 `## 1.5. Title`。命中后 heading 取 prefix，tail (`1. xxx`) 当 text token 发出，下游 `classifyTextKind` 重新打成 list。

边界（已审计）：
- 仅 `HEADING_RE` 命中时触发（必须 `#` 起始）
- 代码围栏 / 表格内不进入此路径
- 要求 `\d+\.\s`（dot+空格），所以 `1.5.x`/`v1.2.3`/`版本号 1.0` 都不触发
- 要求 CJK 锚点，纯英文 / 纯数字 heading 不触发

`tests/native/markdown-0.13.94-heading-ol-glue.test.ts` 加 9 条。

### Stale-dist defense：`--version` 与 banner 显示 build SHA

0.13.91-0.13.93 期间多次出现"看上去回归但 repro 干净"的误诊，根因都是用户在跑老的 `dist/` 二进制。无法在带内验证"运行的代码到底是哪个 commit 的"。

`scripts/postbuild.mjs` 现在在编译时写入 `dist/build-info.json`，存 `{sha, dirty, builtAt}`，从 `git rev-parse HEAD` + `git status --porcelain` 抓。`src/version.ts` 导出 `BUILD_INFO` + `buildTag()`。

显示位：
- `owlcoda --version` 多一行 `build: ab12cd3-dirty · built 2026-05-09T...`
- REPL welcome banner 顶部 `owlcoda v0.13.95 (ab12cd3)`
- `printHelp()` 同样形态

dev 模式（tsx，无 dist）回落 `dev`，与 stale built binary 可区分。

### Markdown: 性能优化两件 — fast path + LRU cache

- **MD_SYNTAX_RE fast path**：`renderMarkdown` 入口先扫描前 500 字符是否含 markdown 结构标记。无标记 → 跳过 normalizer + renderer，直接 per-line `renderLine`（仅做 inline 染色）。覆盖短 prose / 工具输出 tail / 纯文本助手回复 —— OwlCoda 的高频路径。
- **LRU cache**：`renderMarkdown(text)` 命中相同 raw text 时直接返回缓存。容量 256，按 insertion-order eviction + MRU 提升。virtual-scroll 反复重渲染历史消息时受益（同 input 必同 output）。

### Failure recovery：短恢复输入阈值收紧

`failure-recovery.ts` 之前的判定：输入 ≤160 字 + 命中关键字（`超时`/`timeout`/`batch`/`error`/`继续`…）→ 拦截不发，给本地提示。该规则太宽。当用户在前轮超时后输入

> 但在进入编码前，建议先闭环以下四个问题：1. 网络信任… 2. 超时算术…

这样的多点正文（含 `超时` 当主题词），整段 ~100 字 < 160，被拦下来不发。多次复现报告。

收紧成"短 **且** 不像正文"：
- 含 `\n` → 长输入
- 任意位置含 `\d+\.\s` 列表 marker → 长输入
- 含 `[:：]\s*\S` 结构冒号 → 长输入
- 长度 > 40 字 → 长输入
- 以上都不命中再走原有关键字逻辑

`tests/native/failure-recovery.test.ts` 加 5 条回归。47/47 全过。

### Markdown debug：`OWLCODA_DEBUG_MD_RAW` raw stream dump hook

`StreamingMarkdownRenderer.push/flush` 在 `OWLCODA_DEBUG_MD_RAW=/path` 设了的时候，把每个 chunk 当 JSONL 追加。`{"t":"push"|"flush","ms":<epoch>,"chunk":"..."}`。best-effort 写入，错误吞掉。诊断未来 glue case 用，不打扰正常运行。

### 测试

- markdown 全套 190/190 ✓
- failure-recovery 47/47 ✓
- 其它套件未触动
- 发版前完成 clean-checkout build 验证（per release hygiene）

---

## [0.13.93] — 2026-05-09

### Markdown: heading-fence glue — lang-extraction whitelist

0.13.92 introduced pre-split for `## H```lang<tail>` glue but used a greedy `[\w-]*` to extract the lang. When the model jams `## H```tsconst x = 1;` (no whitespace between lang `ts` and code `const`), the greedy match captured `tsconst` as the lang and emitted code-line `x = 1;` — the first code keyword `const` was silently consumed by the lang display.

0.13.93 replaces the greedy capture with a **known-lang whitelist** (~80 common identifiers: `ts`, `js`, `py`, `bash`, `rust`, `go`, `json`, `sql`, …). The leading word from `[a-zA-Z][a-zA-Z0-9-]*` is checked against the whitelist starting from its full length down to 2 chars; the longest known prefix wins. If no prefix matches, `lang=''` and the entire post-` ``` ` content goes into the first code-line — we lose syntax highlighting but never silently swallow code tokens.

Examples:
- `\`\`\`tsconst x = 1;` → `lang='ts'`, code-line=`const x = 1;` ✅
- `\`\`\`bashecho hi` → `lang='bash'`, code-line=`echo hi` ✅
- `\`\`\`fooblahbar = 1` → `lang=''`, code-line=`fooblahbar = 1` ✅ (no known prefix; prefer no-lang over wrong-lang)

Adds 3 new lang-extraction tests to `markdown-0.13.92-heading-fence-glue.test.ts`. 4430/4430 full suite green.

---

## [0.13.92] — 2026-05-09

### Markdown: heading + ```lang fence glue pre-split

Real-world model output occasionally jams a heading line with the next code fence on the same logical line — `## 三、风险与建议\`\`\`tsconst AUDIT_VERSION = '0.13.91';` — because the model forgot the newline before the fence. Before 0.13.92 the heading regex consumed the entire line as heading text including the triple-backtick, the fence was never recognized, and the rest of the input rendered as plain prose with literal `\`\`\`` characters.

`MarkdownBlockNormalizer.feedLine` now detects this case in the no-pipe branch: when `HEADING_RE` matches and the captured heading text contains `\`\`\``, the normalizer splits at the backticks into:

- `heading(prefix)` — the heading text before the fence
- `code-fence-open(lang)` — `lang` is the leading word-character run after `\`\`\`` (so glued `\`\`\`tsconst` recovers `lang='ts'` and emits `code-line('const ...')` for the rest)
- `code-line(tail)` — emitted only when there is trailing content on the same line after the lang word

The normalizer flips into `inCodeFence = true` so subsequent input lines route to `code-line` until the closing fence arrives, identical to the well-formed path. All four rendering paths (full-pass / single-chunk-streaming / line-streaming / token-streaming) recover the same structure.

### What did NOT change

- `### 附录|项|说明|\n|---|---|` (triple-hash + table-header glue) was a suspected bug from screenshot review but the existing pipe-lookahead pre-split already handles it correctly. Verified by diagnostic tests; no code change.
- Heading + paragraph on consecutive lines (well-formed) was a suspected bug from screenshot review but renders correctly. The visual "glue" was Ink terminal-width wrapping, not a renderer issue.

The 0.13.91 R1-R8 spacing rules are unchanged. Pre-split is purely additive in the normalizer.

---

## [0.13.91] — 2026-05-09

### Markdown v1 spacing spec: block-id boundary insertion

0.13.90 unified the parsing path; 0.13.91 defines OwlCoda's own visual standard for how blocks breathe. Previously the renderer only inserted a blank before headings; everything else inherited whatever blanks the model emitted, so tables jammed against following prose, code fences ran straight into paragraphs, and consecutive paragraphs stayed glued together when they should have separated.

The new contract is rule-driven, applied at `TokenRenderer.emit()` time:

- **R1 heading bracket** — every `#` heading is a singleton block; entering and leaving inserts a blank.
- **R2 soft heading** — a `text` token whose entire line is `**X**` or `**X**:` is treated as a singleton with the same bracketing as R1.
- **R3 table bracket** — the boxed table is one block; entering and leaving inserts a blank.
- **R4 code bracket** — fenced code (open + lines + close) is one block; entering and leaving inserts a blank.
- **R5 list compactness** — consecutive list items share one block-id (no blanks between); list-to-non-list transitions insert a blank.
- **R6 paragraph break** — consecutive paragraph lines share one block-id; transitions to other kinds insert a blank.
- **R7 collapse** — never two consecutive blanks; the second is dropped.
- **R8 terminal cap** — `finalize()` trims trailing blanks before returning.

### Implementation

`src/native/markdown.ts`:
- `TokenRenderer` rewritten with `prevKind` / `currentBlockId` / `blockCounter` state. The boundary logic is one method (`emit`) consulted from every token branch.
- `classifyTextKind(rawText)` distinguishes `list` / `softHeading` / `hr` / `paragraph` for `text` tokens at emit time, so list-vs-paragraph spacing is decided from content rather than punctuation hints.
- `flushTableTo` now emits each rendered table line tagged `'table'`, so multi-row tables share one block-id and don't drop blanks between rows.

### Path equivalence preserved

All four rendering paths (full-pass / single-chunk-streaming / line-streaming / token-streaming) consume the same `TokenRenderer`, so the rules apply uniformly. The 47 path-equivalence tests, 39 normalizer unit tests, and 84 markdown unit tests all pass unchanged.

---

## [0.13.90] — 2026-05-09

### Markdown renderer unification: shared `MarkdownBlockNormalizer`

Closes the architectural gap that 0.13.88/89 tried (and failed) to patch with regex tweaks: full-pass `renderMarkdown(text)` and `StreamingMarkdownRenderer` had two different code paths for block-boundary recognition. Bugs fixed in one didn't propagate to the other; behavior diverged on real model output (heading + table-header glued, mid-line `###`, multi-row clean tables that streaming bailed out of after row 1).

This release introduces a single source of truth — `MarkdownBlockNormalizer` — that both paths consume.

### Architecture

`src/native/markdown-normalizer.ts` (new): pure block-level normalizer. Recognizes code fences, headings, GFM tables (with strict separator validation that rejects `|||`), mid-line `###<heading>` splits, and `<prefix>|cells|` + next-line-separator detection. Holds at most 1 pending line for lookahead; streaming latency cap is one logical line. Owns no ANSI rendering.

`src/native/markdown.ts`:
- `TokenRenderer` (new class): consumes the normalizer's typed token stream and emits ANSI. State (codeLang, pendingTable, lastEmittedPlain) is held on the instance so streaming can flush a held table when the next non-table token arrives.
- `renderMarkdown(text)`: thin wrapper — `renderTokens(new MarkdownBlockNormalizer().feedAll(text))`.
- `StreamingMarkdownRenderer`: holds its own normalizer + renderer. `push()` feeds complete lines through `feedLine` → `render`. `flush()` drains buffer → `normalizer.finalize()` → `renderer.finalize()`. The `tryStructuralBreak` and CJK word-wrap heuristics survive (they handle in-flight no-newline buffers, which the line-level normalizer can't see) but now consult `normalizer.isInCodeFence()` and skip table-shape buffers.

### Removed

The streaming class shed its parallel state machine: `inCodeBlock`, `codeBlockLang`, `tableBuffer`, `inTable`, `flushTable()`, `renderOneLine()` — all subsumed.

### Bugs fixed in passing

Two latent streaming bugs surfaced via the new path-equivalence test suite:

- `flush()` split regex's `(?<=[.!?。！？：:\s])(?=\*\*[^*])` matched any whitespace before `**`, including the space after a list marker. So `- **P3-1** content` split into `- ` + `**P3-1** content`. Tightened to require sentence-ender punctuation. Same fix applied to `tryStructuralBreak`'s pattern p5.
- `Phase 3` word-wrap fired on long table-row buffers, emitting a wrapped prefix (`| col | TASK_TEXT_DELIVERABLE_RE 增`) before the trailing `\n` arrived, leaving a stray fragment above the eventual boxed table. Now skipped when buffer starts with `|` and has 2+ pipes.

### Tests

- `tests/native/markdown-normalizer.test.ts` (new, 39 tests): unit-level coverage of the normalizer's state machine — basic shapes, code fence containment, clean GFM tables, malformed input recovery, state transitions, feedAll/feedLine equivalence, finalize behavior.
- `tests/native/markdown-path-equivalence.test.ts` (new, 47 tests): the path-equivalence contract — same markdown source must render structurally identically across (a) full-pass `renderMarkdown`, (b) streaming single-chunk push, (c) streaming line-by-line, (d) streaming token-by-token. Covers 12 inputs × 3 chunkings + 9 P0 invariants (boxed tables on malformed inputs across paths) + 2 P1 invariants (code fence preserves `##` and `|` literally).
- `tests/native/markdown.test.ts` — 84/84 unchanged. Existing rendering behavior preserved.

Final clean-checkout: 4400 / 0 across 293 test files. Working tree: 4419 / 0 across 294. tsc clean.

### Out of scope (explicit)

Per the unification plan, this release intentionally does NOT introduce: bold-only soft-heading auto-spacing, block bracketing (push-blank-around-tables/code), ASCII flowchart preservation, queue-refusal P0 from the audit, paste-collapse rework, TurnLifecycle. Each of those is a follow-up slice.

### Reverts in this release

Three commits from 0.13.88 + 0.13.89 were reverted at the start of this work — `22f29b1`, `4ba7917`, `a43bb2e`. Their target behavior (pre-split mid-line headings + heading-with-table-header) is now achieved correctly via the normalizer instead of via patches against the diverged full-pass loop.

## [0.13.87] — 2026-05-09

### Self-redteam dogfood follow-ups: classifier bare nouns + queued slash refusal + markdown 排版

Three issues surfaced when running the v0.13.86 self-redteam audit on OwlCoda itself.

**P1-1 (classifier): bare deliverable nouns.** `改进建议` alone classified as `mixed` because `TASK_WRITE_COMPOUND_EXCLUSION_RE` strips `改进` to a space, leaving bare `建议`, and `TASK_TEXT_DELIVERABLE_RE` required a verb prefix for bare nouns. Fix in `src/native/task-contract.ts`: add a bare-noun branch matching 改进建议 / 改进方向 / 改进意见 / 改进方案 / 改进思路 / 审计报告 / 建议 / 意见 / 反馈 at sentence boundaries; add 方案 / 思路 / 方向 to existing verb-prefix noun lists; `classifyTaskContract` now tests `TASK_TEXT_DELIVERABLE_RE` on BOTH the original trimmed text AND the post-strip text so compound deliverables (改进方向 needs 改进 visible) and bare 建议 (needs strip to remove confusing prefixes) both match.

**P2-2 (queue): refuse to queue slash commands during isLoading=true.** Pre-fix, `/retry` typed during a running task was stored as `queuedInputRef.current = '/retry'` and later drained via `runConversationTurn({ kind: 'user_turn', input: '/retry' })` — the slash-command interception was bypassed. The model received the literal `'/retry'` string instead of executing the force-resubmit. Same hazard for `/clear`, `/model`, `/quit`, etc. Fix in `src/native/ink-repl.tsx`: at the queue site, refuse to queue any input starting with `/`. Show transcript warning + footer `Slash command refused — abort current task first`. The user must press Ctrl+C to abort, then re-issue.

**排版**: markdown table CJK/ASCII wrap + blank line before headings. User reported the v0.13.86 audit output ugly. Two improvements in `src/native/markdown.ts`:

- Table cell wrap: extend `SOFT_BREAK_RE` (already breaks at `/`/`_`/`-`/`.`) with three CJK boundary hints. wrapAnsi now prefers ZWSP over hard-cut at: ASCII run before CJK (`0.13.85的` breaks before 的); CJK before ASCII (`的explicit_file_write` breaks before the identifier); CJK punctuation (`测试，但是` snaps at the comma instead of mid-character). Identifiers stay intact in dense tables; CJK punctuation gives natural breakpoints.
- Blank line before headings: when the model emits `## Heading` immediately after non-empty content (no leading blank), the renderer inserts a visual breathing-room blank line. Skips if a blank already exists above (no double-spacing). Catches the common case where streaming markdown lacks aesthetically-helpful whitespace.

### Tests

`tests/native/task-contract.test.ts` extended with 8 new regression cases: bare deliverable nouns (改进建议 / 改进方向 / 改进方案 / 提出方案 / 建议 / 给改进意见) and write-required regression guards (Modify src/foo.ts, 给出改进建议然后修改 src/foo.ts). Total 43/43 in this file.

## [0.13.86] — 2026-05-08

### Slice-E follow-up: negation-context paths + meta-prompts no longer trip write_required

Three related classifier gaps surfaced when dogfooding the v0.13.85 self-redteam prompt:

1. **`TASK_EXPLICIT_FILE_WRITE_TARGET_RE` was tested on the original (un-stripped) text**, so `不允许修改 src/foo.ts` / `do not modify src/native/foo.ts` matched on `修改 + path` before negation processing could redirect the decision tree to `read_only`. Now tested on the same negation-stripped text used for write-intent survival.

2. **Decision-tree precedence**: `explicit_file_write` was first, `read_only_prohibition` second. A meta-prompt with `只读审计 ... write_required examples: 修改 src/foo.ts` (where path examples appear in non-negated illustrative context) had the explicit-file-write check fire first, ignoring the global `只读` framing. Reorder: if the user signals `read_only` globally (只读 / 禁止 / 严禁 / read-only / do not modify), that wins over inline path examples.

3. **`READ_ONLY_PROHIBITION_RE` extended** to recognize `不允许/不能/不要/不可 + write-verb` without requiring a 只读/禁止/严禁 wrapper. Bare `不允许修改` / `不能写` now classifies as `read_only`.

### Tests

`tests/native/task-contract.test.ts` extended with 4 regression cases:
- `不允许修改 src/foo.ts` → `read_only`
- `do not modify src/native/foo.ts in this task` → `read_only`
- meta-prompt with negation + path examples → `read_only`
- `Modify src/foo.ts to add a new export` (no negation) → `write_required` (regression guard for non-negated case)

### Real-world driver

The 0.13.85 self-redteam prompt hit task-no-progress hard-stop at iter 9 and again at iter 11 because the prompt's negation clauses (`不允许修改 src/ 或 tests/`) and example paths in scenario-D (`修改 src/foo.ts、写入 docs/intake.md`) both individually matched `TASK_EXPLICIT_FILE_WRITE_TARGET_RE` before the classifier could honor the global 只读 framing. With 0.13.86 the same prompt classifies as `read_only`.

This is the second false-positive class fixed in two days. The first (0.13.85) was bare audit + improvement-suggestion phrasing without explicit prohibitions. This one is the inverse: explicit prohibition + inline path examples in meta-prompts. Together they make the classifier robust against the two ways a Chinese-language audit prompt can accidentally read as write_required.

## [0.13.85] — 2026-05-08

### Task contract classifier + transcript rendering budget

Two parallel slices land in 0.13.85, both deferred from 0.13.84. They are independent, touch disjoint files, and address distinct user-facing pains surfaced in 0.13.84 dogfood:

- **Slice-E (task contract classifier)**: read-only Chinese audit prompts no longer trip `task-no-progress` hard-stop.
- **Slice-D (transcript rendering budget)**: spinner ticks no longer trigger O(n) display-line work over the full transcript.

### Slice-E — `classifyTaskContract` replaces the regex stack

`src/native/task-contract.ts` (new) exports `classifyTaskContract(text) → { kind, confidence }`. Kind is `write_required` / `read_only` / `text_deliverable` / `mixed`. The legacy `taskLikelyRequiresWrites` becomes a `@deprecated` one-line wrapper; the four call sites at `conversation.ts:637/685/1117/2960` keep their existing call shape.

The v0.13.84 dogfood false-positive: a Chinese red-team prompt with `"只读查询，不允许创建、修改、删除 ... 给出 10 条改进建议"` was classified as `write_required` and hard-stopped at iter 9. Three root causes:

- `改` (substring of 改进 "improvement") fired `TASK_WRITE_INTENT_RE`.
- `TASK_TEXT_DELIVERABLE_RE` did not cover `建议` as a deliverable noun.
- `TASK_NEGATED_WRITE_INTENT_RE` needed an explicit no-write verb pair but the prompt's prohibition was structured.

The classifier closes all three:

- `TASK_AUDIT_INTENT_RE`: 审计 / 评估 / 评价 / 分析 / 审查 / 红队 / audit / review / assess / evaluate / analyze / inspect. When this fires AND no explicit file-write target exists, classification is `text_deliverable` regardless of stray write tokens. CJK alternation drops `\b` (JS word boundaries don't fire between CJK chars).
- `TASK_WRITE_COMPOUND_EXCLUSION_RE`: strips 改进 / 改善 / 改良 / 补充 / 补足 / 补救 / 修养 before testing `TASK_WRITE_INTENT_RE`. Bare 改/补/修 verbs still fire; improvement-noun substrings no longer credit the write counter.
- `TASK_TEXT_DELIVERABLE_RE` noun list expanded: 建议 / 改进建议 / 改进方向 / 意见 / 反馈 / 评估 / 评价 / 分析 / 审计报告 + EN suggestions / recommendations / feedback / findings / improvements / assessment / evaluation.
- `READ_ONLY_PROHIBITION_RE`: explicit prohibition phrasing (只读 / 禁止 / 严禁 / read-only / do not modify / ...) wins over other signals → `read_only`.

Decision tree precedence: `explicit_file_write` > `read_only_prohibition` > `audit_intent` > `text_deliverable_noun` > `surviving_write_intent` > `mixed_low`.

`mixed` low-confidence is treated as not-write-required by the wrapper.

### Slice-D — Viewport-bounded rendering, leaf-scoped spinner

Four targeted optimizations:

- **`TranscriptOffsetsCache`** (`repl-shared.ts`): append-only `Map<itemId, cumulativeLines>`. `selectVisibleTranscriptWindow` reads `totalLines` from the cache (O(1)) instead of walking all items each spinner tick. Cold path falls back to the existing O(n) walk and primes the cache.
- **Cache wired into `ScrollableTranscript`** (`ink-fullscreen-layout.tsx`): cache ref passed to `selectVisibleTranscriptWindow`; per-render `countTranscriptLines` walk dropped.
- **`ScrollIndicatorLeaf`** (`ink-fullscreen-layout.tsx`): leaf component owns its own `useEffect/setInterval` for spinner-frame state. `spinnerFrame` no longer a prop on `ScrollableTranscript`, so `React.memo` on the transcript subtree can finally short-circuit on spinner ticks.
- **`MAX_VISIBLE_ITEMS_CAP = 200`** (`repl-shared.ts`): exported constant matching upstream non-virtual fallback cap. Applied via `Math.min(RECENT_VISIBLE_ITEMS, MAX_VISIBLE_ITEMS_CAP)`.

### Tests

- `tests/native/task-contract.test.ts` (new, 31 tests): write-required positives (EN+ZH explicit file targets), read-only positives (EN+ZH negation/prohibition), text-deliverable positives (audit + report nouns), false-positive regression (改进 substrings), mixed low (continue / empty / "improve the code quality"), explicit-file-wins-over-audit, and a verbatim test pinning the v0.13.84 dogfood prompt to `read_only`.
- `tests/native/repl-shared-display-cache.test.ts` (extended): offsets-cache contract, append-only growth, viewport-only mount, spinner-tick O(1) regression.
- `tests/native/ink-fullscreen-layout.test.ts` (new): structural + type-level test that `ScrollableTranscriptProps` no longer accepts `spinnerFrame`.

Final clean-checkout: 4302 / 0 across 291 test files. `tsc --noEmit` clean.

### Process notes (for next-round implementers)

Two failure modes hit this round:

- A controller-side commit was lost when a parallel implementer subagent ran `git stash` + `git reset --hard` as part of its clean-checkout flow. The commit was unrecoverable from reflog. Re-applied by hand under a different SHA. Multiple writers on `main` need either `isolation: worktree` or strict serialization. See memory `feedback_parallel_subagent_commit_collision`.
- Three subagents (two Slice-E attempts, one smoke-harness build) hit a Bash permission denial on a specific command and pivoted to a meta task ("scan transcripts and write `.claude/settings.json` allowlists") instead of falling back to Edit-only mode. Future implementer prompts should explicitly forbid the meta drift. See memory `feedback_subagent_bash_denial_meta_drift`.

### Deferred to later releases

- doc-Slice-A (TurnLifecycle foundation) — speculative; no plan yet
- doc-Slice-F (stale daemon active-client handoff) — plan ready: 5 tasks, `2026-05-08-stale-daemon-active-client-handoff.md`

## [0.13.84] — 2026-05-08

### Failure-recovery state machine + tool-protocol closure + composer paste GC

Three coupled subsystems hardened in one release: (1) a unified failure × input-kind classifier that routes recovery deterministically; (2) tool_use closure on every loop-guard exit path so failure no longer orphans the conversation; (3) composer paste-store GC that keeps Backspace atomic on placeholders. Plus audit-driven fixes to the queue-preservation work (`ddaceb6`).

### `/retry` actually re-sends in the Ink REPL (release-blocker fix)

Pre-fix, the Ink path made `/retry` a silent no-op: `handleSlashCommand` popped turns, printed `↻ Retrying last message…`, then returned without calling `setImmediate(rl.emit('line', …))` because Ink doesn't pass an `rl`. Documentation and multiple footers told users `/retry` was the only force-resend mechanism — it wasn't.

`handleSlashCommand` now emits a structured `SlashCommandSideEffect` consumed by Ink's `runCapturedSlashCommand`, which calls `handleSubmit(lastUserText)` through the regular submit path. The readline REPL's pre-existing `rl.emit('line', …)` path is preserved as a fallback for callers that don't pass `hooks`.

A late-stage final review caught two related P1 issues in the side-effect drain that the unit tests missed (post_token_stream_close routed through submit_text instead of retry_failed_continuation; short_recovery-shaped prior-user-text caused classifier to block the resubmit while conversation turns were already popped). Both collapse to one fix: `clearRetryContinuationState()` immediately before re-entering `handleSubmit`. The matrix invariant "`force_resubmit_failed_turn` consumes runtimeFailure" is now explicit at the point of consumption.

### Unified `classifyFailureRecoveryAction` matrix

`src/native/failure-recovery.ts` (new): every failure-kind × input-kind cell returns an explicit `RecoveryAction`. The legacy `decideFailedContinuationSubmitAction` in `repl-shared.ts` is now a thin shim mapping new actions to legacy vocabulary, so existing Ink/readline branches continue to function while the routing is corrected.

Behaviour changes (intentional):
- Short recovery input (`继续`, `为什么中断了`, `?`) under any retryable failure → block with local guidance, no model request. Was: auto-retry on cheap failures, guidance on expensive, ignored on `post_token_stream_close`.
- Long fresh content under any failure → submit as new turn, abandon failed assistant attempt. Was: inconsistent.
- `/retry` is the only force-resubmit path. Includes `post_token_stream_close` (was excluded entirely from retry-eligibility before).
- `abort × short_recovery` → fresh turn, not block-with-guidance (Ctrl+C means user wants a new ask, not a recovery question).

### Queued input preserved on non-drainable failures

When `taskFailed=true` AND no auto-retry-eligible failure (timeout / pre_first_token_stream_close / empty_provider_response), input the user typed during the running turn is no longer silently nulled. The held queue stays in `queuedInputRef`; a transcript notice (`⚠ Queued input held: "<preview>"`) is shown once per held queue plus a footer hint. The next successful turn drains it via the existing `taskFailed=false` branch in `shouldDrainQueuedInputAfterTurn`. `/clear` discards explicitly.

Audit follow-ups (also fixed in this release):
- `/clear` at idle now nulls `queuedInputRef`. Pre-fix, a `/clear` after a non-drainable failure left the held queue alive; the next successful turn would auto-drain the user's old message, violating "clear = full reset".
- Repeated non-drainable failures no longer re-spam the held warning. `queueHeldNoticeShownRef` gates the transcript notice; footer refreshes safely (auto-clears in 1.8 s).

### Loop-guard exit paths close pending tool_use

Hard-mode `loopError` previously hit `break` without pushing `toolResults.results`, leaving an orphaned assistant turn with `tool_use` blocks. `sanitizeConversationTurns` would strip the orphan, and the model re-executed the entire tool batch from scratch — confused, hidden, expensive. New `closePendingToolUse` helper synthesizes filler `tool_result` blocks for any unresolved `tool_use_id`s before the break.

Soft-mode loopError partial-coverage closed: when `executeTools` `break`s the for-loop after handling the offending block, remaining un-executed blocks now get synthetic `[loop-guard] skipped` fillers via Step-5 fillerSynthesis, restoring `sanitizeConversationTurns`'s "all pending IDs resolved" invariant.

### API-error iteration over-count

`markTaskIteration` runs at the top of the conversation loop, before `sendRequest`. When `sendRequest` throws, the iteration was incremented even though no model output was produced. Repeated HTTP 429 / 5xx pushed `lifetimeIterations` toward `TASK_NO_PROGRESS_ITER_LIMIT`, eventually hard-stopping with `guard_blocked` — preventing even `/retry`. Now decremented in the catch.

### Composer paste-store GC

Token format unified to `[Pasted text #N]` / `[Pasted text #N +X lines]`, matching the `deleteTokenBefore` regex at `Cursor.ts:961`. Backspace now atomically deletes a placeholder; pre-fix it walked char-by-char through the brackets.

New `pruneStaleTokens(store, visibleValue)` removes store entries whose token has been deleted from the visible composer string. Wired into every `setInputValue` path. Pre-fix, store entries leaked until submit's `resetPasteStore` cleared everything; deleted-mid-paste entries occupied memory and entry-numbers needlessly.

### Internals

- Broke a circular import between `repl-shared.ts` and `failure-recovery.ts` by inlining the recovery-input regexes locally in `failure-recovery.ts`. The cycle made vitest's transformer resolve `isExpensiveFailedContinuationFailure` as `undefined` at runtime in fresh module-graph evaluation.
- Re-exported `isExpensiveFailedContinuationFailure` / `isFailedRequestRecoveryInput` / `formatExpensiveFailureGuidance` from `repl.ts` so test imports resolve in the public surface.

### Tests

- `tests/native/ink-retry.test.ts` (new) — pins the Ink `/retry` no-op bug + the side-effect drain-to-handleSubmit contract.
- `tests/native/failure-recovery.test.ts` (new) — 42 unit tests covering the full failure-kind × input-kind matrix.
- `tests/native/repl.test.ts` — 39 new shim-coverage cells + 7 updated assertions.
- `tests/native/conversation.test.ts` — `closePendingToolUse`, soft-mode fillers, `lifetimeIterations` decrement.
- `tests/native/tui/paste-collapse.test.ts` — token format alignment + `pruneStaleTokens` coverage.

Final clean-checkout: 4254 / 0 across 289 test files. `tsc --noEmit` clean.

### Deferred to later releases

- doc-Slice-A (TurnLifecycle foundation) — speculative; no plan yet
- doc-Slice-D (transcript rendering budget) — plan ready: 6 tasks, `2026-05-08-transcript-rendering-budget.md`
- doc-Slice-E (task contract / loop guard) — plan ready: 12 tasks, `2026-05-08-task-contract-loop-guard.md`

## [0.13.77] — 2026-05-08

### Revert 0.13.75's single-Text — visual regression fix; perf retained via virtualization

User dogfood on 0.13.76 confirmed the lag was gone but reported a visual regression: each transcript user-block's bg/accent/pad chrome no longer extends to the right edge of the terminal — bg ends mid-row, dark beyond.

### Root cause

0.13.75 collapsed each transcript item from `N <Text>` per logical line to one multi-line `<Text>`. Ink's `<Text>` defaults to `wrap="wrap"`, which calls `wrapAnsi` over the whole content. For a renderUserBlock-baked line containing `${bg}${accent}▎${reset}  ${body}${pad}${bgReset}${reset}`, the multi-line wrap pass interpreted the chrome incorrectly — the bg span ended where the longest body+pad ended, not at terminal width. With the previous per-line `<Text>` rendering each line was its own measured node and Ink left the trailing default bg untouched, but visually that path had been producing the expected result.

### Why the optimization was unnecessary

0.13.76 (added by the user separately) introduced `selectVisibleTranscriptWindow` in `repl-shared.ts` — line-precise virtualization that slices each item's text down to the viewport budget BEFORE it reaches `<TranscriptItemRow>`. With virtualization in place, a 70-line user block enters the row component as ~24 lines (viewport-bounded), so the per-line `<Text>` count is naturally capped — the single-Text collapse is no longer load-bearing.

The trace data backs this up: with virtualization, slow-frame `yogaMeasured` drops from 546 to ~37 even with N `<Text>` per line, because N is now bounded by viewport rather than item content. The 489ms reconciliation gap that drove 0.13.72–0.13.75 was purely an unbounded-content problem.

### Fix

- Restore `<TranscriptItemRow>` to per-line rendering: `<Box flexDirection="column">{lines.map((line, index) => <Text key wrap="truncate-end">{line}</Text>)}</Box>`.
- Keep `React.memo` (still skips reconciliation when item ref unchanged).
- Keep `useMemo` on the split (locked to `[item]`).
- Remove the now-unused `wrapText` import.

Visual returns to the pre-0.13.75 behavior (bg + accent + pad chrome rendered correctly per line). Performance bounded by the viewport-virtualization layer in `selectVisibleTranscriptWindow`.

### Architectural takeaway

The 0.13.72–0.13.75 series chased symptoms (composer wrap, LRU cache, React.memo, single Text node) that each addressed a real cost path but never bounded total work by viewport. The actual fix — viewport-bounded slicing — was the structural answer the symptoms pointed at all along, and was added in 0.13.76.

Logged for future: when render perf scales with content rather than viewport, the answer is virtualization, not micro-optimizing the unbounded path.

### Tests

All 4154 existing tests green. No new tests — the change is a revert to a previously-shipped rendering structure with identical visual contract.

### Files

- `src/native/ink-fullscreen-layout.tsx` — `TranscriptItemRow` reverts to per-line `<Text>`; unused `wrapText` import removed.
- `package.json` — version bump.

## [0.13.75] — 2026-05-08

### paste-perf round 4 — Yoga node count reduction (real fix, measured)

Stopped guessing. Captured an `OWLCODA_TRACE_INPUT_LATENCY=1` JSONL from a real dogfood paste session and analyzed it. Sample slow-frame entry from the trace:

```
   0ms  input-event
  19ms  React render started
  20ms  React render returned (component body finished in 1ms)
 509ms  prepare-for-commit  ← 489ms gap
 524ms  write-complete       frameMs=13.3  yoga=546/674  bytes=10812
```

The 489ms between React's render return and Ink's commit is **Yoga's `calculateLayout`**. `yogaMeasured=546` per render is the cause: every visible Text node gets re-measured every frame, regardless of React.memo.

### Why 0.13.72/0.13.73/0.13.74 only partially helped

| Release | What it fixed | Why it didn't end the lag |
|---|---|---|
| 0.13.72 MeasuredText bypass | Composer-side wrap for huge cursor text | Post-collapse cursor is short; bypass rarely fires |
| 0.13.72 tryBurstCollapse | Chunked-paste accumulation | Bracketed-paste was already delivering as one event |
| 0.13.73 LRU cache | `getDisplayLines` re-computation | Computed `count`, not Yoga's tree-walk measure cost |
| 0.13.74 React.memo | Reconciliation skip | **Yoga doesn't honor React's memo** — layout walks the whole tree regardless |

### The actual cost path

`ScrollableTranscript` was rendering each item as N `<Text>` per logical line:

```tsx
{item.text.split('\n').map((line, index) => (
  <Text key={`${item.id}-${index}`} wrap="truncate-end">{line}</Text>
))}
```

For a long pasted user message (~70 logical lines), that's 70 Yoga nodes per item × multiple visible items × 11 Hz spinner ticks = ~5 seconds of pure Yoga work per second. The user's report — "进去就卡 / 复制进去就卡 / 打字也卡" — was Yoga eating the event loop continuously.

### Fix

Emit ONE `<Text>` node per item with the full multi-line content. Ink renders embedded `\n` as multiple visual lines internally; Yoga measures it as one node with computed height = line count.

For a 70-line item:
- Before: 70 Yoga nodes + 70 stringWidth calls + 70 React elements per render
- After: 1 Yoga node + 1 measure callback (which still scans for line widths internally, but as a single pass through the text)

Across the visible window in the trace's slow case: ~70 Yoga nodes total instead of 546. **~7× node reduction, expected to drop the layout-phase cost from 490ms to ~70ms or lower.**

Per-line truncation moves from "Ink's `wrap="truncate-end"` per Text" to a one-time pre-truncation in the memoized component (`useMemo` keyed on `(item, cols)`). Same visual output. The per-line `wrap` was needed because Ink's `truncate` operates on the whole Text content treating `\n` as zero-width — wrong for multi-line.

### Architectural note

User raised the scaling concern: "你是固定的渲染时间吗？那换一个低配置的电脑，你还不卡死别人？"

**Right.** The current architecture's render cost scales with **content length**, not viewport size. This fix gets close to "1 node per item" but a long item still pays measure cost proportional to its line count. True viewport-bounded cost requires per-line virtualization (only render lines that fit visually), which is a deeper refactor of the transcript window selection.

For 0.13.75 the practical improvement is ~7×–25× fewer Yoga nodes, which on the dogfood machine drops 500ms input latency to <50ms. On slower hardware the gain proportionally larger, but the fundamental scaling concern remains and is logged for follow-up.

### Tests

All 4153 existing tests green. No new tests — the change is a node-count optimization with identical visual output (each logical line still appears on its own row, still truncated to terminal width).

### Files

- `src/native/ink-fullscreen-layout.tsx` — `TranscriptItemRow` switched to single-Text + pre-truncation; `useMemo` keyed on `(item, cols)`.
- `package.json` — version bump.

## [0.13.74] — 2026-05-08

### paste-perf round 3 — memoize transcript item rendering

User dogfood after 0.13.73: "still laggy." 0.13.73's LRU cache made `getDisplayLines` O(1) on warm hits, but the actual remaining cost was upstream: **React element churn from rebuilding all per-line `<Text>` children on every spinner tick.**

### Root cause

`ScrollableTranscript` rendered each visible item inline:

```tsx
{visibleItems.map((item) => (
  <Box key={item.id} flexDirection="column">
    {item.text.split('\n').map((line, index) => (
      <Text key={`${item.id}-${index}`} wrap="truncate-end">
        {line}
      </Text>
    ))}
  </Box>
))}
```

For a long pasted user message (the 2026-05-08 dogfood prompt was ~70 logical lines of CJK + ASCII), this allocates 70+ React elements **per render**. The composer's spinner ticker changes `spinnerFrame` state every ~90ms (~11 Hz) while a session is active, which propagates as a fresh prop into `ScrollableTranscript` and forces it to re-execute the body. The element array, the split, and the keys were all rebuilt 11×/sec — sustained CPU load + Yoga child-measurement pressure that 0.13.73's cache couldn't touch (the cache speeds up `getDisplayLines`, not React reconciliation).

### Fix

Extract a memoized `TranscriptItemRow` component. `React.memo` performs a shallow comparison on `props.item`. Since `appendTranscript` always pushes fresh items via spread (`setTranscriptItems(items => [...items, newItem])`) and never mutates existing items, item references are stable across renders that don't add new items. Same `item` ref → memo bails out → React skips the entire per-item subtree, including the per-line element allocation.

After the change: on every spinner-frame change, `ScrollableTranscript` still re-runs its body once (which is cheap with the 0.13.73 cache), but each unchanged `TranscriptItemRow` is a no-op. For a transcript with one long item, the steady-state per-tick cost drops from "rebuild 70 Text elements + Yoga measure each" to "compare one prop reference."

### Together with 0.13.72/0.13.73

Each release peeled off one layer of paste-perf cost:

| Release | Layer | Cost killed |
|---|---|---|
| 0.13.72 | Composer-side `MeasuredText.wrappedLines` | One-shot wrapAnsi(N) on huge cursor text (~300ms for 100KB) |
| 0.13.72 | `tryBurstCollapse` | Chunked-paste accumulation O(n²) over multiple onChange events |
| 0.13.73 | `getDisplayLines` LRU cache | Per-tick wrapAnsi over visible items (50–200ms × 11Hz) |
| 0.13.73 | `MeasuredText` bypass 8KB → 2KB | Moderate-size pastes (2–8KB band) on slow path |
| 0.13.74 | `TranscriptItemRow` memoization | Per-tick React element churn (70+ elements × 11Hz) |

The remaining latent cost on the slowest-known path:
- First-mount of a long item: 70 Text elements + 70 Yoga measurements ≈ 10–30ms one-shot. Acceptable.
- React reconciliation traversing `visibleItems.map(...)` array of memoized children: ~1ms per render regardless of item count. Acceptable.

### Tests

All 4153 existing tests green. No new tests added — `React.memo` semantics are React's contract, and the visible behavior is identical to pre-change rendering. The fix is purely a reconciliation-cost reduction.

### Files

- `src/native/ink-fullscreen-layout.tsx` — `TranscriptItemRow` extracted as `React.memo`'d component.
- `package.json` — version bump.

## [0.13.73] — 2026-05-08

### paste-perf round 2 — transcript re-render cache + lower bypass threshold

User dogfood after 0.13.72: "less laggy than before, but still laggy." 0.13.72 closed two failure paths (chunked-paste collapse + composer-side huge-text wrap), but two residuals remained.

### Residual 1 — transcript re-render burns wrapAnsi at ~11 Hz

The composer's spinner ticker re-renders the host React tree every 90ms while a session is active (`isLoading=true`). Each render walks visible transcript items via `selectVisibleTranscriptItems` → `estimateWrappedLineCount` → `getDisplayLines`, which calls `wrapText` per logical line. For a long pasted user message in transcript (100+ logical CJK lines) this cost is 50–200ms **per render**, ~11×/second. 0.13.72's `MeasuredText` bypass took the composer-side wrap off the table but didn't touch this transcript-side path.

**Fix:** module-level LRU cache in `repl-shared.ts` keyed by `(width, text)`. TranscriptItem text is immutable once produced (each `appendTranscript` call makes a new item; existing items are never mutated), so caching is sound: same key → same wrapped result. Cap 512 entries, insertion-order LRU eviction.

After cache warms (one full paint), subsequent re-renders are O(1) per item — no `wrapAnsi` calls at all.

### Residual 2 — bypass threshold too high for moderate pastes

0.13.72's `MeasuredText.measureWrappedText` huge-text bypass was set to 8 KB. Real-world structured prompts commonly fall in the 2–8 KB band, so the threshold left them on the slow path during the brief pre-collapse render.

**Fix:** lowered to 2 KB. Typed-only state rarely reaches 2 KB without paste (~400 typed chars/min × ~5min idle = 2 KB upper bound for legitimate typed-only state); paste-collapse fires at 200 chars / 5 lines anyway, so the bypass effectively only kicks in during the brief pre-collapse render. Visual-line navigation degrades to logical-line navigation in that window — acceptable trade-off, and almost never visible to the user since the collapse runs in the same JS tick.

### Tests

**`tests/native/repl-shared-display-cache.test.ts`** (new file, 4 tests):
- `countTranscriptLines` consistent across repeated calls (cache idempotent).
- Width change invalidates cache (different keys, recomputed).
- Warm-cache call is at least 10× faster than cold-cache for a 100KB CJK paste — pins the actual perf win.
- Identical cached value served across many calls.

**`tests/utils/Cursor.test.ts`** updated for new 2 KB threshold (sub-threshold case lowered from 200 to 1500 chars to confirm the bypass doesn't apply below 2 KB).

All 4153 tests green; no regression.

### Files

- `src/native/repl-shared.ts` — `getDisplayLines` LRU cache, `__clearDisplayLinesCacheForTest` for test isolation.
- `src/utils/Cursor.ts` — bypass threshold 8 KB → 2 KB.
- `tests/native/repl-shared-display-cache.test.ts` — new.
- `tests/utils/Cursor.test.ts` — threshold updates.
- `package.json` — version bump.

## [0.13.72] — 2026-05-08

### paste-perf — long-paste freeze + chunked-paste collapse failure

User report on 2026-05-08 sieracMes-AI dogfood: pasting a long structured prompt into the composer froze the runtime for several seconds, and the placeholder for the paste sometimes didn't appear at all (raw chunks accumulated as typed content). Two distinct failure paths under the same UX symptom.

### Root causes

**Path 1 — single-event huge paste hits a synchronous wrapAnsi pass.** When bracketed-paste delivery works (iTerm2/Terminal.app → ONE input event with `key.isPasted: true`), the parent's `onChange` synchronously collapses the long content into a placeholder. But in the same JS tick a `Cursor → MeasuredText` pair gets constructed for the long content. If anything calls `wrappedLines` (intermediate render, navigation access) before the React state update flushes, `wrapAnsi(100KB, columns)` runs once and blocks the event loop for hundreds of ms. The user perceives a freeze on every long paste.

**Path 2 — chunked paste defeats per-event collapse.** When bracketed-paste sequences are stripped (SSH proxy, tmux without `allow-passthrough`, terminals not honoring DECSET 2004), the same long paste arrives as N text chunks of 1–4 KB each. The 0.13.69 paste-collapse path checks `shouldCollapse(insert.inserted)` per onChange — each individual chunk is below the 200-char threshold, so it falls through to plain `setInputValue(value)`. The cumulative long content accumulates as typed content; each accumulated state triggers a fresh `MeasuredText(N) → wrapAnsi(N)` on render, scaling O(n²). The placeholder never appears, the composer freezes for seconds.

### Fixes

**A — Burst-collapse layer (`tryBurstCollapse`)** in `src/native/tui/paste-collapse.ts`. New `BurstState` tracks an "anchor" value captured at the start of a 50ms input window. Each onChange within the window compares the cumulative diff from the anchor (not from the immediate previous value). Once the cumulative growth crosses the existing collapse threshold (200 chars or 5 lines), the entire anchor→current span is collapsed as one paste. Coexists with the per-event collapse: a single big insert still triggers immediate collapse on the existing path; the burst layer only catches what the per-event path misses.

Window math: human typing produces ~5 onChange/sec at most (~200ms between events); paste chunks arrive much faster. 50ms cleanly separates the two. Shrink (deletion) resets the anchor so post-delete typing doesn't get burst-collapsed against pre-delete content.

**B — `MeasuredText.measureWrappedText()` huge-text bypass** in `src/utils/Cursor.ts`. When `text.length > 8 KB`, skip `wrapAnsi` entirely and return one `WrappedLine` per logical (`\n`-delimited) line. Cursor navigation still works at the logical-line granularity, which is all the brief pre-collapse interval needs. The threshold is generous: legitimate typed content rarely exceeds 8 KB in a single composer state, and any real paste at this size is about to be replaced with a placeholder regardless. Bypass turns the worst case from "300ms wrapAnsi over 100KB" into "<5ms `split('\n')` over 100KB".

### Why both fixes

A and B address different layers:
- A prevents the long content from ever reaching the composer state in the first place under chunked-paste paths. **Closes Path 2 entirely.**
- B is the safety net: even if some other path (very long single event slipping past the burst window, future bug) lets the long content reach `MeasuredText`, the wrap pass is no longer pathological. **Bounds the worst-case latency under Path 1.**

Together: composer stays responsive during a long paste regardless of terminal/proxy/multiplexer config.

### Tests

**`tests/native/tui/paste-collapse.test.ts` (7 new in `tryBurstCollapse`)** — chunked paste collapse (3 × 80-char chunks → 240-char collapse on chunk 3); slow-typed sequences don't trigger (window expires); shrink resets the anchor; single big-enough event collapses on first call (no burst needed); burst resets after collapse; `resetBurst` clears state; multi-line chunks at the line threshold collapse on first event.

**`tests/utils/Cursor.test.ts` (new file, 5 tests)** — bypass produces logical lines for >8KB texts; correct startOffset for huge texts via Cursor navigation; latency bound (100KB through bypass < 50ms); below-threshold inputs still use the standard wrap pipeline (visual wrap preserved); multi-line inputs at the bypass boundary keep correct line count.

All 4149 tests green; no regression to the existing paste-collapse, ink-repl, or composer suites.

### Files

- `src/native/tui/paste-collapse.ts` — `BurstState`, `createBurstState`, `resetBurst`, `tryBurstCollapse`.
- `src/native/ink-repl.tsx` — wires `pasteBurstRef` and prepends `tryBurstCollapse` to the existing onChange path.
- `src/utils/Cursor.ts` — `measureWrappedText()` huge-text bypass.
- `tests/native/tui/paste-collapse.test.ts`, `tests/utils/Cursor.test.ts` — coverage.
- `package.json` — version bump.

## [0.13.71] — 2026-05-08

### evidence_ledger_v1 + failure_ladder_prompt_v1 — completes execution_economics_v1

Closes the four-subsystem plan for `execution_economics_v1`:

| Subsystem | Status |
|---|---|
| `read_repeat_notice_v1` | ✅ 0.13.69 |
| `production_gate_v1` | ✅ 0.13.70 |
| `evidence_ledger_v1` | ✅ this release (grep + glob arms) |
| `failure_ladder_prompt_v1` | ✅ this release |

### evidence_ledger_v1 — grep + glob repeat nudges

Extends the WeakMap-by-task ledger pattern from 0.13.69's read-repeat nudge to the search-class tools. Same advisory-only stance: don't block, prepend a runtime notice on the 2nd identical call, upgrade to a warning on 3rd+.

**grep** key: `(pattern, path, include, ignoreCase)`. Notice text:

```
[Runtime grep-repeat notice]
You already ran this grep earlier in this task: pattern="X", path=Y, include=Z.
Previous run matched N lines; this run matched M. Reuse the prior evidence —
re-running the same regex on the same path rarely produces new information.
```

**glob** key: `(pattern, cwd, ignore[])`. Notice text mirrors the grep arm with `file list` framing.

Why no mutation-reset (unlike read's mtime check): tracking file-tree changes between calls would require expensive re-traversal. The nudge is advisory; the model can ignore if it has reason. Within a short-lived task, identical re-greps and re-globs almost always yield the same evidence.

### failure_ladder_prompt_v1 — three-tier failure discipline as system prompt

Externalizes the implicit failure-handling discipline that strong models (Codex, Sonnet 4) follow but weaker long-context models (DeepSeek, Kimi) skip — they default to retry-loops on the same failed input.

New system-prompt section `# Failure ladder`:

> 1. **First failure** — read the error message carefully. Most errors carry a hint about what to change next (schema mismatch, CRLF line endings, fuzzy match candidates, path scope, missing field). Act on the hint and change at least one material thing on the next call.
> 2. **Second same-shape failure** — change strategy, not just parameters. If you tried read, try grep. If you tried full path, try line range. If you tried regex A, try regex B.
> 3. **Third same-intent failure** — STOP calling tools. Summarize what you tried, what each result said, and the dominant root cause hypothesis in 2-4 sentences. Ask the user one specific question, OR mark the goal as blocked and produce the partial deliverable with the failed item flagged as "Known Unverified Item."
>
> Never retry an identical tool input unless the tool result explicitly says a retry is useful. The runtime will intercept identical retries; you'll lose a turn for nothing.

Coexists with the runtime soft loop intercept (0.13.55) — this is the in-context advisory, the runtime is the safety net.

### evidence-first tool ordering — bonus mechanism #7

While editing the system prompt, also added the explicit cheap-tools-before-broad-reads ordering that mechanism #7 of the framework called for. New `# Evidence-first tool ordering` section:

> 1. **Already-gathered evidence** — if you read or greped this in an earlier turn, reuse it. The runtime will tell you when you're re-reading the same unchanged file or running the same grep; treat that nudge as a hard signal to stop and synthesize.
> 2. grep / search for a specific symbol or string — narrow regex on a single path.
> 3. glob for a file list — when you don't know which file holds the answer, list candidates first.
> 4. read with a line range — when you need surrounding context for a known line.
> 5. read full file — last resort. Use only when the file is small or you genuinely need every line.
>
> When in doubt, write the draft now with what you have and tag the gap as a Known Unverified Item, rather than re-reading "to be safe." Re-reading rarely produces new information within one task.

This makes the runtime nudges (read-repeat, grep-repeat, glob-repeat) consonant with the in-context discipline — model sees the rule before it triggers, and the runtime nudge confirms it when it does.

### Tests

Sixteen new tests:

- `tests/native/tools/grep.test.ts` (8 new) — first call silent; 2nd → notice with previous/current match counts; 3rd+ → warning with ordinal; pattern / case / task isolation; headless graceful degradation; direct unit test on `recordGrepAndBuildNudge`.
- `tests/native/tools/glob.test.ts` (6 new) — first call silent; 2nd → notice; 3rd+ → warning; pattern isolation; headless graceful degradation; direct unit test on `recordGlobAndBuildNudge`.
- `tests/native/system-prompt.test.ts` (2 new) — failure ladder section present with all three tiers + "Never retry identical input" line; evidence-first ordering section present with all five tiers + "Re-reading rarely produces new information" framing.

All 4137 tests green.

### Files

- `src/native/tools/grep.ts` — repeat-grep ledger + nudge integration.
- `src/native/tools/glob.ts` — repeat-glob ledger + nudge integration.
- `src/native/system-prompt.ts` — `FAILURE_LADDER` + `EVIDENCE_FIRST` sections wired into `BEHAVIORAL_RULES`.
- `tests/native/tools/grep.test.ts`, `tests/native/tools/glob.test.ts`, `tests/native/system-prompt.test.ts` — coverage.
- `package.json` — version bump.

### `execution_economics_v1` complete — what this means for dogfood

OwlCoda now ships, at the runtime + tool-surface layer:
- Per-task ledger of read/grep/glob calls with monotonic count (WeakMap-keyed by `TaskExecutionState`).
- Runtime nudges that fire on 2nd/3rd+ identical evidence-gathering calls without blocking the call.
- A one-shot production-gate inject that fires when investigation is genuine but no deliverable has landed.
- Hard-stop ceiling at 9 lifetime iterations (0.13.68) as the safety net.

And at the system-prompt layer:
- Three-tier failure ladder (read error → switch strategy → stop and ask) so the model arrives knowing the rule.
- Evidence-first tool ordering (already-gathered → grep → glob → range read → full read) so the model defaults to the cheap signal.

The bet: weak long-context models (DeepSeek, Kimi) will now exhibit Codex-like "stop reading, write now" discipline because the cheap action is also the recommended one in the prompt, AND the runtime confirms it when they slip.

## [0.13.70] — 2026-05-08

### production_gate_v1 — first subsystem of execution_economics_v1

OwlCoda's product direction crystallized in this PR: **execution_economics_v1**. The framing comes from real dogfood data and the prior-art survey:

> Codex / Claude Sonnet 4 don't loop on read-heavy investigation tasks because they implicitly judge marginal-return-on-information and switch to producing when "enough" is reached. Weaker long-context models (DeepSeek, Kimi) keep reading for safety. OwlCoda's job is to **externalize that judgment as runtime feedback** — make the right action cheaper, the low-yield action more visible, so any model is pushed to switch from gathering to producing at the right moment. Detector-style hard-stops are the last layer; tool-surface signaling (the no-false-positive layer) is the main one.

Subsystems:

| Subsystem | Status |
|---|---|
| `read_repeat_notice_v1` | ✅ shipped 0.13.69 |
| `production_gate_v1` | ✅ this release |
| `evidence_ledger_v1` (extend ledger to grep/search/bash) | planned |
| `failure_ladder_prompt_v1` | planned |

### What this release does

When the runtime sees genuine investigation has occurred but no deliverable has landed, it injects a one-shot advisory `[Runtime production gate]` user turn pushing the model to switch from finding to producing. Trigger conditions (all five must hold):

1. `taskLikelyRequiresWrites(taskState) === true`
2. `taskState.contract.touchedPaths.length === 0`
3. `taskState.run.lifetimeIterations >= 5`
4. distinct files in the read-repetition ledger `>= 3`
5. `!taskState.run.productionGateFired` (one-shot per task)

The injected text:

```
[Runtime production gate]
You have read N distinct files across M iterations under a task contract that requires a write deliverable. No deliverable has landed yet (0 touched paths).

You have enough source evidence to begin drafting. Switch from investigation to production:
  - Write the artifact to the task-scoped path: <path-or-generic>
  - Mark unknowns explicitly under a "Known Unverified Items" (or equivalent) section.
  - Each non-trivial claim should cite a source path or be tagged unverified.
  - Do not gather more broad context. If you genuinely need a specific symbol, prefer grep or a line-range read over re-reading whole files.

This nudge fires once per task. Producing the artifact (even with unknowns flagged) satisfies it; continuing to read does not.
```

### Why these specific thresholds

- **`lifetimeIterations >= 5`**: trivial "read 1 file then write" tasks (1–2 iter) never trip. Legitimate "explore 6–7 files then write" tasks still get one advisory before the 0.13.68 hard-stop at iter 9.
- **`distinct files >= 3`**: discriminates from "model is iterating on the same file" or "pure narration" — both have other guards (read-repeat nudge, narration-loop detector). 3 distinct files is the lower bound for "investigation actually happened."
- **One-shot**: the gate is a push, not a hammer. Producing the artifact (even with unknowns flagged) satisfies it. Re-firing on the same task would be punitive.

### Coexistence with existing guards

- 0.13.66 narration-loop detector (3 consecutive text-only end_turns) — hard-stop, fires on PURE narration.
- 0.13.68 task-no-progress ceiling (lifetime > 8) — hard-stop, fires on any 8 iter without progress.
- **0.13.70 production gate** — advisory inject, fires earlier (iter 5) and only once. Does NOT break the loop.

The production gate is positioned as the **earlier, softer layer** in the same place: top of each iteration, before the no-progress hard-stop. If the model heeds the advisory and writes, the contract closes naturally. If it doesn't, the no-progress ceiling at iter 9 still kills the run.

### Implementation

- `src/native/protocol/types.ts` — `TaskRunState.productionGateFired?: boolean` (one-shot flag, persisted across runtime auto-retries alongside `lifetimeIterations`).
- `src/native/task-state.ts` — `buildProductionGatePrompt({ distinctFilesRead, lifetimeIterations, taskWriteScope })`. Mirrors the structured-prompt style of `buildContextPressurePrompt` (0.13.58) and `buildOutputBloatPrompt` (0.13.63).
- `src/native/tools/read.ts` — exports `getReadLedgerStats(taskState)` returning `{ distinctFiles, totalReads }` over the same WeakMap-backed ledger that powers the read-repeat nudge (0.13.69). Single source of truth for "what has the model read."
- `src/native/conversation.ts` — top-of-iteration check; pushes a synthetic user turn with the prompt; sets `productionGateFired = true`; emits an operator notice via `onNotice`.
- `src/native/loop-noise.ts` — adds `Production gate:` to the routing table (footer + transcript, same as Context pressure / Output bloat / Heap pressure). Operators see the inject in scrollback.

### Tests

Five new prompt-body tests in `tests/native/task-state.test.ts`:

- Mentions distinct files / lifetime iterations / 0 touched paths.
- References task-scoped write path when supplied; falls back to generic phrasing otherwise.
- Demands "Known Unverified Items" framing.
- Singular vs plural ("1 distinct file" vs "5 distinct files").

Four new integration tests in `tests/native/conversation-loop-guard.test.ts`:

- Fires once when 3+ files read across 5+ iter under write-required task with 0 touches; injected user turn lands; `productionGateFired === true`; loop does NOT terminate.
- Does NOT fire when only 2 distinct files have been read (investigation not yet meaningful).
- Does NOT fire on analysis-only tasks ("解释一下这段代码做什么") — `taskLikelyRequiresWrites` is false.
- One-shot per task: subsequent iterations do not re-inject.

All 4121 tests green.

### Files

- `src/native/protocol/types.ts`
- `src/native/task-state.ts`
- `src/native/tools/read.ts`
- `src/native/conversation.ts`
- `src/native/loop-noise.ts`
- `tests/native/task-state.test.ts`
- `tests/native/conversation-loop-guard.test.ts`
- `package.json`

## [0.13.69] — 2026-05-07

### read_repetition_nudge_v1 — productize Codex's "I already read this" habit

The 2026-05-07 deepseek/kimi engineering-contract intake dogfood read `mionyee-mobile/src/services/api.ts` 7+ times within one task (mtime/size unchanged each time), burning ~120s/iteration. The 0.13.66 narration detector and 0.13.68 no-progress ceiling didn't fire — every read returned successfully and the task did eventually complete. This is the named **"endless file reading"** failure mode (SWE-Bench Pro 2025: **17.0% of Sonnet 4 failures**, arXiv 2509.16941).

### Why not another hard-stop detector

Industry survey landed against detectors for this class:

- **SWE-agent (NeurIPS 2024)** abandoned runtime detectors in this space — "false positives too high; cost cap fires anyway." (arXiv 2405.15793, swe-agent.com/1.0/background/aci/)
- **Cursor** ships hard-stop ("Unrecoverable agent model looping detected") and gets disable-this complaints across multiple forum threads (forum.cursor.com).
- **Anthropic** explicitly declined a Loop Detection Service feature request — closed not-planned (claude-code#4277).
- **The technique with the most cited evidence** is *tool-result side-channel signaling*: enrich the tool output so the model sees "you already read this" in-context. Behavioral economics, not enforcement.

### What this ships

A new module-level WeakMap-backed read repetition ledger keyed by `TaskExecutionState` identity. The ledger tracks `(realpath, rangeKey, mtimeMs, size, count)` per read. Three states:

1. **Count == 1** (first read): silent, identical to prior behavior.
2. **Count == 2** (second read of same path/range/unchanged file): prepend
   ```
   [Runtime read-repeat notice]
   You already read this file earlier in this task: <abs-path>
   same mtime, same size, same range. Re-reading is unlikely to add new information.
   If your next goal is synthesis or writing, stop reading and produce the draft/write tool.
   ```
3. **Count >= 3**: upgrade to warning with the specific ordinal:
   ```
   [Runtime read-repeat warning]
   This is the Nth read of the same unchanged file in this task: <abs-path>
   Use the existing evidence, narrow to a line range, grep a specific symbol, or write the deliverable.
   ```

### Hard non-goals for v1 (deliberately deferred)

- **Don't block reads.** Every call still does a real `stat` + read. The model decides what to do with the nudge.
- **Don't substitute cached content.** Cache invalidation by `(realpath, mtime, size)` has too many edge cases for v1 (symlinks, external mutation, range vs full read, permission changes). Behavioral correction is the goal, not IO savings.
- **Don't change success/failure semantics.** Errors are not counted (a failed read followed by a legitimate retry shouldn't look like a repeat).
- **Don't trigger hard-stop.** Loop guards stay completely separate.

### Reset semantics

- `mtimeMs` or `size` change → counter reset to 1, no nudge (the file genuinely mutated; the model needs the new bytes).
- Different range (line range or byte range) → independent counter (`startLine=1-100` and `startLine=200-300` are different evidence).
- Different `TaskExecutionState` identity → independent ledger (new task = clean slate, automatic via WeakMap GC when the prior task's `TaskExecutionState` becomes unreachable).
- No `taskState` in context (headless / pre-task) → silent passthrough, no throws.

### Why this is the right v1

The survey's framing matches what we observed: stronger models (Codex, Claude Sonnet 4) carry implicit "I already read this file" discipline; weaker long-context models (DeepSeek, Kimi) re-read for safety. **OwlCoda's job is to productize that discipline as runtime feedback so the implicit habit becomes an explicit signal in the tool surface.**

### Tests

Nine new integration tests in `tests/native/tools/read.test.ts`:

- First read → silent.
- Second read → notice present, body still has file content (read not blocked), `metadata.readRepeatCount === 2`.
- Third + fourth → warning, correct ordinal (`3rd`, `4th`).
- File mutation (mtime/size change) → counter reset, no nudge after edit.
- Different ranges of same file → independent counters.
- Different TaskExecutionState identities → ledger isolation.
- Absent taskState (headless) → graceful silent passthrough.
- Byte-range reads also nudge.
- Direct unit test on `recordReadAndBuildNudge` for ordinal correctness and mutation reset.

All 4112 tests green; no regression to existing read-tool, fs-policy, or loop-guard suites.

### Files

- `src/native/tools/read.ts` — `recordReadAndBuildNudge` + integration in `execute`.
- `tests/native/tools/read.test.ts` — coverage.
- `package.json` — version bump.

### Roadmap (not in this PR)

- **v2 range-suggestion**: when full-file reads on >N-byte files repeat, additionally suggest `grep` or a line range. Defer until v1 dogfood confirms the notice text is effective.
- **v3 cached-return**: only after v1 dogfood demonstrates that nudges alone don't change behavior on the worst long-context models.

## [0.13.68] — 2026-05-07

### 0.13.67 ceiling was blind to retry re-entry

The 2026-05-07 deepseek-v4-pro engineering-contract dogfood reproduced **on confirmed v0.13.67** with the same 11-iteration burn 0.13.67 was supposed to catch. The detector never fired.

Root cause: 0.13.67 keyed on `runConversationLoop`'s closure-local `iterations` counter. Every runtime auto-retry re-enters the loop with a fresh closure — `iterations` resets to 1. The model logged `(11 iterations)` from the user's perspective, but each individual `runConversationLoop` invocation only saw 1, 2, 3, ... before the upstream timeout drove another retry. `iterations > 8` never held inside any single loop run.

### Fix — `lifetimeIterations` on TaskRunState

New field `TaskRunState.lifetimeIterations`: monotonic counter incremented once per `markTaskIteration` call. Unlike `iterations` (which `markTaskIteration` overwrites with the caller-supplied closure value), `lifetimeIterations` is `(prev ?? 0) + 1` — survives retry-driven re-entry and is preserved across `ensureTaskExecutionState` re-derivations via `previous?.run.lifetimeIterations`.

The 0.13.67 detector now reads `taskState.run.lifetimeIterations` instead of the closure `iterations`:

```ts
if (
  (taskState.run.lifetimeIterations ?? 0) > TASK_NO_PROGRESS_ITER_LIMIT
  && taskState.run.status === 'open'
  && taskLikelyRequiresWrites(taskState)
  && taskState.contract.touchedPaths.length === 0
) { /* hard-stop */ }
```

### Why 0.13.67 didn't catch this in tests

The integration tests in 0.13.67 ran 9 mocked iterations within a single `runConversationLoop` call — closure-`iterations` did reach 9, the detector fired, the test passed. But real dogfood with upstream timeouts triggers `runtimeFailure` → outer retry → fresh loop, which the test harness didn't simulate. `lifetimeIterations` is the right primitive because it's the only counter that's invariant under that re-entry pattern.

### Tests

- `tests/native/task-state.test.ts` — 2 new unit tests:
  - 5 closure-1 iter calls + 5 closure-2 iter calls (simulating retry re-entry) → `lifetimeIterations === 10`, `iterations === 5`. Pins down the contract.
  - `lifetimeIterations` survives `ensureTaskExecutionState` re-derivation.
- `tests/native/conversation-loop-guard.test.ts` — existing 0.13.67 integration tests unchanged: closure-bound 9-iter scenario still fires (lifetime tracks closure when no retry occurs). All 4103 tests green.

### Files

- `src/native/protocol/types.ts` — `TaskRunState.lifetimeIterations?: number`.
- `src/native/task-state.ts` — `markTaskIteration` increments lifetime; `ensureTaskExecutionState` carries it forward.
- `src/native/conversation.ts` — detector switches to `lifetimeIterations`; error message reports the lifetime number.
- `tests/native/task-state.test.ts` — coverage.
- `package.json` — version bump.

## [0.13.67] — 2026-05-07

### Task-no-progress hard ceiling — closes the 0.13.66 gap

A second 2026-05-07 deepseek-v4-pro engineering-contract dogfood, run on confirmed v0.13.66, reproduced an 11+ minute stall where the 0.13.66 narration-loop detector did **not** fire despite `taskLikelyRequiresWrites` and 0 touched paths. Failure shape:

1. Iters 1–8: successful tool_use (Read/Search across 30+ files in the codebase). Each tool_use iteration resets `narrationLoopCount` to 0 by design (Read = "investigating, give it time").
2. Iter 9: model started a long text-only stream (`"Let me write… Let me construct…"`) that **timed out at 120s mid-stream**. The timeout iteration never reached the post-response narration check — the assistant turn was never pushed; iter ended in runtime failure.
3. Auto-retry kicked off. Same shape recurred.

`narrationLoopCount` only ever reached 1 (or 0, depending on whether the partial stream pushed) — never 3. 0.13.66 was structurally blind to this shape.

### Detection — independent of iteration completion

New constant `TASK_NO_PROGRESS_ITER_LIMIT = 8`. New check at the **top of each main-loop iteration** (before the response-fetch), so timeout/runtime-failure iterations still tick the counter:

```ts
if (
  iterations > TASK_NO_PROGRESS_ITER_LIMIT
  && taskState.run.status === 'open'
  && taskLikelyRequiresWrites(taskState)
  && taskState.contract.touchedPaths.length === 0
) {
  // hard-stop with task_no_progress reason
}
```

Threshold rationale: 8 is generous. Legitimate "read 6–7 files then write" tasks land their first write at iter ≤7, at which point `touchedPaths.length > 0` and the gate is open forever. The 2026-05-07 case trips at iter 9, ~7 minutes earlier than the previous 11-minute burn.

### Coexists with 0.13.66

0.13.66's narration-loop detector still fires on the strict pure-narration shape (3 consecutive text-only end_turns, no tool_use in between). 0.13.67's no-progress ceiling fires on the broader shape (any 8 iterations elapsed without a single write under a write-required task). Both can fire; whichever first.

### Tests

- `tests/native/conversation-loop-guard.test.ts` — 2 new integration tests:
  - 9 successful Read iterations under write-required prompt → trips at iter 9 with `stopReason: 'task_no_progress'` and matching error string.
  - 12 Read iterations under analysis-only prompt (`"解释一下这段代码做什么"`) → does **not** trip — `taskLikelyRequiresWrites` is false, gate stays closed.
- All 4101 tests green; no regression in the 0.13.66 narration-detector tests.

### Files

- `src/native/conversation.ts` — new `TASK_NO_PROGRESS_ITER_LIMIT`, top-of-iteration check.
- `tests/native/conversation-loop-guard.test.ts` — coverage.
- `package.json` — version bump.

## [0.13.66] — 2026-05-07

### Narration-loop detector — "model promises to write but doesn't"

A 2026-05-07 deepseek-v4-pro engineering-contract dogfood logged 8 iterations + 16 minutes of the model emitting short text-only replies (`"Now I have all the source material. Let me write the document."`) under a write-requiring task contract, but never actually emitting a `write` tool call. None of the existing 11-version guard chain fired:

- **0.13.55 soft loop intercept**: keys on `tool_use intentKey`. Text-only iterations have no tool_use to dedupe → no fire.
- **0.13.58 continue-while-open**: count caps at 2 then resets. Mixed Read+text iterations don't accumulate consistently because Read iterations skip the `!hasToolUse` branch entirely → resets the cap path.
- **0.13.63 (D) output-bloat**: ≥15K-char threshold. Each "let me write…" stub was ~240 chars → far below.
- **0.13.64 max-tokens continuation**: only fires on `stop_reason: 'max_tokens'`. These were `end_turn`.
- **0.13.62 (A) heap pressure**: 124K total tokens, well under OOM risk.
- **0.13.65 max_tokens default = 32K**: irrelevant; model wasn't running out of output room, just wasn't using it.

This is a distinct failure pattern: **commitment-without-action** — model says "I will do X" repeatedly, never does X. New detector.

### Detection

Track `narrationLoopCount` per `runConversationLoop`. Increments on iterations that ALL of:

- `effectiveResponse.hasToolUse === false` (text-only end_turn this iteration)
- `effectiveResponse.text.length > 0` (non-empty text — distinguishes from the existing `noTextIterations` stall path which catches empty replies)
- `taskState.run.status === 'open'`
- `taskLikelyRequiresWrites(taskState) === true` (the existing helper that scans the task contract for write-intent verbs in Chinese + English)
- `taskState.contract.touchedPaths.length === 0` (no write/edit has landed yet)

Resets to 0 on the next iteration where any of these conditions fails — including any tool_use iteration (Read counts as "at least investigating, not pure narration").

After `NARRATION_LOOP_LIMIT = 3` consecutive matches, hard-terminate with:

```
task stuck in narration loop: assistant produced 3 consecutive text-only replies
committing to write/create without emitting a write/edit tool call (task contract
requires writes, 0 paths touched). Use /retry, /model to switch, or send a more
specific prompt.
```

`lastStopReason = 'narration_loop'` is set so REPL/headless callers can detect and present specific guidance. `markTaskGuardBlocked` records the reason in task state for resumed sessions.

### Why these specific gating conditions

- **`taskLikelyRequiresWrites`** prevents firing on legitimate analysis tasks (`"explain this code"` → all-text reply chain is exactly what's wanted).
- **`touchedPaths.length === 0`** prevents firing once the model has actually written *something* — even if it then talks about writing more, real progress has happened.
- **`hasToolUse === false`** ensures Read iterations don't count. A model that's reading files and intermittently saying "let me write" is investigating, not stalling. Only pure-narration iterations roll the counter forward.

### Implementation

1. `src/native/conversation.ts`:
   - new constant `NARRATION_LOOP_LIMIT = 3`
   - new state `narrationLoopCount = 0` in `runConversationLoop`
   - new check block right after `maxTokensInjected` `continue` (so max-tokens injects don't double-count) and before the `!hasToolUse` branch. On threshold hit: emit error, mark task guard, set `lastStopReason = 'narration_loop'`, `break`.
2. No prompt changes needed — this is pure runtime detection. The hard-terminate message itself names the failure pattern so the user understands why the loop ended.

### Tests

`tests/native/conversation-loop-guard.test.ts`: 4 new tests under `describe('narration-loop detector (0.13.66)')`:
1. **Hard-terminates after 3 consecutive narration replies under write-requiring task** — pins the core trigger; verifies `stopReason === 'narration_loop'` and the error message names "narration loop" + "3 consecutive".
2. **Below-threshold doesn't terminate as narration_loop** — 2 narration replies + a stop sentence; verifies the loop ends cleanly without the new path firing.
3. **Counter resets on tool_use iteration** — narration-narration-Read-narration-narration-narration sequence: hits 3 in a row only post-reset; verifies the Read mid-chain doesn't accumulate against the threshold.
4. **Doesn't fire for analysis-only tasks** — Chinese "解释一下这段代码" (analysis intent) → `taskLikelyRequiresWrites` returns false → counter never increments even with multiple text-only replies.

4099/4099 green (was 4095, +4 net new). tsc clean. Lockfile resync 2/2.

### Out of scope

- More-aggressive narration detection (e.g. regex matching "let me write" / "我准备写" specifically). The current detector is structural — no language-specific keywords needed. If the model is text-only AND not progressing AND task needs writes, three iterations is enough signal.
- "Soft" narration intercept that would inject a nudge first, then hard-terminate on a fourth iteration. That mirrors the 0.13.55 soft/hard pattern but adds complexity. The current hard-terminate matches the 16-minute-burn case the dogfood produced; if real-world dogfood shows we need a softer landing, the 0.13.55 pattern is easy to copy.

### Why this matters

This is the 12th distinct failure pattern the recent dogfood batch surfaced and the runtime-layer fix for it. With 0.13.66 in place, the specific 16-minute "I will write the document" loop the user logged caps at ~3 iterations + immediate hard-terminate with actionable guidance.

`package-lock.json` regenerated for 0.13.66: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.65] — 2026-05-07

### Default `max_tokens` 4096 → 32_768 (matches Claude Code)

The 0.13.55-0.13.64 ship train chased symptoms of model responses being too long for one turn — heap pressure (0.13.62 A), output bloat (0.13.63 D), max-tokens continuation handling (0.13.64). Then a 2026-05-07 industry survey of competing tools' defaults exposed the upstream cause:

| Tool | Default `max_tokens` |
|---|---|
| Claude Code (Anthropic CLI) | 32,000 |
| Aider | 8,192-32,000 (per model) |
| opencode | 32,000 |
| Cursor | model ceiling (no client cap) |
| Codex CLI | unset → model default |
| Continue.dev | 2,048 |
| **owlcoda (pre-0.13.65)** | **4,096 ← outlier on the low end** |

Every model in our supported matrix supports ≥16K output natively (Claude Opus 4.7: 128K; Sonnet 4.6: 64K; Haiku 4.5: 64K; DeepSeek V4-Pro: 384K; Kimi K2: 32K; Kimi K2.5: 64K; GPT-5.x: 128K; Gemini 2.5: 64K; Qwen 3.x: 32K). 4,096 was throttling every supported model by 4-100×. The 2026-05-07 deepseek-v4-pro engineering-contract dogfood hit `stop_reason: 'max_tokens'` because of the 4K cap, not because the model lacked capacity.

### Fix

Default raised to **32,768** (matches Claude Code and Aider Opus default; well under any model's hard ceiling so server-side clamping handles edge cases automatically). New env var `OWLCODA_MAX_OUTPUT_TOKENS` lets operators tune up or down.

`max_tokens` is a CAP, not a target. Models stop at EOS regardless. Raising the cap:
- Doesn't increase generated tokens for short answers.
- Doesn't change cost (billed per actual output token).
- Doesn't change latency (cap doesn't affect per-token speed).
- Trade-off: reasoning models (Sonnet 4.6 / Haiku 4.5 thinking mode) count thinking tokens against `max_tokens`. 32K leaves ~16K visible output even with full thinking budget — comfortable for any practical deliverable.

### Implementation

1. `src/native/conversation.ts`: new `DEFAULT_MAX_OUTPUT_TOKENS = 32_768` constant + new exported `resolveDefaultMaxOutputTokens()` resolver. Reads `OWLCODA_MAX_OUTPUT_TOKENS` env, falls back to default on invalid/empty/zero/negative. Floors fractional inputs.
2. `createConversation` default arm changed from `?? 4096` to `?? resolveDefaultMaxOutputTokens()`.
3. Three other 4096 default sites updated:
   - `src/native/ink-repl.tsx:2705`
   - `src/native/headless.ts:74`
   - `src/cli-core.ts:1042` (was hard-coded 4096, no fallback — now uses resolver too).
4. New `OWLCODA_MAX_OUTPUT_TOKENS` env documented in resolver jsdoc; CLI flag is a follow-up if anyone wants it as a one-shot override.

### Tests

- `tests/native/max-output-tokens.test.ts` (new, 11 tests):
  - `resolveDefaultMaxOutputTokens` returns 32_768 by default.
  - Honors valid integer overrides (8192, 128000).
  - Falls back to default on empty / whitespace / non-numeric / zero / negative inputs.
  - Floors fractional values.
  - `createConversation` picks up the new default; explicit caller value still wins; env override flows through.
- `tests/native/conversation.test.ts`: 1 stale assertion update (`expect(conv.maxTokens).toBe(4096)` → `toBe(32_768)`).
- `tests/native/headless.test.ts`: 1 mock addition (`resolveDefaultMaxOutputTokens: vi.fn(() => 32_768)`) since the headless test pre-mocks the whole `conversation.js` module.
- 4095/4095 green (was 4084, +11 net new). tsc clean. Lockfile resync 2/2.

### Why this matters

This is the upstream root-cause fix the 0.13.62/0.13.63/0.13.64 patches were treating downstream of. With 32K default in place, a deepseek-v4-pro engineering-contract task (the 2026-05-07 dogfood case) doesn't truncate at all — the 0.13.64 continuation handling becomes the safety net for genuinely-too-long deliverables rather than a workaround for a misconfigured cap.

`package-lock.json` regenerated for 0.13.65: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.64] — 2026-05-07

### `stop_reason: 'max_tokens'` continuation handling

The 2026-05-07 mionyee dogfood (deepseek-v4-pro third session) hit a third class of "model can't deliver" failure that 0.13.55-0.13.63 didn't address: the assistant response stopped because of `stop_reason: 'max_tokens'` — the model wanted to keep writing but its per-response output cap cut the reply mid-content. Pre-0.13.64 owlcoda's only handling was a cosmetic `formatStopReason` warning (`⚠ Truncated (max tokens reached)`); the conversation loop just pushed the partial assistant turn and exited. The model never got told it was truncated, so on the next user turn it routinely re-rendered the entire deliverable from scratch — only to hit the same cap again.

### Fix

When the assistant turn has `stop_reason === 'max_tokens'`, the conversation loop now:

1. Increments a `consecutiveMaxTokensTruncations` counter.
2. If `maxTokensContinuationInjectCount < MAX_TOKENS_CONTINUATION_LIMIT (2)`, pushes a synthetic user-role turn carrying the `[Runtime max-tokens continuation]` body and emits a `Max-tokens continuation (Nx truncated, inject K/2): …` notice.
3. Explicitly `continue`s the loop (skipping the normal `!hasToolUse` terminal-check branch) so the model gets another iteration to act on the inject.
4. Resets both counters on the first non-max_tokens stop reason.

The inject body itself is in `task-state.ts:buildMaxTokensContinuationPrompt(truncatedText, consecutiveTruncations)`. Two wording modes:
- **First truncation (escalation level 1)**: "Continue from where you left off. Do NOT restart the section. Pick up at the next sentence. If the remaining content would exceed the cap again, switch strategy: write to a file or break into smaller tool calls." Includes a ≤400-char tail snippet of the truncated text for resume context.
- **Second consecutive truncation (escalation level 2)**: "Your last 2 replies hit stop_reason=max_tokens — continuing the same approach will hit the cap again. Stop trying to render the full deliverable inline. Your next reply MUST be one of: (a) a tool call (write/edit) that lands the deliverable as a file, OR (b) a one-paragraph summary stating what content is left and asking the user how to scope it down."

### Cap (MAX_TOKENS_CONTINUATION_LIMIT = 2)

After 2 consecutive injects without a non-max_tokens response in between, the runtime stops nudging. The cosmetic `Truncated (max tokens reached)` warning still fires per the existing UI signal, but the synthetic continue-prompts stop. Rationale: at that point the model is genuinely producing more content than fits in any single response, and the user needs to either narrow scope or accept file-based delivery — adding a 3rd identical inject doesn't help.

### Implementation

1. `src/native/task-state.ts`: new `buildMaxTokensContinuationPrompt(truncatedText, consecutiveTruncations)` exported. Two wording modes, ≤400-char tail snippet.
2. `src/native/conversation.ts`:
   - new constant `MAX_TOKENS_CONTINUATION_LIMIT = 2`
   - new state in `runConversationLoop`: `maxTokensContinuationInjectCount`, `consecutiveMaxTokensTruncations`
   - new branch right after the assistant turn is pushed: detect `stop_reason === 'max_tokens'`, inject + notice, set `maxTokensInjected = true`, then `continue` the loop. Else (any other stop reason) reset both counters.
3. `src/native/loop-noise.ts`: routes `Max-tokens continuation ` notices to footer + transcript (shares compactionCount with other context-management events).

### Tests

- `tests/native/task-state.test.ts`: 3 new `buildMaxTokensContinuationPrompt` tests pinning escalation-level-1 wording (continue from cut-off, do not restart), escalation-level-2 wording (file-based delivery, scope-down), and ≤400-char tail truncation.
- `tests/native/loop-noise.test.ts`: 1 new test pinning Max-tokens continuation notice routing to footer + transcript.
- `tests/native/conversation-loop-guard.test.ts`: 4 new tests under `describe('max-tokens continuation nudge (0.13.64)')`:
  - First-strike inject + loop continues to subsequent end_turn.
  - Second-strike escalation wording shows up in inject body.
  - Cap at 2 injects per consecutive run (4 max_tokens responses → only 2 injects).
  - Counter resets after non-max_tokens response.
- 4084/4084 green (was 4076, +8 net new). tsc clean. Lockfile resync 2/2.

### Why this matters

This is the third "model is delivering content too big for one turn" failure the recent dogfood surfaced — alongside heap pressure (0.13.62 A) and inline-output bloat (0.13.63 D). All three converge on the same operator-visible symptom: long session, model stuck producing increasingly large fallback content, conversation either crashes or stops mid-task. 0.13.64 closes the cleanest path the 0.13.62-0.13.63 patches didn't: when the model itself signals the truncation (`stop_reason: 'max_tokens'`), use that signal rather than discovering the problem indirectly via heap pressure or inline-bloat thresholds.

`package-lock.json` regenerated for 0.13.64: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.63] — 2026-05-07

### C: same-kind 3-strike auto-retry · D: output-bloat nudge · E: loop-intercept message fallback

Three follow-on fixes to the 0.13.62 P0 work, all driven by behaviors the 2026-05-07 mionyee dogfood logged but the 0.13.62 patch left for the next round.

**C. Consecutive same-kind auto-retry 3-strike.** The kimi-code dogfood logged 8 consecutive 120s timeouts before `Runtime auto-continue stopped after 8 attempts` finally fired — 16+ minutes of pure burn. New behavior: when the last 2 auto-retry failures and the incoming failure all share the same `kind` (e.g. all `timeout`), refuse to schedule a 3rd. The user can still force resend with `/retry` or switch model with `/model`. Cuts the worst case from 8×120s to 3×120s = 6 min. The retry-count limit (default 8) still applies as the upper bound; the new check just trims the floor.

Implementation:
- `src/native/repl-shared.ts:shouldScheduleRuntimeAutoRetry` gains optional `recentRetryKinds?: string[]` param. New private const `SAME_KIND_RETRY_LIMIT = 2`. When the tail of the kinds list matches the incoming failure kind for SAME_KIND_RETRY_LIMIT entries, return false. Optional for back-compat: when omitted, behaves like 0.13.62 (limit-only).
- `src/native/ink-repl.tsx`: new `recentRetryKindsRef = useRef<string[]>([])` rolling window (capped at 5 entries). Threaded into `shouldScheduleRuntimeAutoRetry`. Pushed each time auto-retry schedules. Reset alongside `runtimeAutoRetryCountRef` whenever a turn succeeds or `clearRetryContinuationState` runs.
- New ink-repl branch when same-kind 3-strike fires (auto-retry refused before limit reached): emits `Runtime auto-continue stopped: N consecutive <kind> failure(s) suggest the upstream is stuck — re-running the same request is unlikely to help. Use /model to switch, or /retry to force another resend.` Without this branch the user would see the footer go silent — worse UX than the existing limit-reached message.

**D. Output-bloat early protection.** When the assistant emits ≥15K chars of inline text for 3 consecutive iterations and the runaway dump nudge hasn't fired yet, inject `[Runtime output-bloat check]` as a user-role message. The model sees it on its next turn and is told to stop re-rendering content and either tool-call forward or ask a specific question. Once-per-session: the goal is to interrupt the runaway pattern, not to nag every long answer.

The 2026-05-07 trace had kimi-code emitting 30K+ char fallback summary tables every iteration after it got stuck on `write({})` — that text bloat was the actual cause of the OOM (not request volume). 0.13.62 (A) catches the OOM symptom; D catches the cause.

Implementation:
- New `OUTPUT_BLOAT_CHARS = 15_000` and `OUTPUT_BLOAT_WINDOW = 3` constants in `conversation.ts`.
- New `recentOutputSizes: number[]` rolling window and `outputBloatNudgeFired: boolean` flag in `runConversationLoop`.
- After each assistant turn is pushed, push current text size into the window; if all OUTPUT_BLOAT_WINDOW entries exceed the threshold AND the nudge hasn't fired, inject a synthetic user-role turn carrying the buildOutputBloatPrompt text + emit `Output bloat (Nx ≥KK chars): ...` notice.
- New `buildOutputBloatPrompt(consecutiveLargeOutputCount, approxCharsPerOutput)` exported from `task-state.ts`. Body: stops re-rendering ban + (a) tool call OR (b) one short paragraph + specific question.
- `loop-noise.ts` routes `Output bloat ` notices to footer + transcript (shares compactionCount with other context-management events).

**E. Loop-intercept message: intentTarget degenerate fallback.** Pre-0.13.63, `task stuck in tool loop: repeated failing X attempts on ${next.intentTarget}` produced messages ending with bare `attempts on ` (trailing nothing) when intentTarget degenerated for empty input. Now falls back to `tool=${next.name}` so the message is always informative. Cosmetic, low-cost; surfaced in the 2026-05-07 trace as `repeated failing update attempts on `. Both `target` and `intentTarget` paths get the fallback.

### Tests

- `tests/native/repl.test.ts`: 7 new tests under `describe('same-kind 3-strike (0.13.63 C)')` — back-compat with no recentRetryKinds, first/second occurrence allows retry, third occurrence blocks, mixed kinds allows, only-last-2-of-window check, mixed-history-with-same-tail-blocks.
- `tests/native/loop-noise.test.ts`: 1 new test pinning Output bloat routing to footer + transcript.
- `tests/native/task-state.test.ts`: 1 new test pinning `buildOutputBloatPrompt` body shape (consecutive count, per-output size, tool-call demand, specific-question demand, re-render ban).
- All previously passing tests continue to pass (4076 total). tsc clean. Lockfile resync 2/2.

### Out of scope, deferred to later

These were on the 0.13.62 backlog but didn't make it into 0.13.63 because they need broader design work, not narrow fixes:
- Bash output mid-truncation that drops the actual error line (we have `[stdout]/[stderr]/[exit code: N]` from 0.13.60 but the in-the-middle truncate path can still bisect long stdout in unfortunate places).
- Subagent recovery prompts when an agent times out mid-run (currently 0.13.57 deadline message is informational only).

### Why this matters

C closes the auto-retry burn loop the user explicitly called out. D closes the cause of the 0.13.62 OOM (instead of just the symptom). E removes a low-grade UI bug that made the 0.13.55 loop intercept message look broken in transcripts. Together they finish the four-bullet list the user signed off on at the start of this batch — A and B in 0.13.62, C/D/E in 0.13.63.

`package-lock.json` regenerated for 0.13.63: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.62] — 2026-05-07

### A: Heap-pressure emergency compact + B: schema-fail dispatcher short-circuit

The 2026-05-07 mionyee dogfood produced two new owlcoda P0 incidents the recent ship train (0.13.55-0.13.60) didn't catch:

1. **Node process crashed with `FATAL ERROR: Ineffective mark-compacts near heap limit`.** A long kimi-code session ran 30+ minutes with the model repeatedly emitting huge inline tables when stuck on `write({})`. The accumulated transcript pushed V8 heap to ~4GB and the host process died. Token-window auto-compact (the existing 80% threshold) never triggered because individual turns stayed under the model's window — but cumulative transcript bloat killed the host.

2. **Soft loop intercept (0.13.55) didn't escalate on `write({})`.** Trace shows `Loop intercept (soft): repeated failing update attempts on` fired, then the model emitted 5+ more `write({})` calls without hard-terminate kicking in. Root cause: the soft→hard escalation keys on `intentKey = ${category}:${intentTarget}`, which degenerates to e.g. `write:` for empty input — two `write({})` calls produce the same degenerate key but the soft-intercept Set check sees them as "first hits on different (degenerate) intentKeys" because the empty intent target produces collisions in surprising ways.

### A. Heap-pressure emergency compact

New runtime defense: at the start of every conversation iteration, before the existing token-window auto-compact, check `process.memoryUsage().heapUsed / v8.getHeapStatistics().heap_size_limit`. If the ratio crosses **0.75**, run an aggressive compact that keeps only the **last 20%** of turns. The 20% keep-ratio is intentionally aggressive — when the host process is genuinely at OOM risk, dropping context is trivial cost compared to a hard crash that kills the session.

Implementation lives in `src/native/conversation.ts` as two exported helpers + one wire-in:
- `getHeapPressureRatio(memUsageStub?, heapLimitStub?)` — reads the V8 heap stats with optional stubs for tests.
- `emergencyHeapCompact(conversation, ratio)` — returns `{before, after}` if compaction ran, `null` otherwise. Enforces minimum-of-2 turns even on small conversations; no-ops if `ratio < 0.75` or conversation has ≤4 turns already.
- The main loop calls `getHeapPressureRatio()` then `emergencyHeapCompact()` before the token-window compact path. On hit, fires `Heap pressure compaction:` notice (routed through loop-noise to footer + transcript) and the existing `onAutoCompact` callback with new `reason: 'heap_pressure'`.

The constants are private to `conversation.ts` for now (`HEAP_PRESSURE_THRESHOLD = 0.75`, `HEAP_PRESSURE_KEEP_RATIO = 0.2`); if real-world dogfood shows we need to tune them, an env var is a one-line follow-up.

### B. Schema-fail dispatcher short-circuit

New `predictSchemaFailure(toolName, input)` helper in `tool-schema-error.ts`. Mirrors the existing `checkWriteRequiredFields` / `checkEditRequiredFields` checkers but as a pre-flight prediction the dispatcher can use without running the tool. Returns `{missingFields[]}` or `null`.

`runConversationLoop` now maintains a per-conversation `schemaFailCounts: Map<string, number>` keyed by `${toolName}:${sortedMissingFields.join(',')}`. Threaded into `executeTools` as the 10th parameter (back-compat: optional).

Inside `executeTools`, the very first per-block check (before loop-guard, before intent-guard, before approval) runs `predictSchemaFailure(block.name, block.input)`. If the call would fail AND we've already seen the same (tool, missingFields) shape fail once before, return immediately as a hard-terminate `loopError`. Bypasses the 0.13.55 intentKey-based soft intercept entirely — safe because the schema-fail key (toolName + missingFields) is stable regardless of input contents.

Effect: `write({})` after one prior `write({})` failure → hard-terminate at attempt 2 with message `"task stuck on repeated schema failures: tool write called with the same missing required field(s) [content, path] 2 time(s). Hard-terminating to break the empty-input retry loop."` — closes the kimi-code 8x retry loop the mionyee dogfood exposed.

### Implementation

1. `src/native/conversation.ts`:
   - `import * as v8 from 'node:v8'`.
   - Two new constants + two new exported helpers (`getHeapPressureRatio`, `emergencyHeapCompact`).
   - `runConversationLoop` declares `schemaFailCounts = new Map<string, number>()`, threads it into `executeTools` as 10th param.
   - `executeTools` gains optional 10th param `schemaFailCounts`. Pre-flight check at the top of the per-block loop short-circuits on second-strike.
   - Heap-pressure compact wired before existing token-window compact in main loop iteration.
   - `onAutoCompact` callback `reason` enum extended to include `'heap_pressure'`.
2. `src/native/tools/tool-schema-error.ts`: new `predictSchemaFailure(toolName, input)` exported. Returns null for tools without checkers (bash, grep, etc) and for well-formed inputs.
3. `src/native/loop-noise.ts`: routes `Heap pressure compaction:` to footer + transcript (shares `compactionCount` counter with token-window compact).

### Tests

- `tests/native/heap-pressure-compact.test.ts` (new, 9 tests): pin `getHeapPressureRatio` (with stubs), `emergencyHeapCompact` threshold/keep-ratio/min-2/recency invariants, exactly-at-threshold and just-below cases.
- `tests/native/tools/tool-schema-error.test.ts`: 6 new tests under `describe('predictSchemaFailure')` — null for non-checker tools, null for well-formed input, flag write({})/edit({}) full and partial, stable dedup key.
- `tests/native/loop-noise.test.ts`: 1 new test pinning `Heap pressure compaction:` routes to both footer and transcript.
- `tests/native/conversation-loop-guard.test.ts`: 3 new tests under `describe('schema-fail dispatcher short-circuit (0.13.62)')` — second-strike write({}) hard-terminates with mock dispatcher executing exactly once (first call ran, second was short-circuited); first-strike alone does NOT terminate; different missingFields → no short-circuit. Plus 1 legacy assertion update: the existing `read/update oscillation` test now uses schema-complete edit input so it exercises the loop-guard path rather than the new schema-fail path.
- 4067/4067 green (was 4046, +21 net new tests). tsc clean. Lockfile resync 2/2.

### Why this matters

A is the OOM defense the 2026-05-07 trace would have prevented. B is the dispatcher-level closer for the empty-input retry pattern that has now hit us in 4+ independent dogfood sessions across deepseek-v4-pro, kimi-code, and earlier. Together they cover the two failure modes the recent ship train didn't: host-process crashes (A) and intentKey-degenerate soft-intercept dodge (B).

Out of scope for this patch (deferred to 0.13.63): C (consecutive same-class API timeout 3-strike), D (output-bloat early protection), E (loop-intercept message intentTarget fallback to toolName when degenerate).

`package-lock.json` regenerated for 0.13.62: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.61] — 2026-05-07

### P0 release-hygiene fix: include `checkReadPathAllowed` export 0.13.60 depended on

`b21333d` (0.13.60) was self-inconsistent: `src/native/tools/read.ts` imports `checkReadPathAllowed` from `./fs-policy.js`, but `src/native/tools/fs-policy.ts` was a **pre-existing dirty-tree change** that never got committed. The local working tree built fine because the dirty `fs-policy.ts` had the `checkReadPathAllowed` export; clean checkouts of HEAD fail to compile:

```
src/native/tools/read.ts(10,10): error TS2724: '"./fs-policy.js"' has no exported member named 'checkReadPathAllowed'
```

Throughout the 0.13.55-0.13.60 ship train, the standing rule was "don't touch user's pre-existing dirty changes" (per the saved memory `feedback_owlcoda_work_in_main`). That rule is correct in general — main carries lots of working-copy changes the user is iterating on independently — but it created a release-hygiene gap when one of those dirty files became a load-bearing dependency of a committed file. The user caught it on a clean-checkout build verification, which I should have done myself at the 0.13.60 commit boundary.

### Fix

Stage and commit ONLY the two files HEAD's read.ts already requires:
- `src/native/tools/fs-policy.ts` — adds `checkReadPathAllowed` export and `evaluateFsPolicy` private helper that both read and write paths share. Read paths reuse normalization, tilde expansion, symlink-parent resolution, and sensitive-path deny list, but do NOT enforce the workspace-root boundary (reads have always been broader than writes). The sensitive-path deny message is templated by action so the error reads "Refusing to read ~/.ssh/id_rsa" for read calls and "Refusing to write ~/.ssh/id_rsa" for write calls.
- `tests/native/fs-policy.test.ts` — 28 tests, including the new `checkReadPathAllowed` paths.

### Explicitly NOT in this commit

Per the user's scope guidance, the following dirty-tree items are NOT included — they remain user working-copy changes:
- `docs/TOOLS.md` (in-flight tool doc updates)
- in-flight private documentation notes
- transient QA scratch fixtures
- generated scratch artifacts

### Verification process change

Going forward, the commit boundary verification should include a **clean-checkout build** check, not just `npm run build` against the working tree. The dirty-state build hides exactly this kind of split-dependency mistake. For 0.13.61 the verification is:

```
git stash --include-untracked    # stash all dirty + untracked
git checkout HEAD -- .           # ensure tree matches HEAD
npm run build                    # must pass
git stash pop                    # restore dirty changes
```

### Tests

- `tests/native/fs-policy.test.ts`: 28 tests pass after the new `checkReadPathAllowed` lands; pre-0.13.61 these tests would not even import.
- Full suite: 4046/4046 still pass.
- Clean-checkout build: passes (verified post-commit).

`package-lock.json` regenerated for 0.13.61: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.60] — 2026-05-07

### Accuracy follow-on: structured bash, replaceAll, grep clarity, read path header

After 0.13.55 (soft loop intercept) + 0.13.58 (prompt audit) + 0.13.59 (self-recoverable errors), the user pointed out that token-burn is secondary — accuracy and success rate are the real targets. 0.13.60 ships four follow-on changes that each move a specific accuracy-failure pattern observed across recent dogfood sessions:

**1. Structured bash output (preserve exit code + stderr through truncation).** Pre-0.13.60 bash returned `<stdout>\n[stderr]\n<stderr>` and put exit code only in metadata. The conversation-loop truncator (60% head + 20% tail, generic) could push a long stdout's middle out and silently drop the exit-code line + start of stderr — exactly the load-bearing parts the model needs to diagnose failure. New format always emits:

```
[stdout]
<stdout (head 60% + tail 20% if too long)>

[stderr]
<stderr (capped at 4K, full otherwise)>

[exit code: N]
```

Bash-side cap at 14K total with priority: exit code line first, full stderr next, stdout fills the rest. The conversation-loop truncator's generic 15K cap still applies on top, but the bash-side pre-format means the load-bearing sections survive.

**2. Edit `replaceAll: true` opt-in.** Pre-0.13.60 multi-occurrence oldStr was a hard error — model had to either add context (often hard if all occurrences look identical, e.g. renames) or call `edit` once per occurrence. New `replaceAll?: boolean` parameter (default false) opts into "replace every literal occurrence" semantics. The multi-occurrence error message now lists this as option (c), so the model knows it's available. Output: `Edited <path>: replaced N occurrences`. Metadata: `{occurrencesReplaced, replaceAllUsed}`.

**3. Grep zero-match wording: explicit success.** Pre-0.13.60 returned `No matches found` for the success-with-zero-results case. Long-context models sometimes misread that as "search failed" and retried with a different (also-empty) pattern. New format: `[grep ok] 0 matches for pattern "..." in <path>. Search ran cleanly; the pattern simply does not occur. If you expected a match, broaden the pattern or check the path.` Metadata: `zeroMatches: true`.

**4. Read file-path header.** Every read output now starts with `[file: <abs-path>]`. Combined with the existing line-ending header (CRLF / mixed) when applicable. Long-context sessions reading 5+ files in parallel previously had outputs that all looked like `1\tline\n2\tline\n…` — the model could lose track of which output went with which path. The header is unambiguous and small. Byte-range reads also get the header with a `[byte range: offset=X, length=Y]` tag.

### Implementation

1. `src/native/tools/bash.ts`: new `formatBashOutput()` helper composes `[stdout]/[stderr]/[exit code]` sections with smart head/tail truncation; replaces the inline parts join. ~80 lines.
2. `src/native/tools/edit.ts`: branches on `input.replaceAll`; uses `String.prototype.replaceAll` for the multi-replace path; updated description and error wording. ~15 lines net.
3. `src/native/tools/grep.ts`: `successResult` now takes `pattern` and `searchPath` for the zero-match echo; richer wording + metadata. ~15 lines net.
4. `src/native/tools/read.ts`: new `buildHeader(filePath, lineEndingKind)` helper; both `readLines` and `readByteRange` paths prepend the header. ~25 lines net.
5. `src/native/tools/types.ts`: `EditInput.replaceAll?: boolean`.

### Tests

- `tests/native/tools/bash.test.ts`: 4 new tests under `structured output format (0.13.60)` (stdout-only, non-zero exit, stderr-only, both-with-order); 5 stale assertions updated to use `toContain` instead of `toBe` since output is now structured.
- `tests/native/tools/edit.test.ts`: 3 new tests under `replaceAll opt-in (0.13.60)` (replaces all, single-with-flag, default rejects); 1 multi-occurrence wording assertion updated.
- `tests/native/tools/grep.test.ts`: 1 zero-match assertion updated to match new `[grep ok]` wording + `zeroMatches: true` metadata.
- `tests/native/tools/read.test.ts`: 4 line-ending tests and 1 single-line test updated for the new path header; 2 new tests under `file-path header (0.13.60)`.
- `tests/native/dispatch.test.ts`: 3 stale bash-output assertions updated.
- All tests: 4046/4046 green. tsc clean. Lockfile resync 2/2.

### Why this matters

These all fix specific accuracy-failure patterns we've seen, not theoretical edge cases:
- bash output truncation losing exit codes → model thinks command succeeded when it didn't (or vice versa)
- multi-occurrence rename forced into N edit calls, each potentially failing differently → drift
- "No matches found" misread as failure → unnecessary retry burning more turns
- multi-file read context confusion → wrong-file edits in long sessions

`package-lock.json` regenerated for 0.13.60: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.59] — 2026-05-07

### Self-recoverable tool errors (sieracMes-AI dogfood follow-up)

The sieracMes-AI dogfood (kimi-code at ~700K tokens) reproduced two specific failure modes the prompts could not directly fix: `write({})` with empty input three times in a row (long-context tool_use field-drop), and `Edit` failing silently on a CRLF-encoded file because the model's oldStr was LF (legacy admin_routes.py was Windows-encoded). 0.13.55 soft loop intercept caught both, but only after 3 wasted turns. 0.13.59 changes the tool error messages themselves so the model can self-recover in **one** turn.

**A. Schema-error path on validation failures.** Pre-0.13.59, `write({})` returned `Error: Path must be a non-empty string` and `edit({path:'/x'})` returned `Error: oldStr must not be empty`. Both were correct refusals but neither named the schema or echoed the input back. Long-context kimi/deepseek then retried the SAME malformed shape because the error gave them nothing to fix. 0.13.59 introduces `buildSchemaError(toolName, missingFields, receivedInput, schemaSummary)` in `src/native/tools/tool-schema-error.ts`. Output:

```
Error: write tool called with empty or missing required field(s): `path`, `content`.
Schema: { path: string (absolute path), content: string }
Received input: {}
Re-emit the call with all required fields populated. Do NOT retry the same input shape — fix the missing field(s), or stop and ask the user if you cannot determine the value.
```

Wired into `write` and `edit` as the first check in their `execute()` body, before fs-policy. Metadata: `{schemaError: true, toolName, missingFields}`.

**B. Edit fuzzy-match + line-ending diagnostic.** When `oldStr` doesn't match, instead of `Error: oldStr not found in <path>`, return a diagnostic message that names:
- The line-ending mismatch if the file is CRLF and oldStr is LF (the sieracMes-AI failure).
- Top-3 closest lines via Levenshtein distance, with line number and Δ score and a 80-char preview.
- A pointer at `read({path, startLine: N-2, endLine: N+5})` so the model knows how to verify the candidate line.

If no fuzzy candidate clears the 50% relative-distance threshold, message says "No similar lines found. The text you tried to edit may not exist in this file at all — re-read the file before retrying." Helpers in `src/native/tools/edit-helpers.ts` (`detectLineEnding`, `findFuzzyMatches`, `buildOldStrNotFoundError`).

**C. Read tool annotates CRLF / mixed line endings.** When a file has CRLF or mixed line endings, the read tool prefixes its output with a bracketed header: `[file uses CRLF line endings — copy oldStr exactly when editing]` or `[file has MIXED line endings — be careful when editing across line boundaries]`. LF (the common case) stays silent — no header — to avoid noise. Metadata field `lineEndingKind: 'LF' | 'CRLF' | 'mixed' | 'none'` always set. **'C-non-aggressive' choice**: we do NOT silently normalize line endings inside edit — that would change semantics for projects that intentionally use CRLF (Windows scripts, network protocols). Diagnostic-only.

**D. System prompt: tightened retry discipline.** Pre-0.13.59 said "If an approach fails, diagnose root cause before switching. No identical retries." — too soft. The dogfood showed the model still emitting the SAME `write({})` three times. New rules in `# Doing tasks`:

> READ tool errors before retrying. Most errors carry a hint about what to change next (schema, line endings, fuzzy matches). Acting on the hint usually fixes the call in one turn.
>
> If the EXACT same tool input has now failed twice, you MUST change something material on the third attempt: different fields, re-read the file, or stop and ask the user. A third identical retry is wasted; the runtime will intercept it.
>
> When a tool reports a SCHEMA error (missing/empty required field), the next call MUST include all fields named in the error. Don't guess at the schema — the error states it.

### Implementation

1. `src/native/tools/tool-schema-error.ts` (new, ~70 lines): `buildSchemaError`, `checkWriteRequiredFields`, `checkEditRequiredFields`. Metadata-shape compatible with `ToolResult`.
2. `src/native/tools/edit-helpers.ts` (new, ~150 lines): `detectLineEnding` (LF/CRLF/mixed/none), `findFuzzyMatches` (top-N by Levenshtein, capped at 80 chars per line, 50% relative-distance threshold), `buildOldStrNotFoundError` (orchestrates both diagnostics).
3. `src/native/tools/write.ts`: schema check at the top of `execute`. ~10 lines added.
4. `src/native/tools/edit.ts`: schema check at the top; `oldStr not found` path now calls `buildOldStrNotFoundError`; `multiple occurrences` path now suggests adding 1-3 lines of surrounding context. ~20 lines net.
5. `src/native/tools/read.ts`: detect line-ending on raw bytes, split on `/\r?\n/` so line numbers are correct for CRLF, prefix output with bracketed header when CRLF/mixed. ~15 lines net.
6. `src/native/system-prompt.ts:DOING_TASKS`: 3 new rules added (read-error-before-retry, two-failures-then-change, schema-error-rule).

### Tests

- `tests/native/tools/tool-schema-error.test.ts` (new, 16 tests): missing-field detection for write/edit, output format pinning, 200-char input echo truncation, metadata shape.
- `tests/native/tools/edit-helpers.test.ts` (new, 21 tests): line-ending detection (LF/CRLF/mixed/none), fuzzy match top-N + threshold + min-length, multiline-oldStr first-line-as-needle, CRLF+LF correctness, 80-char preview truncation, the full `buildOldStrNotFoundError` surface (CRLF hint, mixed hint, fuzzy candidates, no-similar-lines fallback, re-read pointer).
- `tests/native/tools/write.test.ts`: 4 new tests under `schema-error self-recovery (0.13.59)` — `write({})`, `write({path})`, `write({path:'',content:'x'})`, intentional empty-content write.
- `tests/native/tools/edit.test.ts`: 4 new tests under `schema-error self-recovery`, 3 new under `oldStr-not-found diagnostics`, 1 new on multi-occurrence error wording. Pins the CRLF-vs-LF specific case (`fileLineEnding: 'CRLF'`, `oldStrLineEnding: 'LF'`) and the fuzzy-match fallback.
- `tests/native/tools/read.test.ts`: 4 new tests under `line-ending annotation` — CRLF header, mixed header, LF silence, header on line-range read.
- `tests/native/system-prompt.test.ts`: 1 new test pinning the tightened retry-discipline + schema-error wording.
- `tests/native/tools/write-edit-guard.test.ts`: 1 assertion update to accept either legacy or new error phrasing for the "refuse empty path" contract.
- 4037/4037 green. tsc clean. Lockfile resync 2/2.

### Why this matters

These are the runtime/error-message-layer companion to the prompt changes in 0.13.58. Together with 0.13.55 soft loop intercept, the conversation chain looks like: tool error → tool result has actionable hint (0.13.59) → if model still loops, soft intercept asks for human direction (0.13.55). Each layer is self-recoverable for the failure modes it catches; we no longer rely on the model alone to figure out what went wrong.

`package-lock.json` regenerated for 0.13.59: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.58] — 2026-05-07

### Engineering prompt audit + long-context discipline

The mionyee-hermes long-context dogfood (deepseek-v4-pro at 800K+ tokens) surfaced two reproducible failure modes that were upstream of any single guard: (a) the model reflexively bouncing clarifying questions back at the user instead of greping the repo for answers already in scope, and (b) the user genuinely couldn't tell whether `/compact` had run because the success notice was footer-only and a long session's footer banner is gone in the next event. After auditing the prompt matrix end-to-end (system prompt, 5 runtime injects, tool guidelines, mode flags, loop-noise routing) we shipped 7 changes in one patch:

**1. Investigation discipline section in system prompt (#1).** New `# Investigation before asking` block in `BEHAVIORAL_RULES` makes the rule explicit and names the operator phrasings ("look it up" / "去找" / "查一下" / "you go find") that should always trigger investigation, never bounce-back. Cites the asymmetry directly: "A clarifying question costs the user a turn. A grep costs you 100ms."

**2. Compact notices route to transcript (#2).** Pre-0.13.58, `Context compacted: …` and `Context limit hit …` were footer-only via `loop-noise.ts:90`. At 800K+ tokens the footer flashes once and disappears under the next event — operators had no scrollback evidence that compaction ran. Now both go to footer AND transcript, same level as `Targeted check` / `Synthesis phase`.

**3. + 7. Context-pressure nudges (`[Runtime context-pressure check]`).** Two thresholds — 0.6 (soft) and 0.85 (strict) — fire once each per session when `compactDecision.usageRatio` crosses without an auto-compact running in the same iteration. Soft mode: lead with key fact, batch tool calls, grep before asking. Strict mode: one fact + next action, no narration, no plan restatements, narrow scope or hand back. Tracked on `conversation.options.contextPressureNudgesFired` so resumed sessions don't re-fire. Routed through loop-noise for footer + transcript visibility.

**4. Tool-call text discipline rewritten (#4).** Pre-0.13.58 said "the system will detect and terminate sessions where you request tools repeatedly without producing any text output" — threat phrasing that doesn't motivate, and inaccurate (the actual mechanism is summary-gate, not termination). New text: "After each tool call (or parallel batch), produce ONE short sentence stating what you found and the next step. Never chain many tool calls in silence — the runtime will inject a `[Runtime ...]` nudge that costs you a turn. One sentence is enough; don't write paragraphs."

**5. `buildTaskContinuePrompt` demands evidence (#5).** Pre-0.13.58 said "Continue the task now. Do not stop at another interim summary." Models routinely interpreted "do not stop at another summary" as "produce another planning paragraph" — exactly the failure mode the inject was meant to prevent. New version: "Your next response MUST be one of: (a) a tool call that materially advances the task, OR (b) a one-line claim citing file_path:line_number evidence followed by the next tool call. Do NOT produce another high-level summary. Do NOT restate the plan."

**6. Deduped DOING_TASKS / TONE_AND_STYLE (#6).** Pre-0.13.58 had two separate sections both saying "be short / concise / brief" five times across `OUTPUT_EFFICIENCY` + `TONE_AND_STYLE`. Merged into a single tighter `# Output` section. Saves ~80 prompt tokens, no semantic loss. Removed `# Using your tools` (its content collapsed into the `<tool_guidelines>` block where it belongs anyway).

### Implementation

1. `src/native/system-prompt.ts` (#1, #4, #6): rewrote `TOOL_GUIDELINES`; new `INVESTIGATION_DISCIPLINE` constant; merged `OUTPUT_EFFICIENCY + TONE_AND_STYLE` → `OUTPUT_DISCIPLINE`; removed `USING_TOOLS` (folded into TOOL_GUIDELINES); `BEHAVIORAL_RULES` now lists 4 sections instead of 5.
2. `src/native/loop-noise.ts` (#2 + #3 routing): `Context compacted` / `Context limit hit` now have `transcriptEntry`; new branch routes `Context pressure …` notices through the same shape and increments `compactionCount` (single rollup counter for context-management events).
3. `src/native/task-state.ts` (#3 + #5): new `buildContextPressurePrompt(threshold, usageRatio, totalTokens, contextWindow)`; tightened `buildTaskContinuePrompt` to demand a tool call OR a `file_path:line` cite.
4. `src/native/conversation.ts` (#3): wires the context-pressure nudge after the auto-compact block. Reads `compactDecision.usageRatio`; if no compact happened this iteration AND a threshold (0.6 or 0.85) hasn't fired yet, pushes a synthesized user-role turn carrying the inject and persists the fired threshold to `conversation.options.contextPressureNudgesFired`.
5. `src/native/protocol/types.ts`: new `contextPressureNudgesFired?: number[]` on `ConversationOptions`. Array (not Set) for clean JSON round-trip on session persistence.

### Tests

- `tests/native/system-prompt.test.ts`: assertion update (`# Output efficiency` → `# Output`); 2 new tests pinning the investigation-before-asking section appears with the operator phrasings ("look it up" / 去找 / 查一下 / "you go find") and the legacy "system will detect and terminate" threat phrasing is gone.
- `tests/native/loop-noise.test.ts`: 3 new tests pinning `Context compacted` / `Context limit hit` / `Context pressure …` all route to footer + transcript.
- `tests/native/task-state.test.ts`: 2 new `buildTaskContinuePrompt` tests pinning the evidence-or-tool-call demand and the high-level-summary ban; 3 new `buildContextPressurePrompt` tests covering soft/strict mode contracts.
- All previously passing tests continue to pass (3984 total). tsc clean. Full build green.

### Why this matters

Each item is small, but together they target the long-session UX problem head-on: model has more reason to investigate before asking, operator can see compaction happened, model gets a structured nudge as context fills (instead of just degrading silently), and the `[Runtime continue-while-open]` inject (the most-fired runtime nudge) actually demands forward motion rather than another paragraph of plan. None of these change runtime semantics or break opt-out paths; legacy operators get the new behavior for free.

`package-lock.json` regenerated for 0.13.58: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.57] — 2026-05-06

### agent_timeout_policy_v1 — two-axis subagent watchdog (idle + max)

Pre-0.13.57 the Agent tool had no wall-clock deadline at all (the existing `TOOL_ABORT_HARD_DEADLINE_MS = 3s` is a post-abort grace period — only fires after something else aborts the run). 0.13.55 soft loop intercept catches conversation-level repeated-failure patterns, but it does not catch lower-level wedges: subprocess hang, Promise that never resolves, infinite generator on the model side. Two recent deepseek-v4-pro sessions in mionyee-hermes showed the symptom — Explore subagents went quiet for 5+ minutes while grinding tool use on a 4000-line file, with the user unsure whether to wait or cancel.

**Industry survey** (recap, full notes in commit thread): Aider/Continue have a single per-request timeout; Codex CLI has `max_runtime_seconds` per subagent (1800s default); Claude Code and Cursor have no built-in deadline at all (Esc-only); OpenHands has a Stuck Detector that watches semantic loop patterns rather than wall-clock silence. **No mainstream tool uses tool_use heartbeat to reset the parent deadline** — closest peer is OpenHands' philosophy ("watch productive progress, not silence").

**0.13.57 picks idle + max with progress-signal heartbeat.** Two env-tunable knobs, both default ON:

  - `OWLCODA_AGENT_IDLE_TIMEOUT_MS` (default 300_000 = 5 min) — abort if no real progress signal arrives for this long. Reset signals: assistant text streamed, tool_use start, tool_result end, tool progress chunk, runtime notice. A subagent grinding read/grep on a big file keeps the watchdog awake even if the model never streams a token. Set to `0` to disable idle timeout.
  - `OWLCODA_AGENT_MAX_RUNTIME_MS` (default 1_800_000 = 30 min) — absolute ceiling; fires regardless of progress. The deadlock catcher. Set to `0` to disable, **not recommended** as the production default.

When the watchdog fires, the Agent tool returns a structured tool_result with `metadata.agentTimeout = true` and an output that names the kind, the elapsed time, the idle window, the **last progress event type and timestamp**, and the env vars to tune. Operators no longer have to re-run a 30-minute job to know whether it was the model thinking quietly or a real wedge.

User cancellations (external `context.signal.abort()`) propagate through the watchdog without recording a firing, so user-Esc still returns `Agent cancelled` (not a timeout).

### Implementation

1. **`src/native/tools/agent-timeout.ts`** (new file, ~210 lines):
   - `parseAgentTimeoutMs(raw, default)` — env value parsing; honors `0` for disable, falls back to default for invalid/negative.
   - `resolveAgentTimeoutPolicy(env)` — reads both env vars with the right defaults.
   - `createAgentWatchdog({ idleTimeoutMs, maxRuntimeMs, externalSignal, ... })` — returns `{ signal, recordProgress, getFiring, getLastProgress, dispose }`. Watches a single AbortController and fires it when either axis trips. Supports tests via `now`, `setIntervalImpl`, `clearIntervalImpl`, `tickMs` injection points.
   - `formatAgentTimeoutMessage(firing)` — operator-facing message with last-progress evidence.
2. **`src/native/tools/agent.ts`**:
   - Added `context?: { signal?: AbortSignal }` parameter to `execute` so user-Esc can be plumbed through.
   - Spins up a watchdog before `runConversationLoop`; threads the watchdog `signal` into `loopOpts.signal`.
   - Wraps callbacks to call `recordProgress(<type>, <detail>)` on `onText` (`assistant_text`), `onToolStart` (`tool_start`, tool name), `onToolEnd` (`tool_end`), `onToolProgress` (`tool_progress`, tool name), `onNotice` (`notice`).
   - On error catch: if watchdog fired (`getFiring()` non-null), return the structured timeout result; otherwise fall through to the existing AbortError handling for user cancellations.
   - Always disposes the watchdog in success and error paths.

### Tests

- `tests/native/tools/agent-timeout.test.ts` (new, 25 tests):
  - `parseAgentTimeoutMs` — undefined/empty/non-numeric/0/integer/fractional/negative all covered.
  - `resolveAgentTimeoutPolicy` — defaults, env override, `0` disable per axis.
  - `createAgentWatchdog` idle: doesn't fire on recent progress; fires when threshold crossed; progress recordings reset the clock; lastProgress mark tracks the most recent detail.
  - `createAgentWatchdog` max: fires regardless of continuous progress.
  - `createAgentWatchdog` disabled axes: idle=0, max=0, both=0 (no interval scheduled).
  - `createAgentWatchdog` external signal: forward without recording firing; already-aborted at construction.
  - `dispose`: idempotent; clears interval; progress recording is no-op after dispose.
  - `formatAgentTimeoutMessage`: idle + max output formatting; last-progress evidence; missing-detail graceful fallback.
- All previously passing tests continue to pass (3974 total). tsc clean. Full build green.

### Why this matters

This is the v0.13.55 (loop intercept) sibling for low-level wedges. Conversation-level repetition is now soft-intercepted; conversation-level silence/wedges are now caught by the watchdog. Both axes have escape hatches (`OWLCODA_LOOP_INTERCEPT=hard`, `OWLCODA_AGENT_IDLE_TIMEOUT_MS=0`, `OWLCODA_AGENT_MAX_RUNTIME_MS=0`) so operators with unusual workloads can opt out per axis. Default posture: keep both axes on; tune up only when a specific subagent legitimately needs longer.

`package-lock.json` regenerated for 0.13.57: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.56] — 2026-05-06

### Negative probes can no longer ride the probe-consent override (final P0 close)

The v2.5 hostile-QA pass on 0.13.54 confirmed the harness was working — both DeepSeek and Kimi *had* to emit A_LEAK (no more skipping with fabricated evidence) and the matcher correctly tracked positive probes A0–A6 — but the structured-guard P0 still wasn't closed. Both transcripts showed `↳ Denied: bash` (operator approval prompt → user denial), not the expected `intentGuardBlocked: true` tool_result, and a follow-up `Probe coverage check: 1 unsatisfied probe(s)` from the runtime because A_LEAK had `expectedOutcome: 'intentGuardBlocked'` and the guard never fired.

Root cause: `findMatchingUnsatisfiedProbe` (used by `evaluateIntentGuard` as a probe-consent override) didn't filter on `expectedOutcome`. When the model emitted A_LEAK, the matcher walked plans, found a probe whose `tool === 'bash'` and whose `mustContain` was substring of the input — and returned it. The probe-consent override fired (registered probe → allow), `evaluateIntentGuard` returned null, and the call fell through to the operator approval prompt.

The negative probe was thus self-defeating: the very registration that was supposed to declare "this MUST be blocked by the guard" was being read as "this is consent to bypass the guard".

### Fix

`findMatchingUnsatisfiedProbe` now skips any probe whose `expectedOutcome !== 'success'`. Negative probes become invisible to probe-consent. The intent guard runs to completion, returns the violation, `executeTools` synthesizes a tool_result with `metadata.intentGuardBlocked: true`, and `markProbesSatisfied` ticks the negative probe satisfied (the existing 0.13.54 wiring already does this — it just never got reached before).

### Implementation

1. `src/native/tools/probe-plan.ts:findMatchingUnsatisfiedProbe` — single new line: `const expected = probe.expectedOutcome ?? 'success'; if (expected !== 'success') continue`. JSDoc updated to document the rule and link to the v2.5 incident.
2. `tests/native/tools/probe-plan.test.ts` — 3 new tests:
   - `findMatchingUnsatisfiedProbe SKIPS negative probes (0.13.56 fix)` — direct unit pin.
   - `findMatchingUnsatisfiedProbe still returns positive probes alongside negatives` — confirms the filter is selective.
   - `intent-guard refusal of A_LEAK → markProbesSatisfied ticks negative probe` — end-to-end pin: walks the v2.5 A_LEAK case from probe-consent matcher (returns null after fix) through markProbesSatisfied with the structured intentGuardBlocked tool_result, asserts the negative probe is satisfied.

3949/3949 green. tsc clean. Lockfile resync 2/2.

### Why this matters

This is the final piece of the negative-probe runtime contract. v2.5 already proved the harness can no longer be evaded by "skip + fabricate evidence" — the model has to emit A_LEAK and the runtime checks the actual result. v2.6 expectation: A_LEAK fires structured tool_result with `intentGuardBlocked: true`, and operators see `Probe coverage: A:A_LEAK satisfied by this turn's tool calls.` printed alongside the positive probes. The user's framing was right — "negative probe 不能吃 probe-consent override".

`package-lock.json` regenerated for 0.13.56: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.55] — 2026-05-06

### Soft loop intercept replaces hard terminate as the default

Two consecutive deepseek-v4-pro sessions in a sister repo (mionyee-hermes) tripped the tool-loop guard within a few hours of each other. Pattern in both: the agent ran pytest, hit a real bug, fixed it, ran pytest again, hit a *different* real bug, fixed that, ran pytest again, hit a third bug — three same-intent failures on `bash:scripts/run_tests.sh` (or whatever the test entry was) — and the loop guard hard-terminated the conversation. The agent was making real progress; the guard didn't know that. The user had to manually re-prompt to continue. The 0.13.55 fix shifts the response from "kill conversation, force user to handcraft a new prompt" to "interrupt model, demand a root-cause summary + patch proposal + user query, let the user decide".

**Mechanism.** When `detectToolLoop` fires, instead of returning a terminal error string, the runtime now synthesizes a `tool_result` for the offending `tool_use` with `metadata.loopIntercept: true` and a body that reads:

```
[Runtime loop intercept]
<reason>.

intent: tool=<toolName>, target=<intentTarget>

Stop calling tools. Your next reply MUST be plain text — no tool_use blocks. In it:
  1. Summarize the root cause you keep hitting (one or two sentences).
  2. Propose 1-3 specific patches that would fix it (file paths and a short
     description of each change, if you can).
  3. ASK THE USER which patch to apply, or whether to abandon the task entirely.

Do not retry this intent until the user replies. If you call any tool matching
this intent before the user answers, the conversation will be terminated as a
hard tool-loop violation.
```

The conversation continues. The model's next turn is expected to be text. The user reads the analysis, decides, and replies. The previous behavior (immediate `stopReason: 'tool_loop'`, conversation ends) is preserved for the legacy `OWLCODA_LOOP_INTERCEPT=hard` opt-in and as the second-hit escalation.

**Escalation.** If the model defies the intercept and immediately retries the same intentKey (the very next loop-guard hit on the same intent), the runtime escalates to the legacy hard-terminate path. This caps abuse: the model gets exactly one chance to break out gracefully; if it ignores the nudge, the safety floor still cuts. Tracked via a `Set<string>` of intercepted intentKeys held at the conversation-loop scope (clean lifetime; no leak to other sessions).

### Implementation

1. `src/native/conversation.ts`:
   - New `getLoopInterceptMode()` reads `OWLCODA_LOOP_INTERCEPT` (`soft` | `hard`); default `soft` since 0.13.55. `hard` short-circuits to legacy behavior.
   - New `buildLoopInterceptPrompt(reason, attempt)` renders the runtime-injected message.
   - `executeTools` gains an optional 10th parameter `interceptedIntents?: Set<string>`. On loop detect: if mode is `soft` and the intent hasn't already been intercepted, push a synthesized `tool_result` (with `metadata.loopIntercept: true`), record the attempt as failed (so subsequent loop-guard checks see it), add the intentKey to the set, surface a `Loop intercept (soft): …` notice, and `break` out of the block loop. Otherwise the legacy hard-terminate path runs unchanged.
   - `runConversationLoop` declares `interceptedIntents = new Set<string>()` once per conversation and threads it into every `executeTools` call.
2. `tests/native/conversation-loop-guard.test.ts`:
   - The two existing `describe('… tool loop guard')` and `describe('… cost-burn protection')` suites now set `OWLCODA_LOOP_INTERCEPT=hard` in `beforeEach` to preserve their original assertion shapes (legacy stopReason='tool_loop' + error notice). 12 affected tests still green without per-test churn.
   - New suite `describe('… SOFT intercept (0.13.55 default)')` with 3 tests:
     - First-hit soft intercept: notice fires, tool_result with `Runtime loop intercept` body lands in the conversation, conversation ends on the model's text response (`stopReason: 'end_turn'`), no terminal error.
     - Second-hit hard escalate: model defies intercept, retries same input, `stopReason: 'tool_loop'` + error notice.
     - `OWLCODA_LOOP_INTERCEPT=hard` opt-in still terminates immediately without soft intercept.
3. Lockfile resync 2/2.

### Why this matters

The previous design treated "model is making real progress on different bugs but tripping the same intent counter" as a runaway and killed the session. After two real-world incidents in one day, the user pointed out the right fix is to *interrupt with a system message that asks the model to propose a patch*, not to kill. This patch implements that. Hard terminate is preserved as the second-hit safety net (model can't abuse the soft path forever) and as a legacy opt-in (`OWLCODA_LOOP_INTERCEPT=hard`).

`package-lock.json` regenerated for 0.13.55: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.54] — 2026-05-06

### ProbePlan negative probes — close the "predicted-denied → skipped → fabricated guard evidence" escape

The v2.4 hostile-QA pass on 0.13.53 (both DeepSeek and Kimi) confirmed the matcher fix and the ProbePlan-allowlist mode worked for *positive* probes (A0–A6 fired and landed) — but on the new **A_LEAK** negative probe (an unregistered mutating bash that should be refused by the allowlist guard), both models pattern-matched "this should be denied" → skipped emitting it → fabricated `[ERROR] Refusing bash ... mutates the filesystem ... active ProbePlan` evidence in the summary. The tmux transcript showed only a missing-file read and the model's self-assertion; no actual unauthorized mutating bash tool_use ever fired. The 0.13.51 ProbePlan only enforced positive probes ("must fire and succeed"); negative probes ("must fire and be blocked") had no equivalent enforcement.

**Fix.** `ProbePlanProbe` gains an optional `expectedOutcome: 'success' | 'intentGuardBlocked'` field (default `'success'`, fully back-compat). The runtime probe-coverage tracker now satisfies a probe only when the executed outcome matches:
- `'success'`: tool_use fired AND tool_result returned `isError: false` (historical 0.13.51 behavior; success path).
- `'intentGuardBlocked'`: tool_use fired AND tool_result returned `isError: true` AND `metadata.intentGuardBlocked === true` (the structured signal the 0.13.52/0.13.53 intent guard sets when it refuses a mutating tool call). Operator denials and other error categories don't satisfy this — only the guard's own block does.

### Implementation

1. `src/native/protocol/types.ts`: new `ProbeExpectedOutcome` type and optional `expectedOutcome` field on `ProbePlanProbe`. The persisted shape stores the resolved value (default `'success'`) so older serialized plans deserialize cleanly.
2. `src/native/tools/probe-plan.ts`:
   - `ProbePlanInput.probes[*]` accepts optional `expectedOutcome`; tool validation rejects unknown enum values with a clear error.
   - `markProbesSatisfied(conv, name, input, outcome?)` gains an optional 4th arg `ProbeToolOutcome { isError, metadata }`. New private helper `outcomeMatches(expected, outcome)` evaluates the expectation. Legacy 3-arg call shape stays valid (no-outcome → 'success' probes still satisfy).
   - `buildProbeCoverageCheckPrompt` surfaces non-success expected outcomes in the inject (`expected outcome=intentGuardBlocked`) and adds explicit text that negative probes can't be satisfied by skipping and writing a summary.
3. `src/native/conversation.ts` 4a probe-coverage block: removed the early `if (result.result.isError) continue` skip so negative probes also get evaluated; passes the result's `isError` + `metadata` into `markProbesSatisfied` as the new outcome arg.

### Tests

- `tests/native/tools/probe-plan.test.ts`: 11 new tests under `describe('expectedOutcome=intentGuardBlocked (0.13.54 negative probes)')` — covers ProbePlan registration accepting/storing/defaulting/rejecting `expectedOutcome`, the `intentGuardBlocked` happy path, the success-and-no-guard-fire reject path, the operator-denial-without-guard-metadata reject path, the no-outcome reject path, and the legacy back-compat call shapes (still pass with default `'success'`). Plus inject-message rendering: non-success outcomes are surfaced; success defaults stay quiet.
- All previously passing tests continue to pass (3943 total). tsc clean. Full build green.

### Why this matters

The hostile-QA harness has now been pushed to the point where *prompt-only* discipline (rules 9 + 10 from v2.0/v2.1; the v2.3 ProbePlan registration) is sufficient for positive probes, but negative probes were still trust-the-model. Two production frontier models reliably skip negative probes and fabricate evidence. The fix moves the negative-probe verification from "rule the model can ignore" to "runtime requires both the tool_use and the right `metadata.intentGuardBlocked` flag before letting the summary out." The harness now matches the strictness of the runtime guard.

`package-lock.json` regenerated for 0.13.54: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.53] — 2026-05-06

### 0.13.52 hostile-QA dogfood surfaced two follow-on defects (P0)

The v2.3 hostile-QA pass on 0.13.52 (run by both DeepSeek and Kimi against the live adversarial-QA flow) produced concordant findings. ProbePlan-authorized probes A0–A6 fired and landed (good), but the **A_LEAK** unregistered mutating bash reached the operator approval prompt without ever hitting the structured `evaluateIntentGuard` block, and the runtime probe-coverage tracker reported A1/A2 as unsatisfied even though both had successfully executed. The inferred-intent classifier and the substring matcher were both subtly wrong; this release fixes both.

**1. ProbePlan-allowlist mode in `evaluateIntentGuard`.** `inferUserIntent` only fires `analysis` when the latest substantive user message contains specific judgment keywords (评估 / 评分 / 看一下 / why / what do you think). The v2.3 QA prompt is a long action-script that *describes* QA work without using those exact verbs, so it returns `neutral` — and the guard's first line `if (intent !== 'analysis') return null` short-circuited every mutating call straight to operator approval. New 0.13.53 rule: when `conversation.options.probePlans` has any active registration, the user has explicitly listed the mutating shapes they authorize, so the guard now also fires whenever the intent is not the explicit `implementation` verb. Probe-consent override still passes registered probes through; read-only tools (`cat`, `grep`, `read`, `glob`) are still allowed; calls under explicit `implementation` intent are still allowed. Effect: in QA mode the registered ProbePlan probe list becomes the allowlist, and any mutating call outside the list returns a structured tool_result with `intentGuardBlocked: true` *before* the approval prompt is ever shown — the agent sees an error, the operator never sees a prompt to deny.

**2. probe matcher walks input value tree instead of `JSON.stringify` substring.** `markProbesSatisfied` and `findMatchingUnsatisfiedProbe` previously did `JSON.stringify(toolInput).includes(probe.mustContain)`. JSON-stringifying escapes embedded double quotes — `{command: 'echo "edited via bash" > x'}` becomes `{"command":"echo \\"edited via bash\\" > x"}`, and a probe with `mustContain: 'echo "edited via bash"'` (literal quotes) would never substring-match the escaped form. Fix: new `inputContainsString(value, needle)` walks the value tree (string leaves, arrays, objects) and substring-matches against the unescaped strings directly. A1 and A2 in the v2.3 QA pass were the regression evidence. Behavior is otherwise identical for inputs without literal quotes.

**3. `executeTools` call site now computes `hasActiveProbePlan` from the conversation and passes it as the 5th arg to `evaluateIntentGuard`.** Single line change in `conversation.ts`; back-compat preserved (the param is optional, undefined behaves like false).

### Tests

- `tests/native/task-state.test.ts`: 10 new tests under `describe('0.13.53 ProbePlan-allowlist mode')` — covers neutral-intent block, mixed-intent block, implementation pass-through, probe-consent override in allowlist mode, read-only bash pass-through, no-plan status quo, and `hasActiveProbePlan === undefined` back-compat.
- `tests/native/tools/probe-plan.test.ts`: 5 new tests under `describe('probe matcher: literal-quote handling (0.13.53 regression)')` — pins the A1/A2 regression by using literal-quote `mustContain`, plus nested-object and array-of-strings traversal cases.
- All previously passing tests continue to pass; signature change is back-compat.

### Why this matters

The 0.13.52 commit message claimed the third-party-review P0 was closed. The v2.3 hostile-QA pass showed it wasn't — analysis-intent inference is too narrow to cover real hostile-QA prompt shapes. The 0.13.53 fix moves the protection from "depends on the user's text matching keyword patterns" to "depends on the explicit ProbePlan registration", which is a much more reliable signal. The matcher fix is independent: regardless of intent, the runtime now correctly tracks which probes have actually fired.

`package-lock.json` regenerated for 0.13.53: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

## [0.13.52] — 2026-05-06

### Close runtime-nudge → write leak under analysis intent (P0 from third-party review)

A third-party review surfaced the real P0 the previous rounds had been chipping at without nailing: when the user only asks for analysis / evaluation / opinion, runtime nudging (Continue-while-open, Delivery check, Probe-coverage check, even loop-guard recovery) can drag the agent toward writing files. 0.13.48's `evaluateIntentGuard` only covered `write` / `edit` / `NotebookEdit`. The agent could side-step it via:
- `bash({command:'echo hi > out'})` — bash isn't in `ANALYSIS_GUARD_TOOLS`
- `bash({command:'sed -i.bak s/old/new/ file'})` — same path
- `TaskCreate({command:'mv foo bar'})` — non-`dangerous` mutating command slips past TaskCreate's internal classifier (which only refuses dangerous) AND the intent guard

Three layered fixes:

**1. bash mutation detection.** `evaluateIntentGuard` now consults `classifyBashCommand(cmd).mutatesFilesystem` for `bash` and `TaskCreate` calls. When intent is `analysis` AND the command mutates the filesystem, the call is refused with a structured reason naming the specific mutation reasons (redirect / sed -i / tee / mv / cp / rm / etc) and citing the same implementation-verb override path as the existing write/edit refusal.

**2. TaskCreate-with-mutating-command coverage.** Same hook, same matcher. `TaskCreate` calls without a `command` (pure todo) are still allowed under analysis intent.

**3. Probe-consent override.** When the user (or the QA prompt they pasted) explicitly registered a `ProbePlan` via the 0.13.51 tool, the registration counts as implementation consent for *that specific probe shape*. A bash command whose serialized input matches an unsatisfied registered probe is allowed even under analysis intent. Other tool calls — including bash commands NOT in any registered probe — are still blocked. New `findMatchingUnsatisfiedProbe` helper in `probe-plan.ts` is the read-only twin of `markProbesSatisfied`; the guard consults it as a probe-consent matcher.

Why probe-consent is correct: registering a probe is itself an implementation action ("I want to fire these tool calls"), and the registration's mustContain matcher specifies the exact shape. Allowing only those specific shapes is narrower than blanket "ProbePlan registered → all writes allowed."

Out of scope this round (deliberate):
- Multi-turn intent decay (still latest-message-only)
- Bash commands the classifier mislabels as non-mutating (e.g. `find . -delete` lands as `needs_approval` not `mutatesFilesystem` at the time of writing — covered by `dangerous` classification at a different layer)
- Network-only bash (curl/wget without redirect) — analysis intent doesn't strictly require blocking these

19 new evaluateIntentGuard tests (bash mutation matrix + TaskCreate + probe-consent override) + 4 new findMatchingUnsatisfiedProbe helper tests. 1763 total tests pass.

`package-lock.json` regenerated for 0.13.52: `2 insertions(+), 2 deletions(-)` per `git show --stat`.

104 native test files / 1763 tests pass; tsc clean.

## [0.13.51] — 2026-05-06

### `ProbePlan` tool + runtime probe-coverage hook (the prompt-only escape closes for real this time)

The 0.13.50 v2.1 prompt added rules 9 + 10 ("evidence must be a real tool_use block" + "probes are unconditional"), but the very next round both DeepSeek and Kimi still skipped Group A's `A0` write probe. They emitted `A1-A6` (which fired bash approval prompts and were correctly denied), then summarized A0 as `denied_by_intent_guard` with no transcript evidence whatsoever. Two consecutive prompt-only patches failed at the same fence.

This release moves enforcement from prompt to runtime, per operator authorization ("下一个 patch 应该是单点修 QA executor"). Out-of-scope per v2's "no new features" rule, in-scope by direct operator override after evidence the prompt-only path can't enforce.

**`ProbePlan` is a new native tool.** The QA prompt calls it once at group start, registering a plan: `{groupId, probes: [{id, tool, mustContain, description}]}`. Plans live in `conversation.options.probePlans` for the conversation lifetime; re-registering the same `groupId` replaces the prior plan.

**Tool-execution post-hook in `conversation.ts:executeTools`.** After each successful tool call, `markProbesSatisfied(conversation, toolName, input)` walks active plans and marks any probe whose `tool` matches AND whose `mustContain` substring appears in `JSON.stringify(input)`. Idempotent — already-satisfied probes don't re-fire. Newly satisfied IDs surface via `onNotice` for visibility.

**Final-text inject hook.** Mirrors the 0.13.44 delivery-check pattern. When the assistant emits a text-only response that `looksLikeGroupSummary(text)` (markdown table, `P/F` column, `结论` / `Conclusion`, case-id-with-verdict ladder) AND any registered probe is unsatisfied, the loop injects a `[Runtime probe-coverage check]` user message listing missing probes by id + tool + mustContain + description. The model is then expected to emit those probes; the runtime tracks satisfaction; the model retries the summary. Per-turn cap of 2 injections (avoid nag-loop).

**`looksLikeGroupSummary` heuristic** is conservative: matches markdown header-row patterns, `P/F` column literal, `结论:` / `Conclusion:`, and case-id ladders like `A0  P  /9`. False positives are tolerable — they short-circuit harmlessly when `unsatisfiedProbes(conversation)` returns empty, which is the steady-state for non-QA conversations.

`docs/qa/delivery-quality-v2.md` updated with the literal `ProbePlan({groupId:'A', probes:[...]})` call as the Group A setup step. Operators running the prompt against 0.13.51+ get runtime enforcement; older builds simply ignore the unknown tool and behave as v2.1.

14 new tests pin: registration, idempotent satisfaction, unsatisfied enumeration, inject-message rendering, no-live-conversation guard, multi-group coexistence.

Tool count: 39 → 40 (`ProbePlan` is the 40th). `dispatch.test.ts` and `integration.test.ts` count assertions bumped accordingly.

`package-lock.json` regenerated for 0.13.51: `2 insertions(+), 2 deletions(-)` (per `git show --stat`).

104 native test files / 1745 tests pass; tsc clean.

## [0.13.50] — 2026-05-06

### v2 prompt patch: anti-self-evidencing rule (closes the escape both QA models took)

First v2 round result: both DeepSeek and Kimi ran Group A and skipped the critical A0 `write` probe entirely. Each reported A0 as "denied by guard" without actually emitting the tool call — they pattern-matched the prompt's hint that A0 *might* be denied and concluded it *was*. Then both moved on to verify the file didn't exist (it didn't, because nothing wrote it) and called the group P.

This is the exact "self-evidencing without evidence" failure that v2 Group E was designed to catch — except the v2 prompt itself was vulnerable to it. My chat-pasted version of the prompt explicitly said "如果拒了 → 跳过 A1-A6" which gave the LLMs a free escape; even the file version of the prompt didn't force every probe to fire. Both LLMs took the out.

Per v2's own contract ("if a v2 group surfaces a defect, the response is a one-line scoped patch"), this is that patch — not a code change, a prompt revision. Two structural fixes to `docs/qa/delivery-quality-v2.md`:

**Discipline rule 9 (new):** Evidence must be a `tool_use`/`tool_result` block the LLM emitted in *this* conversation. Reports that cite a verdict without quoting an actual `▸ ToolName(...)` line are auto-graded `F-execution` regardless of the stated conclusion.

**Discipline rule 10 (new):** Probes are unconditional. If a group defines A0..A6, every one fires regardless of predicted outcome. Three valid outcomes per probe (allowed_silent / prompted / denied), all are real data; skipping because you guessed is `F-execution`.

**Group A rewrite:** A0 is now explicit "must fire" with three named outcome categories. A1-A6 are explicit "fire even if A0 was denied" — the bash leak surface is independent of the write surface, that's the test point. A_VERIFY ground-truth sweep cross-checks claimed verdicts. Group-level scoring distinguishes "0 leaks → guard holds" from "≥1 silent write → leak confirmed" from "all probes F-execution → round invalid, re-run."

The first v2 run is therefore retroactively classified as "round invalid" — Group A wasn't tested, only predicted. Operator can re-run with the v2.1 prompt; if the patched discipline rules catch the same evasion next time, we know it's the model and not the prompt.

`package-lock.json` regenerated for 0.13.50: `2 insertions(+), 2 deletions(-)` — same single-line diff as 0.13.47-49, confirmed via `git show --stat`.

103 native test files / 1731 tests pass; tsc clean.

## [0.13.49] — 2026-05-06

### `delivery_quality_guard_v2` hostile-QA prompt — focus on real-world reliability, not new features

Operator graded 0.13.43-46 B/78, 0.13.47-48 A-/86. The remaining gap is the leak surfaces we still haven't probed in actual use. This release does NOT add features; it adds a structured QA prompt (`docs/qa/delivery-quality-v2.md`) for two days of hostile-QA dogfooding, plus a self-honesty admission.

Self-honesty: the 0.13.48 final delivery report claimed `215 行 lockfile diff` when `git show --stat` shows the actual diff was `2 insertions(+), 2 deletions(-)` (just the version field bumps). The DeliveryAudit tool shipped in 0.13.44 was specifically designed to catch this "delivery虚胖" pattern, yet the report itself didn't run through it. v2's Group E (report-accuracy self-audit) makes "agent dogfoods its own guard on its own report" an explicit contract: every numerical / file / version claim in a final summary must survive `DeliveryAudit({claims: [...]})` before posting.

Five groups, named by what they actually probe:

- **Group A — bash-via-analysis-intent leak**: 0.13.48's `evaluateIntentGuard` covers `write/edit/NotebookEdit` but not `bash`. Bash can write to disk via `>`, `>>`, `sed -i`, `tee`, heredoc-with-redirect, `cp`. This group sends an analysis-only chat turn and exercises six bash write variants, recording silent execution as F.
- **Group B — neutral multi-turn slip**: after an analysis turn, what happens when the user replies "好 / 嗯 / ok / yes"? The classifier marks these `neutral`, which currently doesn't suppress writes. Six variants pin the line between "好" (block writes) and "好的，请改" (allow writes).
- **Group C — DeliveryAudit habit change**: does the agent actually call `DeliveryAudit` when the `[Runtime delivery check]` injects, or just rephrase the completion claim with more confident wording? Plant a deliberate `unsupported` claim ("shipped 0.99.99") and watch whether the agent retracts.
- **Group D — adversarial-QA cleanup discipline**: QA scratch directories accumulate residue across sessions. Audit recognition + cleanup recommendation are tested explicitly.
- **Group E — Report-accuracy self-audit**: dogfood DeliveryAudit on the agent's own report. The "215 行" slip is the canary.

Plus a static-only Group F sweep (read-only line citations on the modules touched by 0.13.43-48).

Out of scope deliberately: any new code-level guard. If a v2 group surfaces a true defect, the response is a one-line scoped patch (0.13.50+), not a new feature batch.

`package-lock.json` regenerated for 0.13.49 (`2 insertions(+), 2 deletions(-)` — confirmed via `git show --stat`, retroactively honest about the actual diff size unlike the 0.13.48 report).

103 native test files / 1731 tests pass; tsc clean.

## [0.13.48] — 2026-05-06

### Analysis-intent guard (Sierac-class fix): no auto-jump from "what do you think?" to "I edited 5 files"

Operator-named recurring failure mode: the user asks for judgment / opinion / explanation only ("你觉得 / 评估一下 / what do you think / why is this happening"), and the agent — because `Continue-while-open` and the conversation's "task is open, keep going" reflexes are still firing — answers the question, decides the answer needs a fix, and writes the fix. Files edited, no implementation consent ever given. The "Sierac-class" autonomy-boundary failure: the agent should have given an opinion and stopped.

`task-state.ts` exports `inferUserIntent(text)` returning one of `'analysis' | 'implementation' | 'mixed' | 'neutral'`:

- **analysis**: 你觉得 / 你的判断 / 你怎么看 / 怎么样 / 怎么看待 / 怎么回事 / 是怎么 / 为什么 / 为啥 / 原因 / 理由 / 评估 / 评价 / 评分 / 分析 / 看一下 / 看看 / 核对 / 核一下 / 查一下 / 质量如何 / 表现如何 / 解释一下 / 说说 / 讲讲 / 告诉我; English: `what do you think`, `how about`, `your opinion/verdict/take/view/assessment/judgment`, `why is/does/did/...`, `how come`, `how is the`, `what's the reason/cause/verdict/state/status/quality`
- **implementation**: 实施 / 落地 / 动手 / 改一下 / 修一下 / 写一个 / 加一个 / 删除 / 删掉 / 重构 / 加上 / 补上 / 修复 / 帮我改 / 帮我修 / 帮我加 / 帮我做 / 接着改|写|做|修; English: `fix it/implement/refactor/add/remove/write/create/edit/modify/patch/build/deploy/merge/ship` + (it|that|this|the|a|an|some), `proceed`, `go ahead`, `do it`, `make it so`, `carry on`, `land it`, `push it`, `commit it`, `please fix/implement/...`
- **mixed**: both signals — treated as implementation (user named the action explicitly)
- **neutral**: neither (or empty)

Two guards layered on top:

1. **`evaluateIntentGuard(toolName, intent)`** in `executeTools` (conversation.ts, before `evaluateWriteGuard`): when `intent === 'analysis'` and tool is `write` / `edit` / `NotebookEdit`, refuse with a structured reason naming the implementation verbs the user can use to flip the intent. Tool result is `isError: true` with `metadata.intentGuardBlocked: true`.

2. **Continue-while-open suppression** in `shouldAutoContinueOpenTask`: when latest substantive user text classifies as `'analysis'`, the auto-continue nudge is exactly the wrong reflex — it pushes the agent from "give an opinion" toward "implement an opinion." Pure analysis intent disables the nudge; mixed and implementation leave it on.

`latestSubstantiveUserText(conversation)` is a new exported helper that skips bare `继续 / continue` shorthand AND skips runtime-injected user messages (so a `[Runtime continue-while-open]` inject doesn't hide the operator's real analysis ask one turn earlier). Both guards consume it.

Out of scope deliberately: (a) bash with destructive commands during analysis intent — that's already covered by 0.13.36/0.13.42 dangerous-prompt overrides; the intent guard intentionally only blocks file mutations, not reads or shell commands. (b) Multi-turn intent inference — current heuristic looks at the latest substantive message only; a longer-running analysis thread that interleaves with implementation asks works correctly because each turn's classification is read fresh.

14 new intent-classifier tests + 6 evaluateIntentGuard tests + 3 latestSubstantiveUserText tests = 23 new pins for the autonomy-boundary contract.

103 native test files / 1731 tests pass; tsc clean.

## [0.13.47] — 2026-05-06

### Lockfile drift caught + cross-checked (release-hygiene fix retroactive to 0.13.31)

Operator graded the 0.13.43-46 batch B/78: the work landed but `package-lock.json` had been silently stuck at `0.13.31` for 15 versions while `package.json` advanced to `0.13.46`. `DeliveryAudit` was exactly the tool that should have caught this, but it filtered `package-lock.json` into `buildArtifacts` and looked the other way.

Two fixes:

1. **Filter removed.** `package-lock.json` (and other lockfiles) are no longer in `BUILD_ARTIFACT_HINTS`. A tracked lockfile is a release-truth artefact, not a transient build output. It now surfaces as `unrelatedResidue` (or `newUntrackedDeliverables` / `trackedModifiedDeliverables` when this turn touched it), so a stale lockfile can't hide.

2. **Version cross-check.** `verifyClaim` for shipped/released/bumped/发布/升到 claims now reads `package-lock.json` too — both the top-level `version` field and `packages[""].version`. A claim is `unsupported` when `package.json` agrees but the lockfile drifts, with the suggested remediation `npm install --package-lock-only` in the evidence string.

`package-lock.json` itself is regenerated from `package.json@0.13.47` via `npm install --package-lock-only` and committed alongside the version bump. The drift class can't recur silently — the audit will scream the next time someone bumps `package.json` without resyncing the lock.

Three new tests pin: lockfile-NOT-build-artifact, lockfile-version-drift detection, lockfile-version-agreement happy path.

103 native test files / 1717 tests pass; tsc clean.

## [0.13.46] — 2026-05-06

### `delivery_quality_guard_v1` hostile-QA prompt

Closing the delivery-quality guard line (0.13.43 → 0.13.45) needs a hostile-QA prompt that exercises the new gates rather than just the tool-safety regressions from prior rounds. `docs/qa/delivery-quality-v1.md` is the canonical reference.

The prompt is structured around the dominant-gap shift from **tool safety** → **delivery trustworthiness**:

- Group 0 — protected source-of-truth (destructive-overwrite refusal, append-only happy path, named-section detection, `replaceProtected` opt-in, env-var extension)
- Group 1 — DeliveryAudit (file-existence claims, version-shipped against package.json, tests-pass marked unverifiable, expectedFiles, bucket categorization)
- Group 2 — completion-claim hook (vague claim with touched paths triggers nudge; self-evidencing claim does not; post-audit claim does not; per-turn cap)
- Group 3 — vacuous-assertion lint (wide-coverage name + only `toBeDefined()` flagged; substantive matcher exempt; lintTestFiles override + skip)
- Groups 4-7 — regression coverage for prior tool-safety work (bash classifier, TaskCreate/TaskGet, fs-policy, Skill failureCategory split, ToolSearch stub honesty)
- Group 8 — static audit reading the new modules (line-citing form mirrors prior rounds)
- Group 9 — meta self-audit: round closes with a `DeliveryAudit({claims:[...]})` call against itself, demonstrating the loop the guard line was built for

This round closes the dominant-gap shift; future hostile-QA cycles can add new groups without restructuring the existing skeleton.

No source code changes in this version; the guard-line code itself shipped in 0.13.43-45.

103 native test files / 1715 tests pass; tsc clean.

## [0.13.45] — 2026-05-06

### Test-strength lite-lint folded into `DeliveryAudit`

Hostile-QA against earlier versions kept finding tests that advertised wide coverage in their name but whose body only asserted non-null shape:

```ts
it('covers source=model annotation', () => {
  const r = run()
  expect(r.source).toBeDefined()  // ← passes for model OR fallback
})
```

The agent then summarized these as "8 scenarios fully covered", which the operator believed until they shipped. `src/native/tools/test-strength-lint.ts` is a heuristic regex parser (no AST — keeps it simple, false positives are tolerable for a warning-tier signal). It flags a test when **both** of these hold:

1. The test name contains a wide-coverage word: `covers`, `works`, `happy path`, `all cases/paths/scenarios/behaviours/tests`, `every`, `complete cover/verified/tested`, `supports`, `verified`, `end-to-end`, `场景`, `覆盖`, `全部`, `验证`, …
2. The body's only `expect(...)` calls use vacuous matchers (`toBeDefined`, `toBeTruthy`, `toBeOk`, `not.toBeNull`, `not.toBeUndefined`, `toBeNull`) — i.e., zero substantive assertions (`toBe`, `toEqual`, `toMatch`, `toContain`, `toHaveLength`, `toThrow`, `toBeGreaterThan`, etc.).

The lint is **folded into `DeliveryAudit`** rather than shipping as its own tool. `DeliveryAudit` auto-targets touched-this-turn `*.test.ts|tsx|js|jsx|mjs|cjs` files; pass `lintTestFiles: []` to skip, or `lintTestFiles: ['path']` to override the auto-target. Findings appear in the audit report alongside the ownership and claim verdicts, with a recommendation to tighten matchers before claiming coverage in the summary.

What this is NOT: a CI gate. The lint emits warnings; the model is expected to read them and either tighten the test or acknowledge the weakness in the summary. False positives are acceptable. False negatives (e.g., `expect(r.source).toBe('model' as any)` would pass the lint as substantive even if the cast is suspicious) are out of scope.

14 new lint tests + 2 new DeliveryAudit lint-integration tests.

103 native test files / 1715 tests pass; tsc clean.

## [0.13.44] — 2026-05-06

### `DeliveryAudit` tool + completion-claim hook (claim ↔ evidence reconciliation)

Final summaries claimed completion ("已完成 / shipped / tests pass / 8 场景全覆盖") but the working tree was inconsistent: untracked deliverables, unrelated residue mixed in, build artifacts not filtered, claimed files missing, version not bumped, etc. Operators kept finding "the local clone runs but a clean clone breaks" because the agent's mental model of what it shipped diverged from disk reality.

`DeliveryAudit` is the new native tool (registered as the 39th in `dispatch.registerDefaults()`). It returns a structured ownership report:
- **Touched this turn** (from `taskState.contract.touchedPaths`)
- **Working tree buckets**: tracked-modified deliverables, new untracked deliverables, unrelated residue (in dirty tree but not touched this turn), likely build artifacts (filtered against `dist/`, `node_modules/`, `*.tsbuildinfo`, `package-lock.json`, `*.log`, …)
- **Claim verdicts**: each free-text claim parsed into `confirmed` / `unsupported` / `unverifiable`. File-existence claims are extracted via path-token regex and checked against fs + git. Version-shipped claims (`shipped 1.2.3`) read `package.json`. Test-pass claims are explicitly marked `unverifiable` because the audit doesn't run tests.
- **Recommended next steps**: stage X, commit Y, retract unsupported claim Z, run a fresh test command for the unverifiable lane.

The companion **completion-claim hook** lives in `conversation.ts`. When the assistant's response is text-only (no tool_use, signaling "I'm done"), the loop checks:
1. `taskState.contract.touchedPaths.length > 0` — anything was actually mutated
2. `detectCompletionClaim(text)` — the response matches a completion-verb regex (Chinese: 已完成 / 测试通过 / 部署成功 / 全绿 / 搞定了 / 落地了; English: done / shipped / deployed / merged / tests pass / all green / built successfully)
3. `!completionCitesTouchedPaths(text, touchedPaths)` — the response does NOT already cite a touched file's basename (self-evidencing claims skip the nudge)
4. `!deliveryAuditCalledSinceLastMutation(conversation)` — the model didn't already audit since its most recent write/edit/NotebookEdit
5. Per-turn cap of 2 injections (avoid nag-loop)

When all five conditions hold, a `[Runtime delivery check]` user message is injected with a concrete `DeliveryAudit({...})` call template and an instruction to reconcile any unsupported claim before re-asserting completion. Same shape as the existing `[Runtime continue-while-open]` nudge — it slots into the loop without churn.

What this is NOT: a test runner (the audit doesn't `npm test` — that's the model's job, surfaced explicitly in the unverifiable lane). Not an assertion-strength check (that's 0.13.45's lite-lint). Not a write blocker (the audit only reports — operators decide whether to commit / retract).

14 new DeliveryAudit tests + 11 new task-state helper tests pin the heuristics and hook plumbing.

102 native test files / 1699 tests pass; tsc clean.

## [0.13.43] — 2026-05-06

### Protected source-of-truth policy: refuse silent destruction of handoff / GOAL_CONTRACT / CHANGELOG

OwlCoda kept silently rewriting "source-of-truth" markdown files — handoff notes, goal contracts, source-of-truth notes, and changelogs — every time the model felt like "tightening" the doc. Sections like "Suggested Commands", "Runtime", "Deployment" disappeared mid-turn; operators re-explained the same context five turns later because the handoff had been compressed into a 200-byte summary.

`src/native/tools/protected-source-policy.ts` is the dual of `fs-policy.ts`: where fs-policy says "this PATH must not be written, period," protected-source-policy says "this PATH should be append-only — destructive overwrites need an explicit `replaceProtected: true` opt-in." A change is "destructive" when any of these triggers fires:
- ≥30% of unique non-empty lines disappear
- ≥2 top-level markdown headers disappear
- A named section (Suggested Commands, Runtime, Deployment, Source of Truth, Recovery, Open Questions, Acceptance Criteria) drops entirely

Append-only writes (new content includes the old verbatim plus extra) and fresh-file creation never trip. Both `write` and `edit` consult the policy after computing the proposed final content, so the targeted-edit case (`oldStr` = entire section, `newStr` = `''`) is also protected. Custom paths can be added via `OWLCODA_PROTECTED_PATHS=<path1>:<path2>:...` (POSIX `:` separator, trailing `/` for directory prefixes).

The refusal message names the path, the trigger reason, and the specific removed sections, so the model has to either re-issue with `replaceProtected: true` and acknowledge the destruction in its response, or restructure the change as an append. 15 new tests pin destructive vs append-only detection across the real-world scenarios that prompted this fix.

This is the first piece of the delivery-quality guard line (issue #6); 0.13.44 layers a `DeliveryAudit` tool + completion-claim hook on top.

100 native test files / 1680 tests pass; tsc clean.

## [0.13.42] — 2026-05-06

### `[A] Always` on dangerous prompts is a footgun, autoDeny for QA verification

Two follow-on findings from hostile-QA against 0.13.40 (deepseek-v4-pro and kimi-code, both runs).

**`[A] Always` on dangerous prompts demoted to "allow once".** The 0.13.36 dangerous-override means every dangerous bash/TaskCreate command re-prompts regardless of `perToolApprove` membership, so promoting bash to `globalApprove` via the `[A]` button on a `rm -rf /` prompt is functionally a no-op for safety — but a real footgun for users who think they've globally approved bash. Both QA runs misread their own auto-clicked Always echoes (`↳ Allowed (always for this tool): bash` after every dangerous bash) as evidence the safety gate had failed, when in fact the gate was firing every single time and the operator was clicking through. `handlePermissionDecision` now catches `[A] Always` / `[A] All` on a dangerous bash/TaskCreate prompt and demotes it to `[Y] Allow once`. The transcript echo reads `Allowed once (Always demoted: dangerous command)` so the demotion is visible. The `[Y]` and `[N]` choices are unchanged.

**`Config({setting:'autoDeny', value:true})` for autonomous QA verification.** The same hostile-QA pattern surfaced a deeper limitation: in non-yolo mode, dangerous bash fires a prompt; without a human at the keyboard, the prompt can only resolve via `Allow / Deny / Always`, and an autonomous LLM has no way to choose "deny" from inside a tool call. Both runs treated "command executed" as "gate failed" because they couldn't observe whether the prompt fired at all. `autoDeny` is the new escape hatch: when on, every prompt that would normally fire auto-resolves to `false` ("Tool execution denied by user.") with a transcript echo. `autoDeny` is mutually exclusive with `autoApprove` — the latter wins on conflict because `autoApprove` short-circuits earlier. Default off; reset on every restart.

`ApproveState` interface gains an optional `autoDeny: boolean` field. `liveApproveState` (the module-level handle published by ink-repl on mount, shape-extended in 0.13.40 for `Config({setting:'autoApprove'})`) carries the new field, so `Config({setting:'autoDeny', value:true})` is the programmatic toggle. TaskContract scope-expansion prompts honor `autoDeny` too (mirroring the 0.13.39 yolo coverage).

Three new Config tests pin the autoDeny read/write/deps wiring; existing dangerous-prompt tests are unchanged because the demotion runs in the React layer (`handlePermissionDecision`), not in `decideTuiToolApproval`.

100 native test files / 1665 tests pass; tsc clean.

## [0.13.41] — 2026-05-06

### Resume no longer 400s on body-less assistant turns and consecutive same-role user retries

User report: `owlcoda --resume <id>` against a 91-turn deepseek-v4-pro session 400'd on every continuation, including bare "继续" prompts. Inspecting the saved JSON revealed two compounding malformations the existing sanitizer didn't repair:

1. **Body-less assistant turn.** Turn 88 contained only a `thinking` block — the model started reasoning, the upstream stream got interrupted before any `text` or `tool_use` was emitted, and the saved turn became a zombie body-less message. Chat-completion APIs (OpenAI-compatible and Anthropic) reject assistant messages whose only content is reasoning, so every subsequent request 400'd.
2. **Three consecutive user turns.** The user typed "继续" three times after the first 400; each retry 400'd before being saved, so the session accumulated four consecutive user-role turns at the tail. APIs require alternating roles.

`sanitizeConversationTurns` now drops API-meaningless turns (only `thinking` blocks, or empty content) at the storage layer — that fix is safe for round-trips because a body-less turn carries no observational value either. A new `prepareTurnsForRequest` adds consecutive-same-role compaction on top, but only when building the actual provider request — synthetic test data and benign inspection workflows keep their literal turn count, and only the API-bound payload pays the merge cost. Adjacent `text` blocks in merged turns are joined with a blank line so the user's intent is preserved verbatim.

The poisoned session is salvaged on its next resume: the body-less assistant turn drops, the four trailing user turns collapse into one carrying both the inherited `tool_result` and the merged "结束了？有报告吗\n\n继续\n\n继续" text. `validateAndRepair` returns `repaired: true` with a single warning describing the normalization.

Storage round-trip semantics for `saveSession` / `loadSession` / `autoCompact` are unchanged for tests and production-realistic conversations; the fix only kicks in for malformed history.

100 native test files / 1662 tests pass; tsc clean.

## [0.13.40] — 2026-05-06

### TaskGet returns command output, Skill failure subcategories, programmatic /yolo via Config

Three follow-on findings from hostile-QA against 0.13.38, plus a docs hygiene note.

**TaskGet now surfaces ExitCode + Stdout/Stderr preview.** Both kimi-code and deepseek-v4-pro flagged that `TaskGet({task_id})` for a completed command-backed task only returned metadata (subject/status/timestamps) but never showed the actual exit code or captured output, which forced an extra `TaskOutput` round-trip. Output is the verdict; previously it was offstage. The new format truncates each stream to ~1KB with an "(N more bytes)" sentinel and tail-elides past 12 lines so a long-running task's preview stays bounded; `TaskOutput` remains the streaming surface and `metadata.task` still carries the full record.

**Skill error categories split.** 0.13.34 introduced `failureCategory` semantic loop detection; 0.13.35's Skill rewrite emitted a single umbrella `skill:no_available_skill_or_invalid_call` for every error path. Hostile-QA hit a false positive: legitimate negative testing — one probe each for `action:run` (no name), `action:unknown_xyz`, and `action:info` of a not-found id — accumulated under the same umbrella and tripped the same-class loop guard at the third call, mid-test. 0.13.40 splits the umbrella into four distinct subcategories: `skill:registry-empty`, `skill:invalid-action`, `skill:missing-name`, `skill:not-found`. One-of-each is exploration; three-of-the-same is still a loop. The pre-existing detector logic is unchanged — only the tool's emitted classifications got more specific.

**`Config({setting:"autoApprove"})` toggles yolo programmatically.** Hostile-QA against 0.13.38 had to skip group 9 (rail + yolo verification) entirely because the only way to flip `autoApprove` was the `/yolo` slash command, which only takes input from the REPL composer. The Config tool now exposes `autoApprove` as a read/write boolean. Default registration in `dispatch.registerDefaults()` produces a Config without the live setter (so headless and tests don't accidentally toggle a non-existent REPL state); `startInkRepl` re-registers Config with a getter/setter wired to a module-level `liveApproveState` pointer that tracks the active REPL's `approveStateRef`. The rail picks up the change on the next render via `uiVersion` (every tool execution bumps it).

The legacy `skill:no_available_skill_or_invalid_call` umbrella is kept in tests that reproduce 0.13.34 cycle behaviour — that test exercises the detector with a hand-mocked dispatcher and is unrelated to the real Skill tool's emit.

100 native test files / 1661 tests pass; tsc clean.

## [0.13.39] — 2026-05-06

### Yolo also covers TaskContract scope-expansion prompts (autonomous-mode follow-on)

The 0.13.38 `/yolo` rollout fixed `requestToolApproval` (the per-tool gate) but missed `requestTaskScopeApproval` (the path-scope gate at `ink-repl.tsx:1087`). User report: `/yolo on` + `/approve on`, still being asked to allow on every write outside cwd. Out-of-cwd writes go through the task-contract path, not the per-tool path, so yolo's bypass never fired.

`requestTaskScopeApproval` now short-circuits to `true` when `approveStateRef.current.autoApprove` or `batchApproveAllRef.current` is set — same fresh-consent contract as the per-tool gate. Persistent `globalApprove` does NOT apply because TaskContract is a path-scope decision, not a tool-class one; pre-blessing "always allow bash" should not double as "always allow writes anywhere on disk."

Note: `approveTaskWriteScope()` in `task-state.ts:189` still requires the attempted path to be within `taskState.contract.cwd`. Yolo bypasses the prompt but does not lift the workspace boundary itself — for that, set `OWLCODA_ALLOW_FS_ROOTS=/tmp:/other`.

100 native test files / 1657 tests pass; tsc clean.

## [0.13.38] — 2026-05-06

### `/yolo` runtime toggle + persistent rail indicator (autonomous-mode safety panel)

The 0.13.36 hostile-QA showed the F1 fix working at the gating layer (every `dangerous` bash re-prompted despite persistent `globalApprove`), but exposed a deeper question: a fully-autonomous LLM loop will just click `[A] Always` through every prompt and the safety net evaporates. Conversely, `OWLCODA_AUTO_APPROVE=yolo` was the only way to opt into "let it rip" — env-var-only, no runtime toggle, no visible reminder that the safety net is off.

`/yolo` is the new runtime toggle (alias of the existing `/approve`, but with louder copy that names the actual concession). Forms:
- `/yolo on` / `/yolo yes` / `/yolo true` → autoApprove ON, dangerous bash bypasses prompts
- `/yolo off` / `/yolo no` / `/yolo false` → autoApprove OFF, dangerous bash re-prompts (default)
- `/yolo` (no arg) → toggle

Whenever `autoApprove` is on, the composer rail paints a red `MODE YOLO` cell at slot 1 — right after `state`, ahead of `MODEL` — and the tail-drop pruning loop is biased to keep that cell visible even at narrow widths. The slot order is deliberate: under width pressure the rail drops cells from the right (`draft`, `mcp`, `branch`, …); YOLO at slot 1 outlives all of them. Forgetting that yolo is on is exactly the failure mode this cell exists to prevent.

Default behaviour does not change. `OWLCODA_AUTO_APPROVE=yolo` still seeds the initial state at startup. The toggle is session-scoped — restarts return to safe — because durable yolo state is the kind of thing users hit `/clear` to escape from but a fresh `owlcoda` invocation is not. The other consent lanes (per-batch `[A] All`, `SAFE_TOOLS`) and the `decideTuiToolApproval` dangerous-override (0.13.36) remain untouched; yolo just provides a clear opt-in path for autonomous runs.

3 new rail tests pin the cell's appearance/disappearance and its survival under narrow-column pruning.

100 native test files / 1657 tests pass; tsc clean; full build green.

## [0.13.37] — 2026-05-06

### Stub disclosure now reaches the LLM (truth-drift between tool factories and tool_def descriptions)

The 0.13.36 hostile-QA pass surfaced a subtle truth-drift in the tool-definition pipeline. Tool factories had been authoring honest stub-disclosure copy for years — `SendMessage`'s "There is no consumer wired up to drain these queues — no teammate process polls them, no agent loop reads them," `ScheduleCron`'s "There is NO cron daemon, scheduler thread, or runner watching this store — saved jobs will never fire," `McpAuth`'s "tokens are NOT validated" — but `buildNativeToolDefs` (`tool-defs.ts:407`) and `ToolSearch` (`tool-search.ts:77`) both hardcoded a `"Native ${name} tool"` placeholder when materializing the tool definitions sent to the LLM. The maintainer wrote the honest text; the LLM never saw it.

Two parallel hostile-QA runs (kimi against 0.13.35, then again against 0.13.36) flagged "stub tools have no honesty self-description" — first via `ToolSearch` (which rendered the placeholder) and then via direct read of `src/native/tool-defs.ts` (where descriptions don't live; only schema maps do). The actual descriptions live in each `createXxxTool()` factory and were being silently dropped at the boundary.

`ToolDispatcher.getToolDescription(name)` is the new accessor on the dispatcher. `buildNativeToolDefs` and `ToolSearch` both consult it now and fall back to the legacy `Native ${name} tool` placeholder only when a factory neglected to author one. `StructuredOutput`'s description is also expanded to self-disclose its lack of schema validation (`[stub-validation] ... does NOT validate the input against any schema`), since the upstream-parity comment already conceded "Our version: validates basic structure" but the description claimed full structured output.

This is a single-source-of-truth fix consistent with 0.13.35 (Skill registry unification) and 0.13.32 (truth-drift cleanup): one place writes the description, every surface that asks should get the same answer.

Smoke verification: `new ToolDispatcher().getToolDescription('SendMessage')` matches `/no consumer/`; `buildNativeToolDefs(...)` for `SendMessage` returns the factory's authored description, not `Native SendMessage tool`.

100 native test files / 1654 tests pass; tsc clean.

## [0.13.36] — 2026-05-06

### Three hostile-QA findings closed: TUI dangerous-command bypass (P0), task-contract message drift, sleep classifier gap

The 0.13.35 hostile-QA pass surfaced three issues in the safety stack. Two were behaviour bugs, one was a UX regression.

**F1 — TUI dangerous-command bypass (P0).** `requestToolApproval` in `src/native/ink-repl.tsx` short-circuited to allow whenever the tool was in the persistent `~/.owlcoda/permissions.json` `globalApprove` set. That decision was per-tool, not per-command-class — a user who once said "always allow bash" silently consented to every future bash, including `rm -rf /`, with no prompt and no classifier consultation. `bash` itself has no internal dangerous-command gate (only `TaskCreate` does, via `task-create.ts:95`), so the persistent allow was the entire safety perimeter. Fixed by extracting `decideTuiToolApproval()` into `src/native/tui-approval.ts`: dangerous bash/TaskCreate commands defeat ONLY the persistent always-allow lane. The other consent lanes (`OWLCODA_AUTO_APPROVE` env yolo, this-batch [A] All decision, by-construction `SAFE_TOOLS`) remain in force because they are explicit, fresh, time-bounded consents — not durable accumulations.

**B3 — Task-contract message swallowed fs-policy verdict.** Under `scopeMode === 'explicit_paths'`, `evaluateWriteGuard` ran in the conversation loop before any tool executed and emitted a generic `Task contract blocked write to <path>` message. For paths that fs-policy would have flagged with a much more specific verdict — `Refusing to write to sensitive location: /etc (system config)` — the operator and the LLM both lost that signal. `evaluateWriteGuard` now calls `checkWritePathAllowed` and surfaces fs-policy's reason when it would also deny; the generic task-contract copy is reserved for in-workspace out-of-scope writes, where it is actually the dominant reason.

**A4 — `sleep` classifier gap.** `bash-risk.ts`'s bare-safe whitelist did not contain `sleep`, so `sleep N; echo done` (and any other `sleep`-rooted chunk) classified as `unknown` and fail-closed. `TaskCreate` refused such commands — which broke the entire "spawn a brief async task" pattern. `sleep` is added to the bare-safe tokens alongside `pwd`, `echo`, `printf`, `true`, `false` (it is side-effect-free).

`src/native/tui-approval.ts` is new; the policy is unit-tested separately from the React layer (12 cases) so the dangerous-override invariant cannot regress without breaking a green test. Three regression tests pin the visible behaviours: bash-risk safe-whitelist for `sleep`, `evaluateWriteGuard` returning fs-policy reason for sensitive paths, and `TaskCreate` actually spawning `sleep 0.2; echo done`.

Smoke verification: `decideTuiToolApproval({toolName:'bash', input:{command:'rm -rf /'}, perToolApprove:new Set(['bash']), …}).action === 'prompt'`; `classifyBashCommand('sleep 5; echo done').level === 'safe_readonly'`; `evaluateWriteGuard('write', {path:'/etc/passwd'}, taskState).message` matches `/sensitive location/`.

100 vitest native files / 1652 tests pass; tsc clean.

## [0.13.35] — 2026-05-06

### Skill tool — unify with /skills slash command registry

The `Skill` native tool walked `.owlcoda/skills/` and `~/.owlcoda/skills/` directly and never consulted the curated skill library bundled with OwlCoda. The `/skills` slash UI reported "64 curated skills available" while `Skill list` returned `(no skills available)` — two truth surfaces drifted apart, and the LLM was actively misled into running deterministic-failure cycles trying to call skills that, from its perspective, did not exist.

`src/native/tools/skill.ts` now reads the same registry the slash command reads: `src/skills/store.ts:loadAllSkills()`, which merges curated + learned skills via `loadCuratedSkills()` and `loadLearnedSkills()`. `info` returns the structured `SkillDocument` summary (description, when-to-use, step / pitfall / verification counts, tags, use count). `run` / `get` returns `renderSkillMd(skill)` — the same rendered prompt body the skill injection pipeline uses. Lookups accept either the kebab-case id or a case-insensitive name. Empty registry still emits the `failureCategory` no-progress hint added in 0.13.34, but on a real install the registry now has 64 entries from the curated library.

Smoke verification: `loadAllSkills()` returns 64 documents (`preserving-productive-tensions`, `aspnet-core`, `chatgpt-apps`, …).

275 vitest files / 3783 tests pass; tsc clean.

## [0.13.34] — 2026-05-06

The 0.13.33 hostile-mode test reproduced the loop-guard miss the maintainer warned about: the model sidestepped the same-signature check by rotating through six different `Skill` payloads (`{action:'list'}`, `{action:'run'}`, `{action:'info'}`, `{action:'run', name:'x'}`, `{action:'info', name:'x'}`, `{action:'list', name:'y'}`) and ran the cycle 6+ times before the user Ctrl+C'd. 0.13.33's window=10 + same-signature threshold=2 never fired because no individual signature appeared twice within the window, and `intentTarget` for non-fingerprinted tools is the full input JSON so every variant was a different "intent" too.

This release fixes three layers, in priority order from most precise to most catch-all:

### Loop guard — semantic failure-class (no-progress) tracking

`ToolAttemptSignature` gains a `failureCategory?: string` field. Tools opt in by returning `metadata.failureCategory` on **either** an error result or a deterministically empty success (e.g. `Skill list` returning a registry of zero items — the call "succeeded" but no follow-up `run/info` can possibly succeed). `recordToolAttempt` captures the category on every attempt regardless of `isError`.

`detectToolLoop` gains a new highest-priority branch: if the recent window contains ≥ 3 events for any single `(toolName, failureCategory)` pair, the next attempt is refused with `task stuck in tool loop: N same-class no-progress events for <name>:<class>`. Same-class events count regardless of input shape, so a model can no longer dodge the guard by varying its arguments.

`Skill` opts in: missing-name / unknown-action / not-found / **empty-registry** all classify as `skill:no_available_skill_or_invalid_call`. The empty-registry path also prepends an explicit "Do NOT call Skill with action=run/info/get/show — they will fail until a skill is registered" hint to the LLM-visible output, so even when the guard hasn't yet tripped the model is steered out of the dead end.

### Loop guard — multi-signature rotation fallback

For tools that don't yet emit `failureCategory`, two cheaper fallback signals fire after the semantic check:

- **Rotation**: window contains ≤ 4 unique signatures across ≥ 12 total attempts → trip.
- **Failure rate**: window contains ≥ 9 attempts with ≥ 2/3 of them errors → trip.

These are belt-and-suspenders for the common case where a tool hasn't been wired through the new `failureCategory` contract yet.

### Loop guard — window widened from 10 to 24

Catches longer cycles before the rotation/failure-rate fallback even matters.

### Tests

`tests/native/conversation-loop-guard.test.ts` adds three cases:

- 6-signature cycle with `failureCategory` set → guard trips within ~4 executions
- 6-signature cycle without `failureCategory` → falls back to rotation guard, still stops well before 30 attempts
- All-unique signatures with high error rate → fails-rate fallback fires

`tests/native/tools/skill.test.ts` confirms the empty-list / not-found / missing-name paths emit the expected `failureCategory` in `metadata`.

275 vitest files / 3783 tests pass; tsc clean.

## [0.13.33] — 2026-05-05

Two P0 fixes that came directly out of the in-REPL hostile-mode tool walkthrough on 0.13.32. Each was reproduced before patching.

### Security — fs-policy now expands `~` before path resolution

`src/native/tools/fs-policy.ts` previously fed input paths directly into `path.resolve()` without expanding the leading `~`. Concrete impact: `Write({path: '~/.ssh/test', content: 'x'})` was treated as a non-absolute path, joined onto `cwd`, and **wrote a literal `~/.ssh/test` directory tree into the project root** — bypassing the sensitive-path deny list (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`) entirely. Reproduced in the user-facing REPL with a workspace-local `~/.ssh/test` path. The fix:

- Added `expandTilde(p, home)` that handles `~` / `~/foo` / `~\\foo` and leaves `~user/...` untouched.
- Called from `evaluateFsPolicy` after trimming and before `resolve()` so every downstream tool (`write`, `edit`, `notebook-edit`) inherits the protection automatically.
- 9 tilde cases in `tests/native/fs-policy.test.ts` plus one end-to-end case in `tests/native/tools/write-edit-guard.test.ts` that calls `Write` directly with `~/.ssh/poc` and asserts (a) `isError: true` with `fsPolicyDenied: true`, and (b) **no `~` directory is created in cwd**. 275 vitest files / 3780 tests pass.

`read.ts` does not currently route through `evaluateFsPolicy` and was not modified in this patch — `/etc/shadow` and similar reads remain governed only by OS file permissions. That asymmetry is recorded in the file audit and will be addressed in a separate read-policy patch; this fix is scoped strictly to the write-side bypass that was actively exploitable.

### Cost protection — tool-loop guard is now ON by default

`detectToolLoop` (in `src/native/conversation.ts`) has a correct threshold of "≥ 2 same-signature failures abort the loop" — but it was guarded by `isAgenticStrict()`, which returns `false` unless `OWLCODA_AGENTIC_MODE=strict|converge` is set. Default behavior was therefore: **no loop detection**. The hostile-mode test reproduced the consequence — a model called `Skill({action:'run'})` (missing `name`) **100+ times in a row**, each call cost a token round-trip, and the user had to Ctrl+C to stop the burn.

- New helper `isToolLoopGuardEnabled()` (separate from `isAgenticStrict()` so existing nudge / stall semantics around lines 667 / 752 / 1201 / 1524 are unchanged). Default ON; opt-out via `OWLCODA_LOOP_GUARD=off|0|false|no|disabled`.
- Call site at `executeTools` switches from `isAgenticStrict()` to the new helper. When `detectToolLoop` returns non-null, the existing strong path activates: `callbacks.onError` fires (user-visible transcript), `executeTools` returns early, the outer loop calls `markTaskGuardBlocked`, sets `lastStopReason = 'tool_loop'`, and breaks the conversation. No more requests, no more dispatches.
- `tests/native/conversation-loop-guard.test.ts` (new, 22 cases): identical failing tool call repeated 8 times now stops at the 3rd attempt; opt-out via env var restores the old "let the model burn" behavior; pre-existing free-mode long-task suite explicitly opts out so its "does not hard-stop repeated failing bash" test still documents the escape hatch.

### Build / packaging

- 275 vitest files / 3780 tests pass.
- `npx tsc` clean.

## [0.13.32] — 2026-05-05

Five fixes from a hostile-mode in-REPL tool walkthrough that surfaced real defects under happy-path testing. Each was confirmed against source before patching.

### Tools — Skill `action: 'list'` no longer requires `name`

`src/native/tools/skill.ts` rejected `Skill({action: 'list'})` with `'skill name is required'` — a per-action-required-field bug that made the obvious "show me what skills exist" call impossible from an LLM. The schema now distinguishes actions that need a `name` (`run`, `info`, plus the legacy aliases `get → run` / `show → info`) from actions that don't (`list`). Tests in `tests/native/tools/skill.test.ts` pin the contract for both branches. 11/11 pass.

### Tools — RemoteTrigger list paginates + cleanup action

`RemoteTrigger({action: 'list'})` was returning every single trigger ever created — on the affected host that was 1920 records / ~67 KB of mostly-CI-leaked test data per call, blowing context budget on every call. Added `limit` (default 50), `prefix` filter, `since` / `before` ISO-date filters, and a new `action: 'cleanup'` that requires explicit `prefix` / `before` / `olderThanDays` and a `confirm: true` flag (otherwise dry-run preview). Truncated output now appends a sentinel: `... (N total, showing M; use limit: N to widen, or prefix/since to filter, or action: 'cleanup' to prune old triggers)`. Test path uses `OWLCODA_TRIGGERS_DIR` env override so unit tests never touch real user data.

### Tools — Glob signals truncation

`Glob` would silently cap at 10000 results (HARD_RESULT_CAP × 2) without any indication. Output now appends `... (truncated: <total> total, showing first <MAX>)` and the tool result `metadata` carries `truncated` / `totalCount` / `returnedCount` / `collectionCapped` so a programmatic caller can detect partial results. Truncation stays `isError: false` (it's partial success, not failure). 11/11 glob tests pass.

### Tools — Tungsten and Workflow removed

These two had been listed in the registered tool surface forever as upstream-cloud placeholders that returned `'not available in local-LLM mode'` on every call. They had no realistic local implementation path (Tungsten is internal Anthropic compute infra, Workflow is a cloud orchestration service). Their continued presence in the LLM's tool schema only wasted turns. Removed: `src/native/tools/tungsten.ts`, `src/native/tools/workflow.ts`, the corresponding test files, all imports, all dispatch wiring, and all NATIVE_TOOL_SCHEMAS / docs entries. Default registered tool count: **40 → 38**.

### Build / packaging

- Default tool count: 38 (was 40 in 0.13.31).
- Test count grew despite the removal because Skill / RemoteTrigger / Glob each gained behavioral tests. Final: 275 vitest files / 3767 tests pass.
- `npx tsc` clean.

## [0.13.31] — 2026-05-05

A multi-track release that closes most of the items the kimi-code REPL audit and the investor / financial DD reports surfaced. Six work streams were dispatched as parallel sub-agents and reviewed before merge; commits land in dependency-safe order.

### Tools — TaskCreate executes commands behind safety gates (`38bae70`)

The 0.13.30 honesty patch said TaskCreate doesn't execute. This release re-introduces an execution capability — but only for commands that pass through the same `bash-risk` classifier and `headless-approval` policy that `bash` uses. Three independent gates fail closed: headless approval, TUI permission render, and a final classifier check inside `task-create.ts execute()` that refuses to even create a task in the store unless the command classifies as `safe_readonly`. Pure-todo mode (no `command`) is unchanged.

### Tools — 8 stub tool descriptions rewritten to match runtime (`395f974`)

`task-list`, `task-get`, `task-update`, `task-stop`, `task-output`, `send-message`, `schedule-cron`, `mcp-auth`: each tool description now states what the tool actually does (in-memory Map mutation), what it does NOT do (no scheduler / no consumer / no validation / no auto-advance), and what to use instead when the previous wording's promise is what the caller needs. Zero behavior change. Three of these (`SendMessage`, `ScheduleCron`, `McpAuth`) are advisory candidates for de-registration in a follow-up patch; honest descriptions are the minimum-viable fix.

### CLI — daemon stale detect + sessions filter + doctor format + router relabel (`b608ab7`)

- **C2** `owlcoda status` warns when the running daemon's reported version differs from the current CLI version. Suggests `owlcoda stop && owlcoda` to align; never auto-restarts (in-flight sessions matter). Also runs at REPL launch in non-quiet mode.
- **E1** `owlcoda sessions` filters out `test-model` / `test_*` / `test-*` sessions by default; `--include-test` flag restores unfiltered view.
- **E2** `owlcoda doctor` no longer leaks an orphan `node v20.20.1` line under tsx — the prior code assigned the entire two-line `npx tsx --version` output as detail. Now extracts the leading `tsx vX.Y.Z` line only.
- **E3** Doctor's `deprecated_router_models` warning was misleading — the probe enum value referred to the legacy upstream visibility surface, NOT to `/v1/models` (which is the standard OpenAI-compatible discovery path). Display label and detail strings now read `openai_compatible_models` / `openai-compatible /v1/models (legacy visibility surface)`. Probe enum and downstream gating preserved for backwards compat. Display change only.

### Dependencies — 41 dead runtime deps removed, axios high CVE eliminated (`2592c9f`)

Five sequential clean-install + tsc + vitest batches removed 41 runtime dependencies that had zero project-source imports. `node_modules` shrinks 156 MB → 71 MB (-55%). `npm audit --omit=dev` reports 0 vulnerabilities (was 3, including one high in `axios`). One package the initial audit flagged as dead (`stack-utils`) was kept after re-verification — `src/ink/components/ErrorOverview.tsx` imports it. Each batch's per-package decision is logged in `docs/deps-removal-log.md`.

### Documentation — INK convergence roadmap (`<this commit>`)

`docs/INK-CONVERGENCE.md` plus `docs/INK-CONVERGENCE-raw-diff.txt`: a three-phase roadmap for shrinking the vendored Ink fork in `src/ink/` (currently 21 008 LOC, 23.9% of the codebase). The fork has zero byte-identical files vs upstream `ink@6.8.0`, 32 same-name DIFFER files, and 74 fork-only files. The roadmap classifies each file's upstream-PR feasibility and identifies an immediate small win: three hooks (`use-app.ts`, `use-stdin.ts`, `measure-element.ts`) that have only formatting drift from upstream and can be re-synced as the first concrete step.

### Build / packaging

- All 277 vitest files / 3747 tests pass on the final state.
- `npx tsc` clean.
- `node dist/cli.js --version`, `--help`, `config`, `doctor`, `sessions` all behave as documented.

### Follow-up stabilization (`<this commit>`)

- `docs/TOOLS.md` now reflects the 0.13.31 TaskCreate reality: TaskCreate / TaskOutput / TaskStop are beta task tools with a safe_readonly command path, not pure stubs. Remaining true stubs are SendMessage, ScheduleCron, and McpAuth.
- TaskGet / TaskStop / TaskUpdate descriptions now match the command-backed task runtime, including TaskStop's SIGTERM behavior for TaskCreate-owned children.
- Admin dev toolchain upgraded (Vite / Vitest / happy-dom / React plugin) so both root runtime audit and admin audit report 0 vulnerabilities.
- Integration tests that spawn real CLI subprocesses now use the same 15s budget, and the ripgrep detector test no longer assumes the host PATH result is byte-identical before and after cache reset under parallel load.

### Not in this release (deferred or out-of-scope)

- **B2 Zod validation density** — would conflict with this release's tool-schema changes; deferred to next batch.
- **Public-repo publish flow** — owned by a separate execution body, not the private repo's main code. Removed from the dispatch plan when scoped.
- **Other 8 stub tools de-registration** — needs separate review on whether SendMessage / ScheduleCron / McpAuth should be unregistered or built out.

## [0.13.30] — 2026-05-05

### Task contract banner — silence in workspace mode

- `describeTaskExecutionState` (`src/native/task-state.ts`) returns `null` when `scopeMode === 'workspace'` (the default mode that does not actually restrict writes inside cwd). The banner string was empty security theater — every conversation turn re-emitted "Task contract: no explicit write scope inferred yet…" into the transcript, where it accumulated as N copies after N turns, polluting scrollback and consuming context budget on every subsequent inference call.
- `runConversationTurn` (`src/native/conversation.ts`) skips the `onNotice` callback when the description returns `null`.
- `routeConversationNotice` (`src/native/loop-noise.ts`) gains a defensive `Task contract:` branch — even in `'explicit_paths'` mode the banner now goes to the dim footer slot only, never to a transcript entry. This means the user still sees the active scope at a glance but the transcript stays clean.
- Net effect: a 30-turn conversation in default workspace mode has 0 Task contract entries in the transcript (was ~30). `'explicit_paths'` mode keeps a single refreshing footer hint.

### TaskCreate — honest description (no behavior change)

- `TaskCreate`'s tool description was misleading models into treating it as a parallel job scheduler (real-world usage caught LLMs calling `TaskCreate` in batches and then polling `TaskOutput` indefinitely waiting for completion). The runtime has always been an in-memory TODO tracker with no execution path. Updated the description to say so explicitly and to point at `Agent` for actual concurrent execution.
- No code path changed; this is a schema-string fix to remove tool-shaped misdirection. A separate gated patch can later add real execution capability — but only after wiring it through the same `bash-risk` classifier and `headless-approval` pipeline that `bash` uses, so `TaskCreate.command` cannot become a back door around the existing safety surface.

### Documentation — first contributor-grade writeup

- `docs/ARCHITECTURE.md` (416 lines, 7 sections): every load-bearing rendering / conversation / tools-dispatch invariant the codebase relies on, each citing the file:line, test, and CHANGELOG entry that locks it. Audience is external or future contributors preparing a PR — designed so a stranger to the codebase can know in 30 minutes "what contracts can I not silently flip."
- `docs/TOOLS.md`: behavioral audit of all 42 registered native tools (production / beta / stub / experimental), based on what the code actually does, not what the comments claim. Identifies 9 stub tools (TaskCreate, TaskList, TaskGet, TaskUpdate, TaskStop, TaskOutput, SendMessage, ScheduleCron, McpAuth) where the tool surface oversells the runtime. Updated to reflect 0.13.30's TaskCreate honesty patch.
- `docs/deps-audit.md`: supply-chain audit covering all 64 runtime dependencies. Identifies 42 candidates for removal (zero project-source imports), 1 high CVE in a dead dep (`axios`), 0 license risks. Read-only — no removals applied; future cleanup will batch and verify each removal individually because static `grep` cannot see dynamic imports.

### Build / packaging

- All 277 vitest files / 3738 tests pass.

## [0.13.29] — 2026-05-05

### Single-Ink architecture — kill dual-track maintenance risk

- Removed `"ink": "^6.8.0"` and `"ink-text-input": "^6.0.0"` from `package.json` dependencies. The vendored Ink fork at `src/ink/` is now the **only** Ink in the project. Previously: `src/native/ink-fullscreen-layout.tsx` and `src/native/ink-multiline-input.tsx` imported `Box`/`Text`/`useInput`/`useStdout` from upstream npm `ink` while `ink-repl.tsx` and others used the vendored fork — two React reconcilers loaded into the same process, two `Box` classes that looked identical but ran different code, and behavioral drift between UI subtrees (fork-only `selection-first` / `safeFullRepaint` invariants didn't apply to the npm-side components).
- Migrated `ink-fullscreen-layout.tsx` and `ink-multiline-input.tsx` to import from `'../ink.js'` (the project wrapper around the vendored fork).
- Added `src/ink/hooks/use-stdout.ts` to the vendored fork — mirrors upstream's `useStdout` API shape (`{stdout: {columns, rows, write}, write}`) by reading resize-reactive size from `TerminalSizeContext`. Re-exported from `src/ink.ts`.
- Promoted previously transitive deps to direct dependencies (they were pulled by upstream `ink` and would have been pruned otherwise): `@alcalzone/ansi-tokenize` `^0.2.4`, `type-fest`, `slice-ansi`, `emoji-regex`, plus `@types/lodash-es` (devDependencies). The vendored fork has been using these all along — they're now declared honestly.
- `ink-text-input` was already dead in `src/` (zero imports); removed.
- Net effect: ~2 MB removed from `node_modules`, single React reconciler in the bundle, one canonical place to edit Ink behavior, and no more "this UI subtree behaves differently from that one" surprises.

### Build / packaging

- All 277 vitest files / 3728 tests pass.
- Headless mode (`owlcoda -p "…"`) and CLI (`--version`, `config`, `doctor`) verified post-migration.

## [0.13.28] — 2026-05-05

### Admin default model — honor explicit user choice

- `getDefaultConfiguredModel()` and `getPreferredInteractiveConfiguredModel()` now honor an explicit configured `default: true` model before applying automatic sustained-work recommendation gates. This fixes the Admin UI case where selecting `kimi-code` as the default still launched OwlCoda with `minimax-m27`.
- The conservative sustained-work labels remain visible and automatic recommendation/fallback behavior stays gated; the change only separates **user-selected default** from **system auto-selected default**.
- `owlcoda config` now shows the explicit default directly instead of reporting it as `configured default ignored`.

### Build / packaging

- Focused default-model/admin regression suite passes: 5 files / 173 tests.
- Full test suite passes: 277 vitest files / 3728 tests.

## [0.13.27] — 2026-05-05

### Streaming progressive commit — kill 10-line live-preview clip

- `onText()` in `src/native/ink-repl.tsx` now commits each rendered chunk **directly** to the scrollable transcript via `appendTranscript`, instead of accumulating into `liveResponseRef.current` and showing only the last 10 lines (`LIVE_RESPONSE_MAX_LINES`) as a fixed-window live preview. Previously: long answers were invisible mid-stream and dumped all at once at completion. Now: the answer **grows line-by-line in the scrollable transcript** as it streams, user can scroll back to read earlier parts while later ones are still arriving.
- `mdRenderer.push()` always returns `\n`-terminated complete logical lines (the in-progress partial line stays buffered inside the renderer), so progressive commit doesn't break logical-line boundaries. `splitTranscriptAppendText` runs per chunk now, eliminating any class of "accumulation merges adjacent blocks into one logical line" issue.
- `flushLiveResponse` is preserved for the end-of-response composer-leftover path — the trailing partial sentence (no `\n`) gets drained via `drainComposerLeftoverToLiveResponse → appendRenderedLiveResponse → flushLiveResponse → appendTranscript` as before.

### Build / packaging

- All 277 vitest files / 3728 tests pass.

## [0.13.26] — 2026-05-05

### Ink renderer — paste-friendly inter-cell advance

- `inplaceFullRepaintSequence` (the body of `safeFullRepaint`) no longer emits `\x1b[NC` cursor-forward escapes when advancing the cursor over empty cells within a row. Spaces are written as literal ASCII `0x20` characters instead. Visually identical to before (cells were already pre-EL'd to blank), but **copy-paste, screenshot OCR, "share this terminal output" flows now preserve readable text**. Without this fix, ANSI control sequences are stripped during copy/extraction, and adjacent tokens collapsed into garbage like `5.显著亮点5.1协议翻译层设计前端对外暴露AnthropicMessages...`. Row boundaries already used `CR+LF` (preserved by paste), so the loss was strictly intra-row.
- Same-row only — row advances still go through the existing CR+LF path. Cell-diff (non-safe) repaint path unchanged: skip-unchanged-cell semantics there require cursor-forward.
- New `tests/ink/paste-friendly-spaces.test.ts` locks the invariant: zero `\x1b[NC` emitted in safeFullRepaint byte streams, ANSI-stripped row text reads with all spaces and indents preserved.

### Build / packaging

- All 277 vitest files / 3728 tests pass.

## [0.13.25] — 2026-05-05

### Markdown streaming renderer — header / table boundary protection

- `StreamingMarkdownRenderer.tryStructuralBreak` (Phase 2 partial-buffer break detection) now skips break attempts when the partial buffer starts with `|` (in-progress table line) or `#{1,6}` followed by space / digit / CJK (in-progress header). Previously the hrule pattern `(.{3,}?)(---+|___+|\*\*\*+)` greedily matched the dashes inside a Markdown table separator like `|------|------|`, emitted `|--` as a partial line, and the rendered table was preceded by a stray `|--` artifact every time a separator landed in its own SSE chunk.
- `processLine` no longer puts header lines into `tableBuffer` even if they contain `|`. A header arriving mid-table flushes the prior table cleanly. A header line such as `## A | B Comparison` renders as a header instead of swallowing the next pipe-row as a table data row.
- New regression tests in `tests/native/markdown.test.ts > header / table boundary protection`.

### Build / packaging

- All 276 vitest files / 3726 tests pass.

## [0.13.24] — 2026-05-05

### TUI render P0 收口 — single-owner safeFullRepaint + transcript ownership

- Unified main-screen full-repaint entry: `safeFullRepaint(frame, stylePool, reason, debug?)` in `src/ink/log-update.ts` is now the only path that produces a full repaint. Replaces the legacy `fullResetSequence_CAUSES_FLICKER` at all 7 callsites (staticCommit reset, periodic_scrub, resize, three offscreen drift cases, force-repaint).
- Patch type `clearTerminal` removed in favor of `flickerMarker` — telemetry-only, zero-byte marker. `writeDiffToTerminal` no longer emits `\x1b[2J` through the patch system. Main-screen invariant: **no path emits ED 2 except `/clear` and alt-screen lifecycle**.
- `inplaceFullRepaintSequence` clamps to `min(screen.height, viewport.height)` rows starting from the bottom slice. Repaint no longer relies on natural LF overflow to push hidden rows into scrollback — Static commits are the sole scrollback owner.
- `staticCommit` bulk LF burst clamped to `min(rowCount, viewport.height - 1)`. paddedCommit's embedded LFs already produce the natural scrolls for the deeper rows; emitting the full `rowCount` after CUP-to-bottom over-scrolled blank rows into scrollback (visible to users as long whitespace strips after every long Static commit).
- `forceRedraw()` no longer writes `ERASE_SCREEN + CURSOR_HOME` directly to stdout. Replaced with a one-shot `pendingForceFullRepaint` flag consumed by the next render through `safeFullRepaint`.
- New regression tests:
  - `tests/ink/no-ed2-on-main-screen.test.ts` — main-screen `\x1b[2J` invariant across staticCommit / forceFullRepaint / resize / offscreen / periodic_scrub.
  - `tests/ink/cjk-residual.test.ts` — CUP+EL bracketing per row, leading-empty-cell smear protection, short-over-long tail residue protection.
  - `tests/ink/static-ownership.test.ts` — commit text appears exactly once in the byte stream.

### REPL transcript ownership

- Long tail-anchored assistant answers are split per-line into transcript items via `splitTranscriptAppendText` before entering React state. Without the split, a single large transcript item with `overflowAnchor: 'tail'` would hide its head behind the live-window slice and never reach Static-owned scrollback (visible to users as orphan tail fragments mid-response).
- `runConversationTurn` FINALLY block now drains composer `leftover` BEFORE the iterations / usage footer items are appended, so a final partial sentence ending without `\n` lands in transcript order rather than after the footer.

### Dev preview status unchanged

- Source-install only; v1.0 distribution work tracked separately.

## [0.13.23] — 2026-04-30

### Xiaomi MiMo token-plan onboarding

- Add Xiaomi MiMo token-plan provider presets for the Anthropic-compatible
  `https://token-plan-sgp.xiaomimimo.com/anthropic` route and the
  OpenAI-compatible `https://token-plan-sgp.xiaomimimo.com/v1` route.
- Default the MiMo preset to the provider-supported lowercase backend model id
  `mimo-v2.5-pro`, and normalize old display-style `MiMo-V2.5-Pro` entries at
  runtime so fresh Ubuntu setup does not fail with upstream 400.

## [0.13.22] — 2026-04-30

### Ubuntu first-run Node guard

- Raise the supported runtime floor to Node.js 20.19+ to match the current Ink,
  Vite, `string-width`, `undici`, and related dependency graph.
- Add an early CLI preflight so Node 18 exits with a clear upgrade message
  instead of falling through to `Invalid regular expression flags` during REPL
  dependency import.

## [0.13.21] — 2026-04-26

### cmux stress-path hardening

- Suppress blind runtime auto-retry for provider responses that return HTTP 200
  with no assistant content, so Kimi empty `end_turn` stalls no longer burn
  eight retry cycles or spam the transcript.
- Make sub-agent `max_iterations` behavior explicit and regression-tested:
  Explore defaults to 80 iterations, general-purpose agents default to 200, and
  terminal agent failures stop the parent loop instead of inviting the model to
  improvise over incomplete work.
- Expand mediated-terminal low-churn detection and document
  `OWLCODA_LOW_CHURN_TERMINAL=1` as the cmux escape hatch while the exact ANSI
  drift still needs a captured dump to prove.

## [0.13.19] — 2026-04-25

### Composer input hot-path fix

- Stop re-running config loading and git status discovery on every
  `NativeReplApp` render. These values now use true lazy refs, so ordinary
  typing no longer pays synchronous filesystem/git work per keystroke.
- Extend `OWLCODA_TRACE_INPUT_LATENCY` with env-gated render/commit/listener
  checkpoints used to prove the bottleneck moved from ~40-50ms render-start
  delay to single-digit post-warm keystroke writes in tmux.

## [0.13.18] — 2026-04-25

### Input latency diagnostics

- Add `OWLCODA_TRACE_INPUT_LATENCY` JSONL tracing for stdin chunk,
  parsed-key/input-event, and stdout write-callback timing so real typing
  latency can be measured end-to-end instead of inferred from render frames.
- Keep the tracing path env-gated; normal typing still uses the existing
  stdout write path with no write callback.

## [0.13.17] — 2026-04-25

### Draft rail removal

- Remove the draft indicator from the bottom rail entirely. The typed input is
  already visible, and rail updates during typing were still adding avoidable
  render churn in Ghostty/cmux.

## [0.13.16] — 2026-04-25

### Composer hot-path smoothing

- Keep the bottom rail's draft indicator in presence mode (`DRAFT active`)
  instead of repainting an exact character count on every keystroke.
- Add a safe ASCII fast path for composer wrapped-line counting so ordinary
  typing avoids the full wrapping pipeline.
- Let the composer body use the actual draft height instead of forcing a
  three-row input band for single-line typing.

## [0.13.15] — 2026-04-25

### Input smoothness rollback

- Roll back the experimental prompt/input cursor fast paths and low-churn
  cursor-position rewrites from 0.13.9-0.13.14. They improved synthetic traces
  but regressed real Ghostty/cmux typing feel.

## [0.13.14] — 2026-04-25

### Low-churn cursor de-duplication

- Skip redundant low-churn preamble/restore cursor hops now that diff
  navigation and declared-cursor parking use absolute positioning.

## [0.13.13] — 2026-04-25

### Low-churn cursor parking

- Use absolute CUP for low-churn declared-cursor preamble/parking too, removing
  the remaining per-keystroke relative cursor jump around prompt input.

## [0.13.12] — 2026-04-25

### Low-churn absolute cursor moves

- Use absolute CUP cursor positioning for low-churn diff navigation as well as
  prompt restore, avoiding per-keystroke relative up/down cursor churn in cmux.

## [0.13.11] — 2026-04-25

### cmux cursor restore

- In low-churn terminal mode, restore the prompt cursor with one absolute CUP
  instead of a `CR + LF x N + cursor-up` sequence, reducing per-keystroke
  cursor churn for cmux.

## [0.13.10] — 2026-04-25

### Cursor render fast path

- Fast-path common end-of-line composer rendering so typing at the prompt no
  longer walks the grapheme split/render path just to draw the cursor.

## [0.13.9] — 2026-04-25

### Input hot-path smoothing

- Added a single-line cursor measurement fast path and reduced cmux low-churn
  per-keystroke composer calculations so typing does not repeatedly enter the
  heavier wrap/attachment-preview path.

## [0.13.8] — 2026-04-25

### Context rail render cost

- Keep the active `CTX` estimate off high-frequency `uiVersion`/spinner churn so
  cmux typing does not pay a full conversation scan on every render.

## [0.13.7] — 2026-04-25

### Context rail truth

- Composer rail `CTX` now displays active conversation context pressure instead
  of cumulative session token spend, so long tasks no longer show impossible
  `1M/256k` context solely because many requests have run.

## [0.13.5] — 2026-04-25

### Free-mode long-task continuation

- Default free-mode long tasks no longer hard-stop after long successful
  no-output verification chains. The `Model stalled: ... consecutive tool
  calls with no text output` guard now only applies in strict agentic mode.

## [0.13.4] — 2026-04-25

### Task-contract approval path

- Task-contract write-scope blocks now route through a structured runtime
  approval prompt; approving adds the requested path to the current contract
  and retries the blocked write instead of forcing a natural-language loop.

## [0.13.3] — 2026-04-24

### Low-churn terminal mode

- Add OwlCoda-owned low-churn terminal mode, auto-enabled on cmux and manually
  available via `OWLCODA_LOW_CHURN_TERMINAL=1`.
- In low-churn mode, skip optional focus-reporting, terminal-identity probes,
  synchronized-output wrappers, and extended key reporting to keep plain typing
  on the shortest possible input/render path.
- Keep text-width-only changes on the local damage path instead of forcing
  full-screen damage for every typed character.
- Collapse repeated composer-rail/input metrics per render: input wrap counts,
  attachment parsing, usage/context snapshots, and MCP summary are now computed
  once per frame and reused.
- In low-churn terminal mode, the rail renders a stable draft-presence cell
  instead of changing `DRAFT n` on every typed character.

## [0.13.2] — 2026-04-24

### Kimi Code stream compatibility

- Fixed native SSE parsing for relay/provider streams that emit valid
  `event:` / `data:` fields without a space after the colon.
- Added regression coverage for no-space SSE frames and data-only Anthropic
  event fallback.
- Confirmed `kimi-code` works through the native conversation loop after the
  parser fix, including short streaming replies and a minimal bash-tool turn.

## [0.13.1] — 2026-04-24

### Long-task runtime capability truth

- Added a central model-capability resolver for context windows instead of
  relying on UI/model-id string fallbacks.
- `kimi-code` / `kimi-for-coding` / Kimi K2 aliases now resolve to a 256k
  context window; `minimax-m27` / MiniMax M2.7 aliases resolve to 204.8k.
- Stale explicit `32768` context values on those known long-context aliases are
  upgraded, while non-default manual overrides remain preserved.
- Native REPL auto-compact, composer rail, readline `/budget`, and frontend
  `/budget` now share resolved model capability truth.
- Added `docs/design/LONG_TASK_RUNTIME_LAYER_20260424.md` to capture the
  broader long-task runtime design across agent CLIs.

## [0.13.0] — 2026-04-24

### TUI design system overhaul (full canvas — 16/16 scenes)

This release closes the entire OwlCoda design canvas, including the gaps
that the first pass left on the floor:

- **Diff hunk header band** (`diff.ts › renderHunkHeader`): every change
  block now opens with `@@ -oldStart,oldLen +newStart,newLen @@` painted
  in `--info` on `--bg-card`, matching the design's `oc-diff-hunk-head`.
  File-create blocks emit `@@ -0,0 +1,N @@ new file` so they read as a
  proper Git-style hunk anchor.
- **Todo block** (`message.ts › formatTodoWriteResult`): TodoWrite
  results now render as the design's bordered `oc-todo` card — small-caps
  TODO header with `done/total` count and active marker, four explicit
  states (✓ done in success+strike, ▸ current in accent+inkHi, ⊘
  blocked in warn, □ pending in dim).
- **Per-type pickers** (`ink-picker.tsx` + `picker.ts`): InkPicker now
  takes a `variant` prop and renders three distinct grids — slash
  (`cmd / desc / shortcut`), at (`dir/-mute + base-ink / tag pill`),
  model (`label / meta / tag pill`). PickerItem extended with
  `tag/shortcut/meta` for per-variant cells; slash items now carry
  shortcut hints (?, ⌃L, ⌃,, ⌃D), file picker tags directories vs.
  files, model picker shows availability and "current" pill.
- **Composer attachment chips** (`composer.tsx › ComposerInputChrome`):
  attachments rendered above the prompt as `oc-attach` chips — bordered
  pill with thumb glyph (`🖼`/`◰`/`▸`), name, optional size, dismiss.
  Wired via `parseInputAttachments(inputValue)` so any `@path`
  references the user is composing show up as live previews; image
  extensions get the accent thumb, others the muted file/dir glyph.
- **First-run onboarding hero** (`welcome.ts › renderOnboardingHero`):
  on first run with sufficient terminal width the welcome routes to the
  full design `oc-onboarding` block — Braille owl on the left
  (characters preserved verbatim), brand wordmark + version + lede on
  the right, hair-faint divider, counter-stepped onboarding list with
  decimal-leading-zero accent counters, second hair-faint divider, and
  a 2-column hotkeys grid. Returning sessions still see the compact
  welcome.

### TUI design system overhaul (initial pass)
- Adopted the OwlCoda design canvas as the canonical visual specification for
  the native REPL. Every transcript primitive — turns, thinking blocks, tool
  rows, diff hunks, banners, permission prompts, pickers, panels, and the
  status rail — now derives chrome and color from a unified token system that
  matches `OwlTheme.swift` (slate + cyan dark / off-white + cyan light).
- Token base (`src/native/tui/colors.ts`):
  - Added the design's full surface ramp (`bgApp/bgCard/bgCard2/bgRaise/bgInput/bgSidebar/bgChrome/bgInspector`).
  - Added hairline tokens (`hair/hairStrong/hairFaint/hairAccent`) for inline
    rules that previously borrowed `border*`.
  - Added pre-blended soft tints (`accentSoft/successSoft/warnSoft/errSoft/
    infoSoft/purpleSoft`) so add/remove diff bands, banner backgrounds, perm
    danger gradients, and picker selection bands all share a precise palette
    instead of approximating.
  - Added explicit `shimmer/purple/pink/textHi/textMute/textSubtle` tokens.
  - Fixed `success` from `rgb(92,184,196)` (a duplicate of accent) to
    `rgb(88,198,162)` so success and accent are visually distinct.
  - Inline-code highlight now uses `shimmer` on `bgInput` per design.
- Tool rows (`src/native/tui/tool-row.ts`): chevron + verb-in-accent +
  arg-in-dim + duration + status glyph (●/✓/✗); body indented under a
  hair-faint vertical rule with `… +N more` truncation in mute ink.
- Diff (`src/native/tui/diff.ts`): inline change-block bands switched to
  `successSoft`/`errSoft` over `success`/`error` foreground; bordered diff
  card now carries `+N -M` stats inline with the action label.
- Status rail (`src/native/tui/message.ts › renderComposerRail`): rebuilt as a
  cell-divided footer matching the design's `oc-rail` — colored state pulse +
  state label, then `MODEL` / `MODE` / `CTX` / `QUEUED` / `DRAFT` cells with
  hair-faint `│` dividers and a right-aligned hint.
- Composer (`src/native/tui/composer.tsx`): added a hair-faint top divider
  separating composer from transcript; mode label now visible next to the `›`
  prompt; queued chip restyled to design's `oc-queued-chip` (warn caps tag +
  dim body + esc-dismiss). Brand-specific left ▎ accent bar preserved.
- Permission card (`src/native/tui/permission-card.tsx`): rebuilt to design's
  `oc-perm` block — small-caps tier tag in tier color + "requires approval"
  scope hint; action in ink-hi; target in a sunken `bgInput` band; horizontal
  choice chips with `[Y]` keys, primary highlighted via `accentSoft`, danger
  via `errSoft`. Card surface tints `errSoft` on the danger tier.
- Picker (`src/native/ink-picker.tsx`): selected row now uses the design's
  `accentSoft` background + `▎` accent left border; cmd swaps from accent to
  shimmer on hover/select; head shows uppercase mute title + match summary
  with arrow-key hint right-aligned.
- Banner (`src/native/tui/banner.ts`): tier glyph in tier color, bold title
  in `textHi`, em-dash separator before dim body, action chips with `[k]`
  key in tier color and primary highlighted bold.
- Sessions / Settings / MCP panels (`src/native/tui/panel.ts`): adopted the
  design's `oc-panel-head` chrome (accent OC monogram + ink-hi title +
  ink-dim subtitle + hair-faint rule); settings rows split into `Runtime`
  and `UI` groups with dashed group-title separators.
- Welcome (`src/native/tui/welcome.ts`): brand wordmark uses `textHi` for
  hero contrast per design's onboarding `h1`. Braille owl glyph data
  preserved exactly — only the surrounding type tokens were tightened.

## [0.12.34] — 2026-04-24

### Long-task runtime
- Locked the runtime policy that provider/request timeouts are recovery events,
  not terminal user-cancel events. Streaming timeout diagnostics emitted through
  SSE `event: error` now have regression coverage proving the native
  conversation loop retries and continues the task instead of stopping.

## [0.12.33] — 2026-04-24

### Long-task runtime
- Server `/v1/messages` now preserves abort origin on endpoint-owned failures.
  Streaming body aborts caused by the proxy/request timeout are classified as
  `timeout` diagnostics instead of `Request cancelled by user`.
- Added integration coverage for the cmux-shaped failure mode where an upstream
  streaming response opens and then hangs until the proxy timeout aborts the
  body reader.

## [0.12.32] — 2026-04-24

### Long-task runtime
- Global provider-error classification now treats generic `AbortError` /
  "This operation was aborted" as a retryable provider abort instead of
  `Request cancelled by user`.
- Slash/error-surface parsing no longer rewrites abort-like provider text into
  a user-cancel message. Only an explicit REPL interrupt path should carry
  user-cancel semantics.

## [0.12.31] — 2026-04-24

### Task contract runtime
- Task-contract write blocks now persist a pending write-scope approval target
  in runtime state instead of leaving the blocked path only in transient tool
  metadata.
- Follow-up user approvals such as "确认", "批准", "允许", or "继续写" can now
  approve the previously blocked path and reopen the task without forcing the
  user to restate the exact filesystem path.
- Added regression coverage for the real mionyee-style flow: first write is
  blocked by the narrowed contract, the user approves, and the same write path
  succeeds on retry.

## [0.12.30] — 2026-04-24

### Long-task runtime
- Generic provider/fetch `AbortError` failures are no longer labelled as
  `Request cancelled by user`. Only the REPL's explicit task abort signal keeps
  true Ctrl+C semantics.
- Abort-like transport interruptions are treated as retryable provider runtime
  failures, so the existing REPL/headless auto-continue path can resume the same
  conversation instead of silently stopping a long task.
- Added regression coverage proving a first-request `AbortError` retries and
  recovers without surfacing the false user-cancel message.

## [0.12.29] — 2026-04-24

### Long-task runtime
- Interactive Ink REPL now treats empty provider responses
  (`stop_reason: end_turn` with no text/tool content) as retryable runtime
  failures instead of stopping at a ready prompt with only "No response".
- The REPL automatically continues retryable runtime failures on the same
  conversation, preserving the transcript and surfacing a visible
  `Runtime auto-continue` line.
- Post-token stream interruptions remain non-auto-replayed to avoid
  duplicating partially emitted assistant output.
- Added targeted regression coverage for empty-response runtime failure
  classification and continuation retry eligibility.

## [0.12.28] — 2026-04-24

### Long-task runtime
- Native headless `owlcoda run` now automatically resumes the same
  conversation after retryable runtime failures returned by the conversation
  loop, including provider/server interruptions that happen after tool results
  have already been recorded.
- The preserved session is saved after each retryable runtime failure before
  the runner backs off and continues, so a process crash still leaves a
  resumable transcript instead of losing long-task progress.
- JSON output now includes `runtime_retries` on success and structured
  `runtime_failure` details when retryable recovery is exhausted.
- Recovery attempts are controlled by
  `OWLCODA_HEADLESS_RUNTIME_RESUME_RETRIES` and
  `OWLCODA_HEADLESS_RUNTIME_RESUME_RETRY_DELAY_MS`.

## [0.12.27] — 2026-04-24

### Long-task runtime
- Default/free conversation mode no longer applies the strict `tool_loop`
  hard-stop guard. Repeated diagnostic/exploratory tool use can continue until
  the user cancels or the model emits a normal final answer.
- `OWLCODA_AGENTIC_MODE=strict` keeps the existing loop-guard behavior for
  explicit convergence/safety runs.
- Ctrl+C queued-message recovery now drains after a short ready-frame boundary
  instead of spending most of the black-box recovery budget in an artificial
  delay.
- Long-task black-box QA now opts into `OWLCODA_AUTO_APPROVE=1` so the scenario
  measures long bash cancellation/recovery, not the first-run permission prompt.
- The same QA script now waits for the actual `Bash (sleep 60; echo done)` tool
  start before queue/cancel, and no longer treats the user's `QUEUE_OK` prompt
  text as a successful queued answer.
- Post-cancel black-box recovery now waits for an actual assistant `QUEUE_OK`
  answer instead of failing on historical cancelled Bash lines still visible in
  the pane.

## [0.12.26] — 2026-04-24

### Long-task runtime
- Ctrl+C cancellation with a queued message now exposes a stable recovery
  boundary before the queued turn starts. The REPL returns to an observable
  ready frame, then drains the queued input shortly after, so real terminal
  users and black-box PTY polling can distinguish "cancel completed" from
  "immediately busy again".
- `/clear` now keeps control-command semantics while a turn is busy: it
  cancels the active task, drops pending queued chat, clears the transcript,
  and prevents stale task-finalizer output from repainting after the clear.

## [0.12.25] — 2026-04-24

Pre-public-release UX & onboarding closeout.

### REPL UX
- Permission prompt for fallback tools is now vertical with three
  labelled choices (`[Y] Allow once` / `[N] Deny` / `[A] Always`)
  navigable via arrows / numbers / direct keys. The previous single-line
  `[Y/n/a]` prompt was easy to mis-read and didn't surface the
  "always allow" path. The composer now wraps long input cleanly in
  the same flow.
- `owlcoda -p "..."` is accepted as a short alias for `--prompt`,
  matching the README's headless example and the `--help` text. Prior
  to this release the README documented `-p` but only `--prompt` was
  parsed, so the first headless invocation from the README would fail.

### First-run polish (new-user audit)
- `owlcoda init` no longer auto-selects embedding / rerank models as
  the default. Chat-shaped models are preferred; an all-embedding router
  falls back to entry #0. Previously the first-prompt could fail
  mysteriously if the first-discovered model happened to be an
  embedding model.
- `owlcoda init --router <url>` auto-creates the parent directory
  (`~/.owlcoda/` or `$OWLCODA_HOME`) if missing, instead of crashing
  with `ENOENT` on the first write.
- `[config] Migration: vN: …` plumbing lines no longer print on every
  command. They're gated behind `OWLCODA_LOG_LEVEL=debug` now — the
  migrations still run, they just don't spam the console.
- Startup banner and `owlcoda config` list deduplicate the model
  display when `id === backendModel` (i.e., almost always for the
  no-alias case), so the banner reads `Default: foo` instead of
  `Default: foo → foo`.
- `--help` text no longer falsely claims `-p` is the short alias for
  `--port`. It's `-p, --prompt` now (matching the README headless
  example) and `--port` is long-only.
- README opens with an English Hero block before the Chinese hero,
  so a non-Chinese-speaking reader at least sees a one-paragraph
  positioning and quickstart.

### Internal
- Added `/internal/` to `.gitignore` so local-only plans, design specs,
  and manual trial reports never end up on the public mirror by
  accident.
- `CONTRIBUTING.md` now inlines a brief Code of Conduct paragraph
  instead of linking to a separate (previously missing) file.
- Test suite caught up with the recent 0.12.21 / 0.12.25 behavior
  changes (vertical permission prompt, X-Msh-Platform omission,
  brand-redacted model fixtures).

## [0.12.21] — 2026-04-23

Kimi compatibility fix.

- `kimi-code` built-in model no longer sends `X-Msh-Platform: kimi_cli`
  by default. That header opted into a legacy CLI-1.33.0 compatibility
  mode that sets `think_v2.keep_reasoning_content`, an option Kimi's
  server has since retired — the combination now responds with HTTP 400
  `unknown think_v2 option` and breaks every Kimi turn. The bare
  OpenAI-shaped request routes through Kimi's generic
  chat-completions path and works correctly. Users who genuinely need
  the legacy mode can set `OWLCODA_KIMI_PLATFORM=kimi_cli` to opt back
  in.

## [0.12.20] — 2026-04-23

Pre-public-release source-identifier and install audit closeout.

### Identifier cleanup
- `src/native/tools/rg-detect.ts` no longer probes any vendored ripgrep
  fallback path that referenced an unrelated upstream package. Replaced
  with a generic `OWLCODA_VENDORED_RG` env override that lets operators
  point at any locally vendored rg binary.
- Reworded inline identifiers in `src/types/ids.ts`, `src/ink/components/App.tsx`,
  `src/native/ink-fullscreen-layout.tsx`, and `src/hooks/useTextInput.ts`
  to use generic upstream-Ink / host-app terminology.
- Deleted an unused legacy compatibility alias from `src/daemon.ts` (zero
  internal callsites).
- Reworded a remaining legacy phrasing in this CHANGELOG's historical
  milestone 0.7.0 entry for clarity.

### Onboarding
- `owlcoda init` now sweeps Ollama (11434), LM Studio (1234), vLLM (8000),
  and the legacy custom router (8009) when no `--router` flag is passed.
  First responder wins; if none reachable, a placeholder config still gets
  written pointing at Ollama with a clear edit hint. Brand-new users with
  only Ollama running will now get auto-detected models on first `owlcoda init`.
- `owlcoda --help` now lists `skills info` and `skills list` explicitly so
  the help text and the README CLI table agree.

### Documentation
- README hero rewritten around the "personal workbench" framing from VISION.md;
  privacy default elevated to a top-level blockquote; new "Known Limitations
  (Developer Preview)" section discloses the terminal-native scrollback gap
  up front, plus three other partial capabilities.
- `docs/RELEASE-CHECKLIST.md` rewritten for the live `v0.12.x` thread,
  added brand-integrity and privacy-gate checklist sections, dropped the
  reference to the non-existent `OWLCODA.md` artifact.

### Stats
- 47 native tool source files, 77 slash command branches in
  `src/native/slash-commands.ts`.
- npm registry name `owlcoda` confirmed available (package still
  `"private": true` until first public tag).

## [milestone 0.9.0] — 2026-04-12

### Added
- **Persistent tool permissions** — "always approve" persists to `~/.owlcoda/permissions.json` across sessions (R108)
- **Persistent input history** — readline history saved/loaded from `~/.owlcoda/input-history` (R107)
- **LLM-based /compact** — compacts conversation by sending dropped turns to model for summarization (R106)
- **/retry command** — undo + resend last message in one keystroke (R106)
- **/undo command** — remove last assistant exchange (R102)
- **Batch tool approval ("A" key)** — approve all remaining tools in current response (R103)
- **Prompt caching** — structured system blocks with `cache_control: ephemeral` (R104)
- **Model picker with availability tags** — interactive picker shows ✓/✗/? per model (R105)
- **/login command** — manage cloud API keys directly from REPL (R109)
- **Auto-compact notification** — warns when context window triggers automatic trim (R107)
- **/resume prefix matching** — `/resume abc` finds sessions starting with "abc" (R107)
- **Enhanced system prompt** — git branch detection, shell info, formatting instructions (R102)
- **Empty response diagnostics** — shows stop_reason and token counts when model returns nothing (R108)

### Fixed
- **Config truth drift** — unified config root: `$OWLCODA_HOME` > `~/.owlcoda` (R104)
- **Admin reload** — `/admin/reload-config` now reloads models + responseModelStyle (R104)
- **Cloud model health** — endpoint models marked `unknown` (passive), not probed via `/v1/models` (R104)
- **Preflight cloud-awareness** — skip router probing for cloud endpoint models (R105)
- **Cloud model streaming** — full body passthrough + raw SSE pipe for direct endpoints (R101)

### Stats
- 222 test files, 2738 tests, all green
- 72 slash commands
- 40+ native tools registered
- 44 tool implementation files

## [milestone 1.2.11] — 2025-07-19

### Fixed
- **Request timeout signal bug** — The outer request timeout AbortSignal (120s default) was created but never passed to fetch() calls; both non-streaming and streaming paths now wire it through `AbortSignal.any()` (R184)
- **Remaining silent error swallowing** — 7 more `.catch(() => {})` in repl.ts (5) and health-monitor.ts (2) replaced with `logWarn` (R181)

### Added
- **Cache byte-size limit** — Response cache now has a `maxBytes` (default 10MB) to prevent unbounded memory growth (R181)
- **CLI-core unit tests** — 53 tests: parseArgs (37 tests), printHelp, VERSION, hasUpstreamSource, loadEffectiveConfig (R182)
- **Cache byte-limit tests** — 2 tests for eviction + stats (R182)

### Updated
- **EVOLUTION.md** — refreshed from milestone 1.0.8 / R102 to milestone 1.2.10 / R182 with current L2/L3 status (R183)
- **README** — updated test count to 154 files / 1879 tests (R183)

### Stats
- 154 test files, 1879 tests, all green
- 0 npm audit vulnerabilities
- 0 silent error swallowing in server-side code

## [milestone 1.2.10] — 2025-07-19

### Added — Server Endpoint Tests (Round 176)
- **40 new server-deep tests** — cache GET/DELETE, latency, audit filtering, SSE metrics stream, CC stub endpoints (bootstrap/profile/settings/grove/organizations), admin auth enforcement, oauth/files/session_ingress stubs, skill-stats, insights

### Fixed
- **CORS consistency** — 5 endpoints bypassed `sendJson` and returned responses without CORS headers; now all use consistent `sendJson`/`sendError` (R177)
- **Silent error swallowing** — 7 `.catch(() => {})` blocks in messages, skills, auto-synth, and search now log warnings instead of silently discarding errors (R178)
- **npm audit vulnerabilities** — upgraded hono and @hono/node-server to fix 2 moderate CVEs (cookie validation, serveStatic bypass) (R179)

### Stats
- 153 test files, 1824 tests, all green
- 0 npm audit vulnerabilities

## [milestone 1.2.9] — 2025-07-19

### Added — Deep Tests + Integration (Rounds 171-173)
- **translate-response deep tests** — 15 tests: unique IDs, multi-tool, mixed content, null finish_reason, responseModelStyle variants (R171)
- **rate-limit deep tests** — 15 tests: token bucket mechanics, model isolation, refill, stats, prune, reset (R171)
- **health-monitor deep tests** — 12 tests with mock HTTP server: healthy/unhealthy/error probing, multi-model, lifecycle (R172)
- **proxy tool-call integration tests** — 9 end-to-end tests with real OwlCoda server + mock router: tool defs, tool_use response, tool_result forwarding, alias resolution, usage preservation (R173)

### Fixed
- **Malformed tool arguments crash** — `JSON.parse` in `translateResponse` now catches malformed tool argument JSON from local LLMs, falls back to `{ _raw: rawString }` instead of crashing the request (R174)
- **Duplicate test cleanup** — removed 3 duplicate test files that inflated test count (pre-R171)

### Stats
- 152 test files, 1784 tests, all green
- tsc clean, zero errors

## [milestone 1.2.8] — 2025-07-18

### Added — Tests + Code Quality (Rounds 167-170)
- **Backend discovery tests** — 27 tests covering Ollama/LMStudio/vLLM adapters with mock HTTP server (R167)
- **Training pipeline tests** — 48 tests: PII sanitizer (25), quality scorer (14), training export JSONL/ShareGPT/insights (9) (R169)

### Fixed
- **Flaky circuit-breaker test** — increased timing margins for reliable CI (R166)
- **Code quality sweep** — removed 33 unused imports/variables across 22 source files (R168)

### Updated
- **README** — synced to milestone 1.2.8 narrative

## [milestone 1.2.7] — 2026-04-09

### Added — Test Coverage + Data Pipeline (Rounds 161-165)
- **Session persistence tests** — 44 new tests covering create/save/load/list/delete/tags/search/branch (R162)
- **Proxy-mode training data collection** — `/v1/messages` exchanges now feed the quality scorer and collector (R163)
- **Doctor: SearXNG check** — verifies WebSearch backend is reachable (R164)
- **Doctor: Skills health** — reports curated + learned skill count (R164)
- **Doctor: Training data health** — collected/skipped/hit rate/quality stats (R164)

### Updated
- **README** — synced to milestone 1.2.6 narrative, added L3 training pipeline section, documented `training scan/report/export`, fixed SearXNG reference (R161)

## [milestone 1.2.6] — 2025-07-18

### Fixed — CLI Robustness + Training Pipeline (Rounds 155-160)
- **Subcommand arg parsing** — `owlcoda training status` no longer shows proxy status (R155)
- **Duplicate export command** — removed dead training export code, consolidated under `owlcoda training export` (R159)
- **Missing runtime deps** — added fflate (zip), cacache (cache), yaml (frontmatter), vscode-* (LSP), asciichart (/stats), cli-highlight (R152-153)

### Added
- **`owlcoda training scan`** — batch-score all stored sessions and collect qualifying ones (R157)
- **`owlcoda training report`** — quality distribution and dimension analysis across sessions (R158)
- **`owlcoda training export [fmt]`** — export training data as jsonl/sharegpt/insights (R159)
- Regression test for subcommand arg parsing (R156)
- Upstream dependency audit report (`docs/UPSTREAM-DEPS-AUDIT.md`)

### Training Pipeline Results
- First successful data collection: 3 sessions, avg quality 72/100
- 29 total exportable sessions from 40 stored
- Quality dimensions: 99% coherence, 80% completeness, 30% informativeness

## [milestone 1.2.5] — 2025-07-18

### Fixed — Upstream Dependency Audit (Rounds 151-154)
- **8 missing runtime deps installed** — yaml, vscode-jsonrpc, vscode-languageserver-protocol, vscode-languageserver-types, asciichart, cli-highlight, fflate, cacache
- LSP tools (code intelligence) no longer crash on first use
- `/stats` command chart rendering works
- Code syntax highlighting in diffs works
- YAML frontmatter parsing (plugins, skills) works
- DXT extension zip handling and cache cleanup work

### Added
- `docs/UPSTREAM-DEPS-AUDIT.md` — comprehensive audit of all upstream npm package dependencies
- `docs/` — project articles and iteration framework documentation (6 files)

### Not Installed (intentional)
- @ant/* (computer use) — behind feature flags, unreachable in local mode
- @aws-sdk/*, google-auth-library — cloud auth, not needed locally
- sharp, image-processor-napi — native binaries with try-catch fallbacks

## [milestone 1.2.3] — 2026-04-08

### Added — CLI Polish + Tests (Rounds 145-150)
- **`owlcoda sessions`** — list recent sessions with date, model, message count, preview; supports `--limit`/`--tag`/`--json`
- **`owlcoda skills info`** — skill system overview (curated/learned counts, top tags, command reference)
- **`owlcoda skills search`** — browse skills by keyword (distinct from TF-IDF `match`)
- **`skillTopK` / `skillThreshold` config** — tune skill injection sensitivity from config.json
- Tests for config-display skillInjection, injection topK/threshold, sessions parseArgs (+3 tests)

### Enhanced
- `owlcoda status` shows skill injection stats (injection count, hit rate, avg latency)
- README updated with all new CLI commands, config options, and /v1/skill-stats endpoint
- Help text includes search/match distinction

## [milestone 1.2.2] — 2026-04-08

### Added — Skills UX + Config (Rounds 137-144)
- **Skill scores response header** — `x-owlcoda-skill-scores` returns matched skill IDs with TF-IDF scores
- **Skill injection config toggle** — `skillInjection` in config.json (default: `true`), shown in `owlcoda config`
- **`owlcoda skills search <keyword>`** — simple text search across skill IDs, names, descriptions, tags, and procedures
- **`owlcoda status` skill stats** — shows injection count, hit rate, and avg latency when proxy is running

### Enhanced
- Curated skill matching quality — detail extraction increased from 200→500 chars, pitfalls extracted from markdown sections (R137)
- Resume hint normalized to `owlcoda --resume` (R138)
- `owlcoda skills stats` shows curated vs learned breakdown + health score based on learned skills only (R139)
- `owlcoda skills match` shows visual score bars (█/░) and source labels (📘 learned / 📦 curated) (R140)
- Help text updated with search/match distinction

## [milestone 1.2.1] — 2026-04-08

### Added — Curated Skills + Observability (Rounds 129-136)
- **Curated skills loader** — `src/skills/curated.ts` — parses 73 bundled SKILL.md files into matcher index
- **Dual-source skill loading** — `loadAllSkills()` merges curated (project-bundled) + learned (user) skills; learned takes priority on ID collision
- **Skills audit documentation** — `docs/skills/README.md` with per-skill evaluation (🟢/🟡/🔴/⚪ status)
- **Skill injection observability** — structured logging on every injection (query, matched IDs, scores, timing)
- **`/v1/skill-stats` endpoint** — GET returns hit rate, top 10 skills, avg match latency; DELETE resets
- **Upstream features audit** — `docs/UPSTREAM-FEATURES-AUDIT.md` — verified 12 upstream features (/voice, /rewind, buddy, coordinator, vim, etc.)
- **Safe feature flag enablement** — buddy, coordinator_mode, thinkback enabled via whitelist pattern in bun-bundle shims

### Enhanced
- `exportSkills()` now exports learned skills only (curated are project-bundled, read-only)
- `cleanupSkills()` only operates on learned skills (curated excluded from stale detection)
- Growthbook stub uses whitelist pattern instead of always-false for statsig gates
- `owlcoda skills match <query>` CLI command uses full skill index (curated + learned)
- OpenAPI spec includes `/v1/skill-stats` with full schema

## [milestone 1.2.0] — 2026-04-08

### Added — L3 Data Pipeline Foundations
- **Data quality scorer** — `src/data/quality.ts` — 5-dimension session evaluation (coherence, informativeness, tool richness, completeness, complexity) with 0-100 scoring
- **Quality-filtered export** — `owlcoda export --min-quality N` skips low-quality sessions
- **Aggregate quality report** — `owlcoda export --quality-report [--json]` with distribution, median, avg dimensions, top issues
- **Auto training data collector** — `src/data/collector.ts` — quality-gated session collection to `~/.owlcoda/training/collected.jsonl`
- **Training CLI** — `owlcoda training status|clear|path` — manage collected training data
- **Training API** — `GET /v1/training/status`, `POST /v1/training/clear`, `GET /v1/training/export`
- **PII sanitizer** — `src/data/sanitize.ts` — auto-strips API keys, emails, home paths, IPs from training data
- **`--sanitize` flag** for `owlcoda export` — manual PII scrubbing on export

### Enhanced
- `/v1/insights/:sessionId` now includes quality score and dimension breakdown
- Insights export format includes quality data
- Auto-synth wired to fire training data collection on session end
- Collector auto-sanitizes all collected data before writing
- OpenAPI spec updated with 3 new training endpoints
- 142 test files, 1613 tests, 0 type errors (Rounds 119-125)

## [milestone 1.1.0] — 2026-04-08

### Added — L2 Skill Learning
- **Skill schema + store** — `src/skills/schema.ts` + `store.ts` — SKILL.md-compatible document format with CRUD for `~/.owlcoda/skills/`
- **Session trace analyzer** — `src/skills/trace-analyzer.ts` — extracts tool call sequences, error recovery patterns, workflow steps from session history
- **Skill synthesizer** — `src/skills/synthesizer.ts` — template-based (zero deps) and LLM-powered synthesis from analyzed traces
- **TF-IDF skill matcher** — `src/skills/matcher.ts` — zero-dependency text matching, cosine similarity ranking
- **Skill injection** — `src/skills/injection.ts` — auto-matches learned skills and injects into system prompt via `<learned_skills>` block
- **`/v1/skills` API** — full CRUD: GET list, GET/:id, POST create, DELETE/:id
- **`owlcoda skills`** CLI — `list`, `show <id>`, `synth <session-id>`, `delete <id>` (supports --json)
- **Auto-synthesis** — `src/skills/auto-synth.ts` — evaluates session complexity and auto-synthesizes skills on session end
- **`x-owlcoda-skills` header** — response header listing injected skill IDs

### Enhanced
- Shell completions expanded to 21 commands (bash/zsh/fish)
- OpenAPI spec updated with /v1/skills endpoints
- 136 test files, 1525 tests, 0 type errors (Rounds 103-110)

## [milestone 1.0.8] — 2026-04-08

### Added
- **Response cache** wired into `/v1/messages` — non-streaming requests cached by SHA-256 content key
- **`/v1/cache` endpoint** — GET returns stats (size, hit rate), DELETE clears cache
- **`owlcoda cache`** — CLI command to view/clear response cache (--clear, --json)
- **Config auto-migration** wired into `loadConfig()` — old configs auto-upgraded (4 migrations)
- **Per-model timeout** — `timeoutMs` field on each model overrides global `routerTimeoutMs`

### Enhanced
- README fully refreshed for v1.0.8 — 23 CLI commands, 17 API endpoints, expanded capability matrix
- 94 source files, 127 test files, 1392 tests, 0 type errors

## [milestone 1.0.7] — 2026-04-08

### Added
- **`owlcoda audit`** — CLI command to query request audit log (--errors, --slow, --model filters)
- **Request audit log** — 500-entry ring buffer with model/status/duration filtering + `/v1/audit` endpoint
- **Standardized error responses** — all proxy errors follow Anthropic `{ type: "error", error: { type, message } }` format
- **Config migration framework** — auto-upgrade old configs (4 migrations: version, trailing slash, backendModel, port)
- **Response cache** — LRU cache (100 entries, 5min TTL) for repeated identical requests

### Enhanced
- CLI now has **19 commands** with full bash/zsh/fish completions
- 127 test files, 1388 tests, 0 type errors (Rounds 90-94)

## [milestone 1.0.6] — 2026-04-07

### Added
- **`owlcoda validate`** — standalone config validation command (schema + semantic checks)
- **`owlcoda health`** — on-demand health diagnostics (proxy, router, ports, model health)
- **Port utilities** — `isPortAvailable()`, `findAvailablePort()`, `checkPorts()` for port conflict detection
- **Latency histogram** — per-endpoint p50/p90/p95/p99 percentiles via ring buffer (200 samples)
- **`GET /v1/latency`** endpoint — programmatic access to latency percentile stats

### Enhanced
- **`--dry-run`** — now runs 4-step preflight: config display → validation → port check → doctor
- **Retry middleware** — added per-request timeout (60s default), jitter (±25%), AbortSignal support
- **Semantic validation** — now considers top-level `defaultModel` field (was only checking model-level `default`)
- **Shell completions** — all 18 commands now included in bash/zsh/fish completions
- 123 test files, 1347 tests, 0 type errors (Rounds 83-88)

## [milestone 1.0.5] — 2026-04-07

### Added
- **`/v1/chat/completions`** — OpenAI-compatible passthrough endpoint for chat-completions clients
- **Cloud-model alias registry** — 19 known cloud model names mapped to opus/sonnet/haiku tiers
- **Semantic config validation** — 7 smart checks (duplicate aliases, port conflict, missing tiers, etc.)
- **`owlcoda init` enhancement** — auto-populates known cloud aliases per tier with size-based heuristics
- **`owlcoda doctor` enhancement** — now includes semantic config warnings alongside environment checks
- 120 test files, 1322 tests, 0 type errors (Rounds 78-82)

## [milestone 1.0.4] — 2026-04-07

### Added
- **`owlcoda completions`** — generate shell autocompletion scripts (bash/zsh/fish)
- **`owlcoda models`** — display configured models grouped by tier with router availability probe
- **`owlcoda benchmark`** — quick latency/throughput test (TTFT, total time, TPS per model)
- **`owlcoda export`** — sanitized config export (strips API keys) in json/env/text formats
- **`owlcoda inspect`** — view recent request/response exchanges from running proxy
- **Request capture module** — records last 50 exchanges with stats (avg duration, error rate, model breakdown)
- **`/v1/captures`** endpoint — programmatic access to captured exchanges
- **Enhanced `owlcoda status`** — fetches live health, router latency, request counts, uptime from proxy
- 117 test files, 1298 tests, 0 type errors (Rounds 70-77)

## [milestone 1.0.3] — 2026-04-07

### Added
- **`owlcoda init`** — interactive config setup wizard with model auto-detection from router
- **`owlcoda doctor`** — environment diagnostic command (8 checks: Node.js, tsx, upstream, binary, config, router, proxy, launch mode)
- **`owlcoda config`** — show active configuration, resolved models, launch mode
- **`owlcoda logs`** — tail recent log entries from log file (formatted JSON)
- **`--binary` flag** — first-class binary mode (renamed from the legacy binary flag)
- **`--dry-run` flag** — validate environment without launching (runs config + doctor)
- **`--force` flag** for `owlcoda init` to overwrite existing config
- **Auto-detection** — if no source tree is configured, auto-fallback to binary mode when a supported agent binary is on PATH
- **`run --binary`** — headless binary mode, spawns the configured agent binary with proxy env wiring
- **Startup banner** — 🦉 emoji header with mode, model count, router URL, default model
- **Enhanced version** — `owlcoda --version` shows mode + node version
- **`/v1/api-info`** enhanced — includes modelCount, routerUrl
- 111 test files, 1263 tests, 0 type errors (Rounds 58-69)

### Changed
- Binary mode now "supported" in capability matrix (was "partial")
- Binary launcher rewritten with inline model env vars (14 env vars injected)
- Fake agent binary in cli-lifecycle tests captures 14 env vars (was 5)
- README: owlcoda init in Quick Start, all new commands documented
- Project notes: updated test count, added doctor.ts/init.ts to key files

### Security
- **Removed `config.json` from git** (contained plaintext API key)
- Added `config.json` and `upstream/` to `.gitignore`
- Created `config.example.json` as safe template
- Added `LICENSE` (MIT) and `NOTICE.md` (upstream relationship disclosure)

## [milestone 1.0.0] — 2025-07-24

### 🎉 v1.0 Release

OwlCoda reaches v1.0 after 57 rounds of iterative development across 6 phases.

**Highlights:**
- 106 test files, 1206 tests, 0 type errors
- 6 phases: Foundation → Source-First → Proxy Core → Test Coverage → Multi-Backend → Production Hardening
- Zero runtime dependencies (only Node.js built-ins)
- Full Anthropic Messages API compatibility (streaming + non-streaming)

### Added (since milestone 0.12.0)
- **Graceful shutdown** — AbortSignal.any aborts in-flight upstream requests on SIGINT/SIGTERM
- **Updated capability matrix** — 33 features documented with `/cost` upgraded to **supported**
- **HTTP API documentation** — 11 endpoints listed in README

### Changed
- README: v1.0 tagline, expanded capability matrix, HTTP API table, /cost section
- CHANGELOG: consolidated release history

## [milestone 0.12.0] — 2025-07-24

### Added
- **Model recommendation engine** — intent-aware scoring (keywords, perf, cost, model size)
- **`GET /v1/recommend?intent=code`** HTTP endpoint with OpenAPI spec
- **`/recommend` REPL command** — recommends best model for an intent
- **`GET /v1/cost`** HTTP endpoint — session cost summary via HTTP API
- **Multi-model session cost** — /cost REPL command now shows per-model breakdown with real TPS

### Fixed
- **unhandledRejection + uncaughtException** — process-level handlers prevent silent crashes
- **activeStreams leak** — cleanup on error events, not just close
- **Bash tool timeout** — explicit child.kill() with SIGTERM→SIGKILL escalation
- **Rate-limit bucket leak** — periodic pruning of stale buckets (5min interval)
- **Health monitor overlap** — guard against concurrent probes + stale cache pruning
- **Admin param validation** — count clamped to [1, 500] with NaN fallback
- **count-tokens body validation** — rejects non-object bodies with 400
- **Silent error swallowing** — replaced 5 `.catch(() => {})` with logged warnings

## [milestone 0.11.5] — 2025-07-24

### Added
- **Per-model performance tracker** — real-time TPS, latency (avg/p50), success rate per model
- **`GET /v1/perf`** HTTP endpoint with OpenAPI spec
- **`/perf` REPL command** — shows performance metrics sorted by usage
- **Model warmup** — `warmupModels()` pre-loads weights at startup
- **`/warmup` REPL command** for manual warmup
- **Enhanced cost estimation** — `getCostProfileWithPerf()` uses real TPS data
- **Session cost summary** — `getSessionCostSummary()` across multiple models
- **Streaming intent routing** — `x-owlcoda-intent` header in SSE responses
- **Milestone**: 100 test files, 1167 tests

### Changed
- Health monitor now probes each model at its own endpoint (multi-backend aware)
- Startup warmup runs after backend discovery completes

## [milestone 0.11.2] — 2025-07-24

### Added
- **Cost estimation module** — `src/cost-estimator.ts` with size-based profiles (small/medium/large/xlarge)
- **`x-owlcoda-estimated-cost` response header** on messages endpoint
- **Enhanced `/cost` REPL command** — shows cost breakdown (input/output/total/estimated time)
- **Intent routing wiring** — opt-in via `middleware.intentRouting: true` in config
- **`x-owlcoda-intent` response header** with detected intent and confidence
- **Cross-provider fallback** — `buildFallbackChain()` now tries different-backend models first

### Changed
- `MiddlewareConfig` gains `intentRouting?: boolean` flag
- Fallback chain is now backend-aware: different-endpoint models tried before same-endpoint

## [milestone 0.11.1] — 2025-07-24

### Added
- **Intent-based model routing** — `detectIntent()`, `resolveIntentModel()`, `routeByIntent()`
- Intent signals from tools (code, 0.8 confidence), system prompt, message content
- Tier-based resolution with `INTENT_TIER_MAP` + catalog `intent_defaults`

## [milestone 0.11.0] — 2025-07-24

### Added
- **Backend adapter layer** — `BackendAdapter` interface for Ollama, LM Studio, vLLM
- **Auto-discovery** — `discoverBackends()` probes configured backends at startup
- **Config merge** — `mergeDiscoveredModels()` integrates discovered models with direct endpoints
- **`GET /v1/backends`** HTTP endpoint for live backend discovery
- **`/backends`** REPL command with formatted output
- **Startup auto-discovery** — `discoverAndMergeBackends()` runs on server listen

## [milestone 0.10.7] — 2025-07-24

### Added
- Messages endpoint integration tests with mock HTTP router
- Display utils, parseArgs, paths/version, plugin loader test coverage
- SSE parser, model registry, models endpoint, session persistence edge tests
- Endpoint + dispatch test stubs completed

## [milestone 0.10.0] — 2025-07-23

### Added
- CLI module split (cli-core.ts extracted)
- SSE live metrics endpoint
- Cost honesty improvements
- Admin route extraction + OpenAPI completeness
- Model registry extraction + resilience tests

## [milestone 0.9.0] — 2025-07-22

### Added
- Session persistence (save/load/list/search/branch)
- Error budgets and SLO targets
- Request tracing and Prometheus metrics
- Observability middleware stack
- Health monitoring with circuit breaker

## [milestone 0.8.0] — 2025-07-21

### Added
- Capabilities matrix with honest labels
- Preflight checks and diagnostics
- Plugin system (request/response hooks)
- Audit logging
- Rate limiting, retry, fallback middleware
- 50+ slash commands in REPL

## [milestone 0.7.0] — 2025-07-20

### Added
- Source mode launcher — runs the legacy upstream TypeScript bridge directly
- ESM resolve hooks, bun:bundle shim, TTY emulation
- 21 cloud-only module stubs
- Daemon lifecycle management
