Skip to main content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.1.21 - 2026-09-18

Added

  • grounding — what a run actually received, and whether a claim about it dereferences. calls lists every tool call once with the result the model read, first seen wins, and a result compaction has written a [stale: marker over is never evidence; admit dereferences a {statement, id, quote} claim into a packet by literal containment and names what was missing when it refuses; carries_over is the inverse, refusing text that reproduces a run of words from what was read. Gossip's citation check, the outbox's "what is this draft answering" join and the diagnostician's carry-over refusal now share it. Not a tool: nothing enters the registry. (#244)
  • The front door hands a triage run only the dates_mentioned that are in the stranger's prose as written, between a three- and a 48-character bound; the rest are shown to a person in frontdoor show and the TUI with the reason each was refused — a finding, never a block. The Slack request card inherits the rule because it is built from the same brief. (#245)
  • The mail classifier is asked for deadline_quote — the words a deadline comes from, copied verbatim — and a deadline whose words are not in the message is dropped at classification and recorded as deadline_refused, with its reason. mail list, mail show, the sweep line and mail task say "dropped", never "found none"; an owner's --deadline correction settles the refusal either way. (#246)
  • web_search stays usable in a conversation that has touched mail, the calendar or the graph: the send axis is now a class — None < Blind < Chosen — and the interlock fires on Chosen, a destination the model names. A search backend is Blind only where its API has no destination field (searxng and tavily at both depths, exa at quick), earned in code and never granted in TOML; an armed conversation is served by the blind backends at quick depth and the result says so. (#241)
  • The clock is asked per turn rather than frozen into the system prompt, so a daemon's runs carry today's date. (#238)
  • A confirmed booking is not a request: it settles as booked, never reaches a model, and the request view shows the meeting. A cancellation un-books what it withdraws. (c3ebcd98)
  • A privacy policy and terms of service, one section per integration, with revocation steps per provider — what Google gates publishing the OAuth app on, and the reason a Testing-mode refresh token expired every seven days. (20318b7f, 6cf31150)
  • A spoken yes to an outbox offer is composed from the accepted phrases, carried to the outbox, and never sent to the CLI as words. (#228)
  • Voice calls sound a pause when the link stops carrying audio and a resume when it does again, and the worker holds every pending end-of-turn across the gap instead of ending the turn on the words before it (LinkWatch, scripts/voice/worker.py; linkVerdict, scripts/voice/voice-core.js). The page keeps a screen wake lock for the length of a call and treats the microphone track's mute edge as the same pause. The tasks page's dictate button plays a listening tone, shows the live level, and refuses to send a clip it did not record. A sampler thread records the main thread's stack whenever the worker's event loop stops for half a second — the unnamed first-turn stall every call since 2026-08-25 has shown.

Changed

  • A peer session's delivered message, a boredom notice and a plan-step nudge are labelled as the harness's voice in compaction summaries and in what mecha distill sends to the graph, not as the owner speaking. (#239)

Fixed

  • mecha serve logs a voice offer the runner refused or could not be reached for, and a dictate clip the STT refused, at warn — a failed call left no record anywhere before.
  • parakeet_server.py answers 400 to a WAV with no samples instead of crashing the request.
  • A staged draft's "what it answers" view stops at the staging call even when that call's own result errored, so a read made after the draft is never offered as what it was written from. (#244)

0.1.20 - 2026-09-11

Fixed

  • validate no longer re-probes a reflection whose recording cites a tool this machine can no longer offer by any route. Such a probe cannot succeed on a later night, so it is refused at preparation, counted and reported apart from the other skips, and never buys a --cover slot; --repeat still forces one if a retired provider returns. Previously these were reported as retryable and spent a ledger row and a --cover slot a night each to reach the same refusal. The refusal itself is free: it happens before any provider call.
  • appraisal_probe no longer charges its per-corpus arm budget, or counts a driven arm, for a probe whose recorded surface is gone — that failure happens before any provider call. Such a probe is counted in a new Tally::surface_lost, apart from the unavailable channel that means fixable, and reported in both the --json and human readouts of mecha sessions appraise.
  • bench/build-portable.sh records the branch, commit and sha256 of the tree it built beside the binary, and refuses an unattributable build — a dirty tree, one git cannot read as the root of its own checkout, or one that moved during the build. MECHA_BENCH_ALLOW_DIRTY=1 opts out and is stamped into the recorded line. Repo tooling only; no crate behaviour changes.

0.1.19 - 2026-09-09

Added

  • Anticipatory appraisal for confirmed-goal planning and inline outbox messages: owner-bound commitments, verification and cost evidence derive guilt, embarrassment, regret, disappointment, anxiety and curiosity assessments. run --appraisal-evidence supplies invocation evidence; outbox anticipate records exact-argument predictions with optional release guidance, and outbox outcome links owner feedback to confirmed delivery. Supported exposed-error and impact records produce retrospective embarrassment and guilt. Revised predictions and superseded outcomes retain their history; affected replay/probe paths explicitly report unsupported evidence reproduction.

  • A reproducible gossip comparison using real fixture searches, peer versus own-evidence followups, equal request ceilings and an explicit claim audit. Ordinary gossip behavior is unchanged.

  • A reproducible executable correction pilot compares frozen rules and turn budgets using real file tools, independent artifact checks and paired task-success reports.

  • Validation attempt receipts and mecha validate --repeat; unchanged inputs are deferred while transient failures and regression confirmations remain eligible.

  • Gossip target identity, retrieved citation checks, per-round citation yield and explicit filing failure counts.

  • Ground mismatch learning in verified criterion/check failures; owner-bound task diagnostics can carry relevant count constraints without exposing expected answers. Forecast overruns remain observations and no longer justify behavioral rules by themselves.

  • run --mismatch-case and registered experiment fixtures enable independent JSON-artifact validation of clean planning mismatches in isolated file-only tasks. Validation and rule proposals retain ungraded outcomes for missing evidence or policy refusals; each executed arm records an audit receipt. --no-goal-guidance now works before or after a subcommand.

  • mecha run --goal KIND:ID explicitly confirms a goal, including on resume; experiments can register per-task confirmed goals. Completed todo steps now retain and execute a previously declared check when the completing update omits it.

  • A paired appraisal pilot with twelve independently graded artifact tasks, synthetic charter and sensor fixtures, and reporting that preserves missing measurements. Explicit experiment planning levers now enable disabled settings.

  • Goal-aware planning feedback: confirmed goals persist across turns and resume; declared checks execute through the normal guards; failed criteria/checks and frozen-check changes can produce bounded mismatch reflections. Forecast overruns remain observations. Trace replay explicitly refuses recordings with harness checks until their observations can be reconstructed.

  • goal_context retrieves applicable goal-linked rules and recent, clean examples with passing checks. Optional agent.goal_guidance gives fixed planning advice from task progress, confirmed goals and charter sensor discrepancies.

  • Event-time goal attribution preserves task and charter relationships. Owner task closure remains a separate verdict; global queue movement earns no causal credit.

  • Explicit per-task fixture clocks and recorded rubric judges for assistant experiments; answer checks receive actual tool evidence and fail on grading errors.

  • Durable workflows link task conversations, questions, drafts and dependencies; owner-specified completion checks inspect artifacts and confirmed delivery.

  • A Today view groups urgent items, decisions, verified work and waiting, with commitments, quiet hours, coalesced reminders and snoozing. Refreshes share source reads across workflows; finished work can be explicitly reopened. Failed chat recording preserves prior workflow completion evidence. Blocked question resumes leave the question open and create no running marker.

  • Batch outbox rejection continues past uncertain deliveries, preserving their records and reporting partial failures with a nonzero exit status.

  • Explicit structured-output provider capabilities for quarantined extraction, plus stable research, assistant and coding tool profiles.

  • Repeated assistant lifetime fixtures check actual sent messages and calendar events after review, detect duplicates, and record owner-action counts.

  • Outbox delivery reconciliation in the CLI and web, with durable attempts that prevent automatic retry after an uncertain outcome.

  • A learned rule can be scoped to a surface. The front-end that owns a run tells prepare its kind (GlobalOpts::surface, never a flag), the learned-rules block is matched against it, and the run record keeps what was matched (RunConfig::rules_surface) for the miner to stamp — never SessionMeta::kind, which the board's task door on serve records as a task while the block was matched as web. A rule learned from reflections all on one surface loads only there, widens by restatement from another, and narrows by conviction, as the workspace does; the reflect pass reconciles rows stamped with a kind before the field existed. A question continuation restores the asking run's surface off its record rather than asserting one, and the miner stamps the keys of the run record covering each intervention. A stored scope naming a surface this build cannot read is kept verbatim, matches nothing rather than everything, and is reported at startup, and the front door and mail now append the run record that keeps the surface they declare. Rules from before the key carry no surface and load on every surface as they did.

  • The TUI's task edit form carries the project. tasks set --project existed in the terminal and not in the modal; the edit form now prefills the project and re-files only when the field changed (clearing it clears), so the two surfaces offer the same correction.

  • mecha rules says when a scope loads nowhere. A rule scoped to a workspace or surface no run record ever presented is dark with nothing warning — the corpus-shaped half of the never-loads check, which named tools only; the roster prints LOADS NOWHERE beside it and the JSON carries loads_nowhere, read off every run record in the session store and only when a scope names such a key.

  • The fixture board can be asked to truncate (MECHA_FIXTURE_BOARD_CAP=N), and the cross-process fixture test measures the flag every board reader keys off against the fixture rather than a literal.

  • A learned rule can be scoped to a workspace. The workspace a run is jailed to joins the tool set as a scope key (Situation::scope, Situation::matches, Situation::key): a rule learned from reflections that all came from one workspace loads only in runs jailed there, a verbatim restatement from a second workspace's batch widens it by dropping the key, and a conviction in one workspace narrows it to the one it held in — the same harness arithmetic tools already had (GOAL-SYSTEM-DESIGN.md §17.4). The run record keeps the workspace the block was matched against (RunConfig::rules_workspace), and the miner stamps a reflection with that rather than the session's jail — on serve and Slack the two differ, and a lesson scoped to a jail no match presents would be dark forever. Every mecha reflect pass now reconciles each recorded workspace against the run record before anything is mined — to the matched one, or to none where the record carries none; never adding a key, and leaving a row whose session cannot be read — since reflections stamped with a jail before the field existed were inert until the workspace became a key, and the nightly's learn follows that pass. Rules from before the key carry no workspace and load everywhere as they did. The surface stays recorded and unmatched, since prepare is not told the session kind.

  • A project closes when the owner closes its last task, and its appraisal is the fold over every session that worked one. The board row now carries project_id beside the project's name (mecha-graph PR #10, unreleased when written), the closure appraisal in mecha tasks set records project:<id> on the appraisal's goals after the task, and closing the last open task under a project prints the project's reading on stderr — labels counted, valence summed, tasks never delegated or unreadable counted rather than dropped. No follow-up is staged for a project and no record is written; a board that names a project without identifying it is said once rather than read as no project (docs/GOAL-SYSTEM-DESIGN.md §17.7 item 5, finished). mecha tasks set --project re-files a task by name or node id, or clears it with "" — the correction path a cited pointer needs, through the graph's own resolver.

  • The goal pointer crosses to the graph whole; the sentence stays home. A distilled episode's meta carries goal and serves_charter as kind:id pointers and the pointer on each goal error, where the kind word alone used to cross — resolved first: a charter id against the charter, a task or project id against the board (one kg_task_list per distill run), and what does not resolve falls back to the kind word on goal and on each error's goal alike, so a run that named a setpoint is not read as one that named nothing (serves_charter is the join key alone, absent when no line resolves). The goal hypothesis, the owner's answer and the charter line's text never ride (§17.7 item 8).

Changed

  • Refreshed the documentation for current CLI commands and configuration, Today and workflows, automatic learning, outbox delivery recovery, and replay limits. Goals and appraisal now covers confirmation and drift, project closure, step metrics, current labels, and the features consuming appraisal evidence.

  • The knowledge graph is named as the graph everywhere in the tree. Comments, tests and one mecha distill warning still called it by the retired server's name; nothing calls that server, and the words now say so. Three places changed what somebody does (found on review): the published evaluation page named a fixture file that does not exist, the landing page's [[mcp]] snippet named a binary that is not shipped (and, once it named a shipped one, lacked the [mcp.capabilities] untrusted_input = true that arms the interlock over the graph — the override TRIFECTA.md calls load-bearing; found on review), and prompts/agent.md told the model to call pkg__kg_* tools that are not on the surface — it now names the bare kg_* the documented wiring (prefix_tools = false) exposes.

Fixed

  • Follow-up validation replays the recorded tool surface and grades only complete answers. Coverage selection skips deferred inputs before spending probe slots.

  • Harness measurements reject unsupported effort controls and incomplete replay tails, retaining arm receipts. MCP capability changes are routed as security proposals.

  • Stopping the web service preserves active turns. SIGTERM and Ctrl-C close pending questions, cancel chat and mounted voice work cooperatively, and wait for partial transcripts and outcomes before exiting. A second Ctrl-C or SIGTERM forces termination if needed. Idle event streams and voice clients no longer prevent shutdown. Updated serve/voice service units signal the daemon first, and MCP cleanup finishes before it exits.

  • Chat input appears on every connected device. Typed messages and steering are broadcast with request IDs, avoiding duplicate echoes on the sender and preserving separate messages with identical text. Steering distinguishes queued, delivered, and too-late input instead of claiming everything was sent.

  • Chat transcript and event-stream reads no longer create sessions. The app explicitly opens chats and repeats that step when reconnecting; scripted clients must first POST /api/chat/{key} with X-Mecha-Request: 1. Mode changes now reject invalid keys before creating a workspace. Permanent opening refusals stop retries; transient failures back off.

  • Docker-confined MCP clients now own and remove their containers when the last client drops, including failed or cancelled startup and servers that ignore stdin EOF. Cleanup also works after the async runtime shuts down; mecha run releases clients before refusal and empty-output exit codes, as does mecha batch before a failed-batch exit. Daemon failures are retried and reported with bounded Docker diagnostics.

  • Browser mutations now require a same-origin request header, including bodyless actions, uploads, and voice offers. Reload the web app after upgrading; scripted API clients must send X-Mecha-Request: 1 on writes.

  • Chat uploads reserve filenames atomically and refuse symlinked inboxes; resumed conversations use their recorded workspace for attachments. Downloads stream from confined file handles instead of buffering whole files.

  • MCP clients terminate their spawned child process and abort reader tasks when the last client is dropped, including after an unsuccessful handshake.

  • fs_read applies line selection and its byte ceiling while reading, so a small selection does not allocate or decode an entire large file.

  • Graph route updates and charter drag state now use Svelte's reactive state correctly, removing the corresponding build warnings.

0.1.18 - 2026-09-06

Added

  • The run record knows the goal the owner confirmed and whether the plan left it. A question resume anchors the run on the answered question's serves, and a present human's answer to ask_user anchors it in-run; todo counts every plan write made under an anchor, every one whose serves named a different kind or id, and — apart — every one that named nothing. RunStats carries goal_anchor, goal_plan_writes, goal_drift_writes and goal_unnamed_writes (unknown before the sensor), and mecha sessions health prints a goal drift line and JSON keys whose rate is changed pointers over runs that named a goal at least once under an anchor (docs/GOAL-SYSTEM-DESIGN.md §17.7 item 4, the sensor half; the re-ask stays off until it is read).

  • The goal rides on the question, and a project is a kind of goal. ask_user takes goal (one sentence) and serves (charter:<id>, task:<id>, project:<id>), shows them above the question as one text, and a delegated run's parked question keeps the typed hypothesis beside the owner's answer — the goal record, printed by mecha questions show and carried by /api/questions. The delegated seed folds the goal into the one question it already asks first; the charter block asks every surface for the sentence in words that name no tool; a run with nobody to ask states the goal it is assuming, and mecha outbox show and the web outbox print what the drafting run's plan served as of the staging call, with the charter line's own text, so releasing the draft confirms it. sessions appraise counts sessions that put a goal to the owner and had it answered, and reads the ask's serves as a second producer of a named goal. GoalRef::Project is the fourth kind (docs/GOAL-SYSTEM- DESIGN.md §17.7 items 3 and 5). The charter budget is 2,500 characters, moved with the block's own prose.

  • Task sources, and AgentDojo as a fixture world. [tasks] source names an executable instead of a case file; the driver calls it with list, setup <task> and grade <task> (the run's --json result on stdin), fail-closed at every edge, with four pointer variables so it finds the trial's world. eval/fixtures/source_stub.py is the contract's reference; eval/fixtures/dojo.py serves an AgentDojo suite over MCP and grades its tasks with the suite's own utility and security, from a venv scripts/dojo-venv.sh builds; eval/dojo-workspace.toml measures the interlock's catch rate and false-refusal cost on the workspace suite.

  • The run record counts null steps, reopened steps and completed steps — a plan step completed with no call behind it, a completed step set back to in progress, and the completions both are read against — counted by the todo tool into a per-run sensor and read by mecha sessions health as rates, each over the runs in which the event could have happened — a null over runs with a completion whose span was measured, a reopen over runs with any step activity — with the totals beside (docs/GOAL-SYSTEM-DESIGN.md §17.7 item 2's precondition, now readable from the store).

  • Fixture servers on the manifest, and the synthetic assistant home. A [fixtures] table on an experiment manifest names MCP servers the trial home runs instead of the operator's: when it names any, the home's [[mcp]] is exactly that list for every arm, each server persists under the home (fixtures/<name>/, as MECHA_FIXTURE_DIR) and is seeded once, a charter may be written over the seeded one, the outbox route is the world's (outbox_tools, spelled, never inherited — the operator's names live tools), and the names join the condition hash. Seeding fails closed (built beside, marked, renamed into place) and a single re-seeds every store per trial, since its per-arm home is shared. That closed world opens the two owner channels the principal was gated off: outbox approve and tasks set are permitted under a manifest with fixtures (experiment::SERVER_VERBS, permitted_verb), a release is vetted against the draft's <fixture>__ prefix and run under the driver's own --yes, and the principal is told which fixtures its arm reaches. Two servers ship: eval/fixtures/board_server.py (the graph's kg_task_* in the real server's shapes, stateful, kg_upsert recorded and never entered) and eval/fixtures/mail_server.py (mecha-mail's surface, every send a line in sent.jsonl, nothing delivered). eval/fixtures/home/ is the synthetic assistant home on the fictional cast — board, mailbox with one injected thread, calendar, charter — with eval/home-cases.jsonl, eval/home-principal.toml (release to the cast, close task-<case id> by the grade) and eval/home-lifetime.toml. The manifest's relative paths (servers, the principal's script and policy) resolve against the checkout exp run starts from; the home is rendered before position 0's before_task call so the principal's first verb finds the board.

  • Learned rules widen on evidence and narrow on conviction, and the validation ledger is per region (docs/GOAL-SYSTEM-DESIGN.md §17.4). A rule carries support — the sub-regions its evidence was recorded in — beside scope; a learn batch that repeats a rule from another situation word for word widens it to the intersection of the two regions (the harness computes the scope, the learner only claims "same lesson"). Each validation row records the sub-region the probe exercised, mecha rules shows the counts by region, probation is released only by rows that exercised the rule, mecha validate --cover N spends N probes per (rule, region) pair the ledger has never graded (the nightly passes 1), and mecha rules propose-retirements narrows a rule convicted in some of its support regions and clean in others to where it held instead of retiring it, retiring with the reason when no narrower scope excludes the failure.

  • mecha exp — experiments (mecha_core::experiment; docs/EXPERIMENT-DESIGN.md). A designed comparison over a chosen set of runs: new writes a manifest once (arms naming a provider and model and varying the closed lever set and the override knobs — either axis or both — a control, a falsifiable prediction per treatment arm, the tasks as an eval case file, the seeds, the split seed), run drives one child mecha run per arm × task × seed × repetition into an isolated home per arm whose config.toml is the arm (the operator's posture with keys scrubbed and the arm applied; the learning store, skills and charter seeded once; a clean, allowlisted environment; the staged workspace as cwd), status and judge read the trials back (each treatment arm against the control through the candidate gate, the task outcome entering as a cost), and export hands the whole record over. eval is the special case of arms that name models under the bare preset, and is to become a thin front over this. New with it: SessionKind::Experiment, hidden from the real store's readers and admitted in a trial home; RunConfig::experiment, the trial reference a child's session carries; and mecha run --json now a superset of the batch result (tool calls, taint, counters, elapsed), so a run driven as a process reaches the same grader.

  • mecha exp runs lifetimes. A kind = "lifetime" manifest — an ordered task sequence (tasks.ids, in order) and a [schedule] of loop stages between tasks (reflect every task, validate --unprocessed-only, learn --holdout 0.25 --auto and rules propose-retirements --apply every fifth, harness ruminate every tenth, by default; 0 is never — the nightly's order and flags, validate measuring before learn consumes and the retirement brake after) — runs one isolated home per arm × seed × repetition, walks the sequence in position order, and after each task runs the stages due as child mecha verbs in that home, sequentially, each written to the lifetime's stage ledger (stages/<lifetime>.jsonl) with its exit status. Resume reads the rows and the ledger: a finished task is not rerun, a missing stage runs before the next task, a failed one is due again with its own log — only while no later position has finished, else it is recorded skipped out of sequence — and an interrupted one leaves a running line that burns its attempt. judge and export read the ledger: a failed interrupted or unreadable stage line on either side holds an arm's verdict at propose, and the export carries every stage line. What a stage accepts inside the home — a ruminate override — reaches the next task under the arm's pinned keys. Rows gain position and lifetime; status shows each lifetime's sequence by position with its stage counts. New with it: the stage levers, a second closed set an arm names off in stages_off (reflect, learn, validate, ruminate, retire, sensors_in_brief), on the row's condition hash, refused on a single; and [agent] sensors_in_brief (default on), the switch behind the last one, which withholds the homeostat's and guilt's readings from the diagnostician's brief — their only reader. A lifetime run with no one answering mines no reflections, since every trigger today is an owner's act; ruminate is the stage with an effect until the principal lands.

  • The principal: an executable under a contract, driven by mecha exp. A lifetime manifest's [principal] command is called before and after every task with the trial's state as JSON on stdin — the case, the graded row, the home's pending drafts and open questions — and answers with the owner's verbs to run and the refusals to script. The driver runs each verb as a child mecha against the trial home from a closed set (tasks set|steer|stop, outbox reject|edit — never a release, which executes the routed tool for real, questions answer|abandon) and records the call and every act on the lifetime's ledger as a principal line, held like a stage. New with it: MECHA_DENIALS_FILE and tool::FileDenyApprover — scripted refusals a run answers ahead of its own approver as "Denied by the user", the owner's channel with no headless path until now, honoured only under the experiment session kind and refusing any other run's start — and scripts/principal-gold.py, the gold-verdict principal. Board closure waits on a fixture graph server.

  • Two more levers: predictive_compaction and carried_state ([agent] predictive_compaction, [agent] carried_state, --no-predictive-compaction, --no-carried-state). The two in-run dispositions the lever set could not name because nothing could switch them off: the compaction trigger firing on the forecast of the next request (the threshold stays — a lever removes a disposition above a structural check, never the check — and so do the predictor's other uses, the per-turn tool-output budget and the headroom the model is shown), and a tool's state riding verbatim across a compaction. Both ship on, both are recorded on RunConfig::levers_off, and mecha eval forces both off with the rest of the set. The appraiser's pass stays out of the set — it has no in-run site — and sensors_in_brief waits for the trial manifest, being a stage lever rather than a run's.

  • A cancelled run records what ended it. StopCause gains Parked (a question put to the owner), Stopped (a person: Ctrl-C, a stop button, tasks stop, a trigger cancelled by request) and Shutdown (the process or a limit: SIGTERM, a facade shutdown, a trigger's wall-clock ceiling). The canceller says which through agent::CancelReason, carried beside the token on RunContext and on ToolCtx; a bare token cancel still records Interrupted, which is now read as unknown-which and never as any of the three. The appraisal reads Stopped as the owner's verdict on the intervention channel — a stop followed by a re-prompt is a redirect cited at the re-prompt, a stop never resumed is an abandonment — and reads the other three as nothing. docs/APPRAISAL-RESEARCH.md §3.3, measured in §1.7.2 as the largest single move available to the readout.

  • RunStats::duration_secs — the run's wall-clock seconds, from run_in's entry to its return; None on rows written before it and on any fold missing a run's time. §1.7.4 found wall clock the best predictor of failure on the kept Terminal-Bench trials and on no record.

  • The lever set (mecha_core::harness::Lever, RunConfig::levers_off; docs/EXPERIMENT-DESIGN.md Part II, The switch set, on PR #156). The closed on/off half of the override set: thirteen subsystems a run can carry structurally absent — MCP, learned rules, hooks, the outbox, fallback, messages, skills, the charter, the compact tool, step escalation, approval rules, boredom and compact validation — each a switch that already exists, now named once and recorded on every session's config record in a stable order. None on a transcript that predates the field, and on one naming a lever this build does not know, because a lever dropped from the list would read as on. mecha eval's bare arm is now Lever::bare expressed through the same switches (plus the approval rules, which bare itself never lifts — eval throws that lever by name), and a test reads the forced set back through the function the record is written from. Two new global flags complete the set: --no-boredom and --no-compact-validate, opt-outs for the two [agent] switches that ship on.

  • scripts/appraisal-validity.py — step 0 of the appraisal experiments: joins the readout and the counters to Harbor's per-trial verdict over the kept Terminal-Bench sessions and reports discrimination per channel; the finding is docs/APPRAISAL-RESEARCH.md §1.7.

  • Per-command approval rules ([[rule]] and [approval] in config, mecha_core::policy; PRs #143 and #148). Approval used to be tool-granularity — a run either approved every shell command or asked about every one. A rule names a tool, a prefix pattern (["git", ["status", "diff", "log"]]) and one of three ordered decisions, allow < prompt < forbid, most restrictive wins, judged one shell segment at a time by a splitter that refuses anything it cannot take apart with certainty. allow removes the human prompt and nothing else — the approver's mode, the trifecta interlock and an escalation all still apply; prompt puts the call in front of a person even past a standing "always", and fails closed where no person can be asked; forbid refuses without consulting anyone and is never mined as a correction. An allowlisted interpreter is not an allowlisted command (python -c, sh -c, xargs, sudo, timeout and the rest are judged as at least prompt), every patterned rule must carry a match example checked at load, allow loads from the global file only, and mecha eval forces the whole policy off. A command the splitter finds opaque is searched for every forbid and prompt by its words (redirects, here-strings, $IFS and braced expansions accounted for) and otherwise falls through to the approver as an unmatched command does; an allow/prompt on a tool the live outbox route stages is refused at startup, since staging runs before the rules (a project layer's is set aside for the run with a warning instead).

  • The web app has a desktop layout (REMOTE-SURFACE-DESIGN.md D10). It was nine phone screens faithfully built, so a 1500px window rendered a 560px column and left the rest of the screen empty. Two breakpoints, each because something specific stops fitting: at 900px the bottom nav becomes a left rail and the shell's floating gear docks to its foot, and every view keeps a reading measure instead of stretching; at 1180px the chat's session drawer stops being a drawer and simply stays open — the one thing a phone could not afford, and the whole reason the drawer existed. The phone layout is unchanged apart from the chat header, whose status chips now wrap under the session name rather than squeezing it to nothing.

  • A conversation names itself (REMOTE-SURFACE-DESIGN.md D11, mecha_core::title). A session was created under a key, and a key is an address rather than a name; asking for one moved the friction rather than removing it. mecha serve now derives a short name after a run and re-derives it as the conversation grows (owner turns 1, 3 and 8), recorded as a Record::Title and applied over the header by Session::read. The titler reads only the owner's own turns — never assistant text, never tool results: a title is rendered in the owner's session list for as long as the session exists, which is a longer-lived display than any single answer.

  • Appraisal reports the sign it was hiding (appraisal::Valence, Readout, live_readout; PR #140). The label alone gated on a dimension only a paid replay fills, so 142 of 143 appraised sessions read neutral, twenty-two owner-rejected drafts among them. Every surface now shows the dimensional readout beside the label — positive and negative sums kept apart, never netted: a number on the TUI badge and in a Slack thread line, a two-sided bar on the web chip — and sessions appraise sums it across the corpus. With it: a kind on every session (SessionKind on the meta record, written by each front-end; MECHA_SESSION_KIND=test marks smoke runs and may only narrow), and every corpus readout excludes test sessions by default and counts what it hid, because 46 of 143 appraised sessions were the harness's own development runs; a ceiling reads as the owner's own limit rather than the world's, with Appraisal::cut_short keeping the closure follow-up honest; and the plan is the prediction — a todo step may carry expect, check and expect_calls, the check is frozen the moment its step completes and restored on any later rewrite (surviving trims, resumes, thinned echoes and compaction, each closed on review with a test), a failed check is a signed error, and Trigger::Mismatch is the wire word for the reflection that will fire on one. The check is recorded and never executed here.

  • Appraisal reads the commitment stores (appraisal::SessionRecords, Channel::Commitment; PR #141). A question answered and the session finishing, a question abandoned, a front-door request closed with nothing sent, and a run that left fewer things waiting on the owner than it found — net of anything the owner gave up on (Depth::given_up), so a rejected draft or an abandoned question shortens the queue without crediting the run — each sign an error against the record, from ids and states only, never prose. A judged follow-up reflection counts as an intervention on clean provenance alone, stricter than the learning loop's own gate. The guilt sensor's relief lives in its own field (Homeostat::guilt_after_relief) so the corpus mean over the level stays one quantity, and a reading computed over a store that could not be fully read is marked partial on the record itself, where the closure path's printout and JSON carry it. The queue-delta positive is the one positive a live surface can show; it is a global diff of the stores, disclosed as such. Charter sensors — owner-written observables on a charter line — are designed at GOAL-SYSTEM-DESIGN.md §11.1 under seven containments, and built in three steps: the [line.sensor] table (five closed kinds, a setpoint typed by the kind, refused at load when unknown or zero) and the attribution of a run to the line whose sensor watches what its trace touched; then readings (mecha_core::reading) — each sensored line read against its store as one of five states kept apart (unread, deferred, nothing waiting, too few runs, observed with how far past the setpoint), recorded on every run's homeostat, shown beside the line on mecha charter, the TUI's /charter and the web settings page, and read by mecha doctor, whose stuck-draft, unanswered-question and stale-request findings now use the owner's setpoint where a line names one and name the line, with a saturation finding when a line has read past its setpoint on each of the last ten runs; then the replay tiebreak — among episodes of equal headroom, the one whose signed error names the higher-ranked charter line is selected first, the count printed beside the draw's seed. The sensor's kind, setpoint and reading never enter a prompt and never feed a metric.

  • mecha reflect --backfill-situations — reflections mined before the situation field get one, recomputed from their transcripts with no model call: the tool window, surface and workspace the miner would have recorded, matched by session, trigger and intervention text. A reflection whose intervention cannot be found once in its transcript stays without one and is listed with the reason; a situation recorded at mining is never overwritten; --dry-run reports without writing. The recomputation is stamped on the record (situation_recomputed_at).

  • A sensor can be authored in the web charter editor. Under an open line: a select over the closed set of kinds the server serves, an empty setpoint field with the parser's own unit hint, and remove. Nothing is prefilled — the page proposes no kind and no number — and a bad setpoint is refused at the save by the same reader every run loads through, with the line named. A half-filled sensor is flagged beside the line before the save rather than dropped silently.

Fixed

  • The date prompt now includes a computed local calendar reference for yesterday through the coming week, preventing reliance on inferred weekday/date arithmetic.

  • Mail search rows and thread headers now include a computed calendar date and weekday beside the original timestamp, in the configured mailbox timezone.

  • Mail read-state metadata now identifies the owner's mailbox and unknown recipient read status. Assistant guidance requires source-backed calendar and relative-date claims.

  • Project configuration can no longer remove inherited outbox routes or redirect the global review store.

  • Replays retain per-result provenance and treat legacy unknown results as untrusted.

  • Assistant guidance no longer claims that delegation clears conversation taint; failed task runs now acknowledge possible partial files and drafts.

  • Six-lane harness review (PRs #139 and #142): a dangling symlink no longer passes the path jail as a new file and both writers open with O_NOFOLLOW; a delegated child inherits the parent's taint and a child holding a send-capable tool is itself external_send, so an armed run cannot launder a send through a subagent; trifecta = "ask" no longer equals "allow" under a headless approver (Approver::escalate, default Blocked); streamed Anthropic usage frames replace rather than add; Failover reports its primary's vision; the compaction summariser and validator no longer stream as the assistant's words; a cancelled run hands back the assistant's last words rather than the tool-result tail; a panicking tool costs the call, not the run; the OpenAI-compatible decoder surfaces llama-server's mid-stream error frame; a stream that simply ends is an error, not a turn; one compaction summary survives instead of stacking; and the HTTP client splits into a streaming and a non-streaming one so a stall bound no longer caps a long non-streaming exchange.

  • A backlog read creates nothing (PR #147, closing what #141 opened). Backlog::read runs at both ends of every run, and two of its five store readers still opened through constructors that create — one runs git init — so a machine that had never ruminated gained a learning store twice per run, and a failed creation read as an unknown depth rather than an empty one. All five readers open only what exists, and the test moves the mecha home and asserts it is empty afterwards.

Changed

  • Every mecha eval scorecard is recorded as a one-arm experiment under ~/.mecha/experiments/eval-<stamp>/: a manifest with no control (a measurement — one or more arms, no prediction, nothing to judge) is now a valid design, and eval writes one before its arm runs, carrying the provider and model that ran (the flag, else the provider's configured model, else its default — the run's own resolution), the bare preset plus --mcp, the machine's knobs, and a description naming what no lever can (the lifted approval rules). Each run of each case lands as its own trial row — --runs k is the manifest's repetitions, so k rows share a condition hash and differ by repetition, as the hash's contract requires; mecha exp status|export read it; -o gains experiment. The scorecard is unchanged. An eval under --mcp-file or --no-ask-user is not recorded and says so as a choice, not a failure: fixture servers and a withheld tool have no lever, and a trial row's condition hash would call that eval bare.

  • mecha eval --ab-config and --ab-rules are two-arm experiments. Each writes its design to ~/.mecha/experiments/eval-ab-<kind>-<stamp>/ before the arms run — a bare control and a bare-plus-delta treatment (the override, or learned_rules turned back on: an arm's new levers_on) predicting a lower failure cost — files each case as a trial scored pass^k over --runs, and takes its verdict from experiment::judge, so mecha exp judge <name> re-derives it. The arms still run in-process on eval's forcings; the printed output keeps its shape, the holdout is drawn by a seed derived from the delta rather than a hash of the case id (the same delta over the same case set still holds out the same cases; a case added to the file reshuffles the draw), and both flags now write one JSON shape — experiment, ab_rules, ab_config, holdout_in, judgement, pairs, arm_a, arm_b, flips (each {id, was, now}) — where --ab-rules used to write without_rules/with_rules and flips keyed the same way. Two narrowings: --mcp-file is refused with either A/B flag, since the fixture servers it adds have no lever to record them under; and the manifest's description says that both arms ran with the operator's approval rules lifted, eval's fixture forcing, which no lever can name. Both arms' records carry the four knobs the machine and the flags set, so a control run at --max-turns 60 is filed as such.

  • mecha eval now forces boredom and compact validation off, with the rest of the lever set. Both are [agent] switches that ship on, so two machines with different config graded different runs — a boredom notice in the model's context on one, a second model call per compaction on the other — and no scorecard recorded which. A scorecard taken before this change ran with whatever this machine's config said; one taken after runs bare. (Eval writes no session, so the record that names the bare arm is any other front-end's, from the same definition.)

  • Starting a web conversation asks nothing. The "new" button moved out of the session drawer into the chat header, where it is one tap from anywhere, and no longer prompts for a lowercase-and-dashes name.

  • The served web app revalidates by ETag as well as by date (tower-http 0.6 → 0.7). ServeDir now stamps every file it serves with a strong ETag from its size and mtime, honours If-None-Match and If-Match per RFC 9110, and answers a conditional request with a 304 Not Modified that carries both validators (ETag, Last-Modified) where 0.6 sent a bare status line. For the no-cache entry document that is the difference between a browser that can refresh the entry it just confirmed and one that cannot; the hashed assets stay immutable and never ask. ServeDir::new is unchanged, and the release's breaking changes all sit in middleware this crate does not use.

0.1.17 - 2026-08-31

Changed

  • The web notes and graph tabs are one graph tab (NOTES-GRAPH-DESIGN.md). They were two disjoint halves over one store — a note is a graph episode — so Graph.svelte replaces both: one find field (the graph's own hybrid BM25+vector search; ⌘K focuses it; a search hit naming an entity opens it, and a missed entity lookup falls back to search instead of dead-ending), capture with the CLI's own "entities linked" confirmation, the recent-notes list with in-place edit, and entity pages that now render what the envelope always carried (aliases, per-source coverage) plus the neighborhood (mecha kg related/api/related) and bi-temporal history with superseded facts (mecha kg timeline/api/timeline). The #notes hash still routes.

Added

  • The web settings page can disagree with what the model learned (#119). It listed the learned rules and could do nothing about them, and never showed a reflection at all — so the stage where objecting is cheap was the one stage with no browser surface. It now carries the two panes the TUI's /learning has, with the same verbs behind them.

  • The retirement end-to-end drill (scripts/retirement-drill.sh + mecha-core/examples/retirement_drill_seed.rs) — the ungating precondition LEARNING-LOOP-RESEARCH.md named, finally run whole: in an isolated world (MECHA_SESSION_DIR/MECHA_LEARNING_DIR), an honest recording is seeded with a steer, a probationary bad rule and a bystander, then real probe passes against the live model must regress, bisect to the right rule, hold at one conviction, and retire at the probation leash of 2 — bystander untouched. Its first run found the probation-release bug below, which every unit test under the path had passed over. drive_arm now traces each arm's verdict, calls and final text under MECHA_LOG=debug.

  • The graph's shadow queue reaches every surface an owner holds (#114). mecha review shadow lists the surfaced-verdict queue (review-on-use: live shadow facts about to matter) and decides one with --confirm/--refute through the mecha-graph child; mecha serve gains /api/queue/shadow and /api/queue/shadow/verdict (serve/review.rs::shadow/shadow_verdict), the web review page a surfaced-verdict deck, the /queues modal a graph-shadow row with in-place verdicts, and /find's entity detail per-fact tier marks.

  • A web entity page (#114): /api/entity (serve/board.rs::entity) and Entity.svelte, nav entry graph, marking unreviewed and denied facts as such (entity_detail_marks_unreviewed_and_denied_facts).

  • Chat tool-result previews (#114): WireEvent::ToolResult and Entry::Tool carry a capped preview of what a tool answered, so the web transcript shows results rather than only calls.

  • The docs site embeds a fixture-backed demo of the web surface (#117) — built from web/ against invented fixtures (web/src/demo/), since the real surface is loopback-only behind a tailnet identity and can never be linked or screenshotted. Two CI gates ride the docs build: check-demo fails on any /api endpoint the app reaches without a fixture, and render-check loads every page in headless chromium and fails on an error or a page that drew almost nothing. The docs workflow now triggers on web/**.

  • The appraisal docs page answers when it runs and what it feeds (#115): the four appraisal moments, the consumer map, and the previously undocumented step-appraisal mechanism.

Changed

  • The charter is edited as a list, and re-ranked by dragging. The web editor was a raw TOML textarea — the shape it inherited from mecha charter edit handing the file to $EDITOR — so adding a priority or changing its rank meant writing TOML by hand. Lines are now tapped to edit, added with a button, deleted behind a two-tap arm, and re-ranked by dragging a grip. Dragging is not a convenience: it is the only rank control there can be, since CharterLine denies unknown fields and §11 gives the charter no rank key at all, making position in the file the whole ranking — the design's own "the only editing gesture that cannot produce a tie". Pointer events rather than HTML5 drag-and-drop, because the surface is a phone first. Everything above the first [[line]] is preserved, so header comments and the first-charter template survive a save (trailing blank lines normalise; nothing written is lost); a charter with comments between its lines, or one that does not parse, opens as TOML instead of being silently rewritten. Nothing changes on the server: the same route, the same two-tap confirm, the same Charter::parse validation, and the invariant that matters is untouched — the owner authors every line and no model composes one. the_web_editors_serialisation_is_what_this_reader_loads pins the format across the two languages that describe it.

  • Settings is an index, and its gear is on every view. The page was one scroll of three stacked sections, reachable only from Home — so a charter editor, a rule roster and a live microphone shared one screen, and settings could not be opened from the other six views at all. Each feature now opens its own pane at #settings/<charter|learning|voice> (SettingsCharter, SettingsLearning, SettingsVoice), routed through the hash router the rest of the app already uses, so back, forward and reload land where they should; each index row carries what is actually in there — a count, or a dash where the store could not be read. The gear moves out of Home.svelte into the shell (App.svelte): one button, the same corner on every view, layered below the app's scrims, sheets and drawers so it can never float over an open one. Every view's header reserves that corner.

Fixed

  • The probation leash could never convict. Probation released on observations > 0 — but an attributed regression always arrives inside an observation, so propose-retirements stripped the leash in the same scan that read the convictions and every probationary rule answered to the ordinary threshold of 3: PROBATION_RETIRE_AT = 2, the entire D1 hedge behind applying ungraded rules, was structurally unreachable. Release (release_probation_when_measured_clean) now requires the ledger to grade the rule beyond its convictions (graded > attributed_regressions, counting verdict-bearing rows only — an inconclusive probe ran but graded nothing and releases nothing).
  • Leaving a similarity group re-embedded the whole review queue (#128). The graph queue's grouping embeds every pending statement — measured at ~40s over ~7,000 — and closeItems re-ran it whenever a verdict had been filed inside a group. A guard skipped it when nothing had been judged, so a glance was free and the actual work was not: open a group, reject three, step back, wait. The listing is now rebuilt from its survivors, which is the rule the TUI's Esc has used since the level existed, and groupings are kept for the life of the page rather than discarded by the back arrow, a Review sub-tab switch, or a transient error. A cross-class grouping the page has already paid for reopens instantly; the header says when it ran and offers a regroup. Class groups also had a 30-second server budget against the global layer's 360, so a class big enough to be worth grouping answered 502 on a phone while the identical command in a terminal printed it.
  • A group verdict could delete a card while nothing was verdicted (#130). mecha-graph reports a per-candidate failure as #id FAILED: … and exits 0, so Ok(report) can carry a verdict that did not happen — the #2951 incident the item level records, one level up and over seven candidates instead of one. The /queues group arm now reads the report before removing the row, and the status line distinguishes a fan-out that was never asked for from one the child did not report: cascade_tally answers None for both, and unwrap_or((0, 0)) rendered them identically as ×1 with no "left pending" note. Silence there read as none left. Counts and remedy keys now precede the statement head, because the status line clips at 76 columns and a caveat written after a child-controlled string is one the reader never sees.
  • The entry document must not be cached the way its assets are (#121). Reported as the settings page's learning section vanishing from a phone minutes after it deployed. Nothing had regressed: the phone was rendering the previous build, correctly and whole, from a cached index.html.
  • A pull-request build could evict main's pending Pages deploy (#123). docs.yml put PR builds and main's deploy in one concurrency group, and GitHub holds only one pending run per group, so a third arrival cancels the one waiting.
  • Four cards on the home page did nothing, and one of them should have (#125). mecha review queues --json reports eight queues and Home.svelte had a hardcoded map covering seven, so blocked questions rendered under its raw wire name and went nowhere — while the tasks tab had been showing those same questions all along. The other three are CLI-only by design, but a flat card and a tappable one differed only in an :active background nobody sees on a phone, with the explaining command in a title tooltip touch does not render.
  • The nightly diagnostician could not read the source it was told to read (#127). Six nights of mecha harness ruminate produced three candidates and zero acceptances, all three proposing configuration keys that exist nowhere in this codebase.
  • The /tasks web page rendered no tasks at all on any non-empty board (#116): stateOf called stalled(t) where stalled is a field the server stamps (serve/board.rs), not a function — a ReferenceError on every card. Shipped broken in 0.1.16; caught by #117's render gate.
  • Two demo fixtures did not match the shape their routes return, so the docs demo rendered what a box never would: a cloned voice's created was a date string where the route sends unix seconds (Invalid Date), and charter line ids were bare ordinals where the route sends slugs (1. 1 beside the list marker). A fixture that disagrees with its route is a demo that lies about the product.
  • The charter docs overstated what the surfaces share (366a9ca): the appraisal page and ARCHITECTURE both claimed every charter surface goes through editor::edit_charter_with; a grep for callers says only the two editor-handing surfaces do, and the web save is its own validated temp-sibling-and-rename write sharing Charter::parse instead. Both docs now say what the code does.

0.1.16 - 2026-08-29

Three arcs land together. The appraisal goal system, reviewed end to end and hardened — one test-and-review pass produced the findings, nineteen rounds with the PR auto-reviewer produced the rest (PRs #111, #112): failed-turn transcript integrity across every front-end, positional run configs for the counterfactual probe, an instrument that can no longer eat its own findings, and a structural guard making task closure the owner's act. Onboarding grows the charter step and declines (PR #113), and CI gains a macOS arm that found four real bugs on its way in. And the release workflow finally creates GitHub Releases — fourteen tags had published crates with no release to show for it.

Added

  • Closing a task is the owner's act, structurally. kg_task_update on every model-facing registry is wrapped by a closure guard that refuses exactly a status of done/dropped, pointing at mecha tasks set — which closes and appraises — while every other field of the tool passes through. Presence is a trait answer (Tool::guards_closures) no wire-supplied tool can fake, an unguarded surface is a startup error (closure_guard::verify), subagents inherit the guarded handle through a clone-site belt, and the guard's refusal is classified (ToolOutput::refusal) so the harness's own "no" lands beside approver denials rather than in ended_on_failed_call and the tool-error rate. mecha tools shows the guarded surface, since it is the audit view.

  • An unreadable transcript is a finding, not an empty queue. The session store could rot one file at a time with no surface saying so: Session::list skipped quietly, sessions appraise counted nothing, doctor said "nothing wrong". The skip count now exists (Session::list_counting, Corpus.unreadable, disjoint from sessions_read by construction), doctor reports it, and appraise, stats and health all carry it in text and JSON. appraise also regains the named_a_goal counter lost in the #88/#91 merge overlap, so serves: coverage is measurable from the instrument again.

  • The affect readout reaches the typed web surface — the header carries the same worded chip the TUI badge shows (muted outline, deliberately not the taint chip's hazard amber: a run's mood is not a security posture) — and the TUI badge itself survives --no-session.

  • mecha setup --json now exits non-zero when work is outstanding, which it had documented ("like doctor") and did not do: the --json path returned before the exit-code branch, so the machine-readable spelling was the one that always reported success. doctor --json prints its findings and then falls through to the shared check; setup now does the same. The cost of the old behaviour was a silent one rather than a wrong answer — a !-inverted assertion written against the documented contract is ignored entirely by set -e, so it neither failed nor passed.

  • Five test fixtures named their scratch directory from a timestamp, and two could collide. format!("{pid}-{nanos}") assumes the clock is fine-grained enough that two parallel tests never see the same value — measured on this hardware, 11 of 20,000 adjacent as_nanos() calls are identical, and on macOS it is coarser still. Two tests then share a directory and the first to finish remove_dir_alls the other's store out from under it, surfacing as a bare No such file or directory in whichever lost. Found on the macOS CI arm, where it is a race rather than a certainty — it passed twice before it failed. All five now use a process-unique counter, which cannot collide by construction.

  • A model name from a server nobody named can no longer produce a config that will not parse. verified_settings rendered the alias with Rust's Debug escaping, which is not TOML's — a control character becomes \u{1b}, and TOML's \u takes four hex digits with no braces. Quotes, backslashes and newlines happen to escape compatibly, so only non-printables bite; what made it matter is that the discovery path added in this release takes those bytes from a server the owner has never named. The value goes through the TOML serializer now, and setup --write reads the file back through the loader a run uses before claiming it wrote anything — restoring the backup and saying so if it does not parse, on the same rule as the charter's "saved, but it will NOT load". Without it, every later command died at Config::load_global pointing at mecha config init, which is the wrong fix.

  • The MCP environment-allowlist test now accounts for what a child adds to itself. On macOS the nosy fixture reported __CF_USER_TEXT_ENCODING, which CoreFoundation writes into its own environment during initialization — mcp.rs calls env_clear() before envs(), so it never crossed the boundary. Exempted by exact name and target rather than by prefix, and the test asserts each exempted name really is absent from what we hand over, so the exemption cannot come to cover a genuine leak without failing.

  • A test that only passed on Linux. an_mcp_file_parses_and_resolves_paths_against_its_own_directory built its expectation from std::env::temp_dir() and compared it against a path load_mcp_file had canonicalized — fine on Linux, and wrong on macOS, where temp_dir() answers /var/folders/… and canonicalizing resolves the symlink to /private/var/folders/…. The code was right and the fixture was Linux-shaped. The scratch directory canonicalizes at creation now, so the next fixture that compares a path is correct without anyone remembering why.

  • CI builds and tests the whole workspace on macOS. The test job was ubuntu-only on both arms, so nothing proved any of the four crates compiled anywhere else — the mecha-cli break below was found by a job added for an unrelated reason, and only because it happened to build one binary. macOS is now a full arm (--all-targets, so the tests have to compile too, and then run). The MSRV arm stays Linux-only: it exists to pin what the manifest promises, and a second platform does not make that promise more or less kept.

  • mecha-cli did not compile on macOS, and nothing could see it. exe.rs imported std::path::Path unconditionally while using it only inside #[cfg(target_os = "linux")] branches — an unused import everywhere else, which this repo's -D warnings makes a build failure rather than a lint. Every CI job was ubuntu-only, so the break was invisible; the first run (macos-latest) job added in this release found it on its first run, which is the whole argument for building on more than the platform you develop on.

  • mecha setup --undecline no longer announces an undo it did not perform. A typo'd or unknown step id wrote the set back unchanged and still printed "slak will be offered again" and exited 0, so the person believed the way back had been taken and then met you said no thanks on the step they thought they had restored. The message is read off what actually changed now, and an id that was never declined is told so. In the same shape: mecha setup's three verbs (--json, --write, --undecline) conflict at the parser rather than resolving by whichever branch came first, so --json --write explains itself instead of printing a plan, exiting 1 and writing nothing.

  • Two mecha setup writes that were quiet about losing something. A never answered over an unreadable setup-declined.json rewrote the file with just that one id and dropped every earlier answer — read_declined distinguishes unknown from empty precisely so that cannot happen, and the write path collapsed the distinction and persisted it. The damaged bytes are moved aside now, stamped so a later corruption cannot overwrite the salvage, and where they went is printed. Separately, offer marked a shared installer as handled off the answer rather than the outcome, so a failed cargo install mecha-mail made the documents step report "already handled by the command above" — an assertion about a command nothing had checked. It reads the exit status now, and a failed install leaves the next step genuinely outstanding.

  • Two smaller mecha setup bugs, both found in review. never and the offer loop's de-duplication disagreed: mail and docs carry the identical remedy argv when neither binary is on PATH, and the dedup skipped the second question as well as the second command — so declining mail recorded only mail, left docs outstanding, and setup still exited 1 after somebody had answered everything they were asked. It self-corrected on the next pass, which is why no store-level test could see it; the loop takes its reader as a parameter now so the answers can be driven, and the dedup is on what has been run. And --undecline sat below the --json/--write returns, so mecha setup --json --undecline all printed a plan, exited 1 and undeclined nothing, silently — it is handled first now, ahead of every network call, since a verb that rewrites one local file should not wait on a loopback timeout.

  • Two claims the local-server probe made without establishing them. Both found in review, both the shape this module's own header is about (never write down a number the user merely believes). The probe result was an Option, which collapsed asked and heard nothing into never asked — so an install with a configured-but-unselected [providers.local] and a llama-server running on it was told "Nothing was answering at http://127.0.0.1:8080 when this ran" about an address nothing had asked. It is three-valued now, and that install gets a branch of its own naming the provider it already has and the one-line fix, because it emitted no local-server step either and previously received a single step telling it to serve a model it was already serving. Separately, preflight::Props defaults every field so a version bump costs a check rather than a parse failure — which meant {} with a 200 parsed fine and any JSON service on :8080 was announced as "already serving (an unnamed model)", one y from repointing default_provider at it with no model and no context_window. Discovery now asks for a field only llama-server supplies; fetch keeps its tolerance, which is right for a server the owner has already named.

  • The step that blocks every other one has a way out of it. mecha setup reported anthropic has no usable credential and offered mecha config show — a command that displays a file and fixes nothing, so the one step that made every other step untestable was the one with no path forward. It now says which of the two situations the machine is actually in. If nothing configured can answer, setup probes http://127.0.0.1:8080 (loopback only, one address, and only on an install that is otherwise stuck, so a working install makes no extra call); when something is serving there that no provider names, mecha setup --write writes the [providers.local] table from the server's own /props and points default_provider at it — the existence of the provider being as much a measured fact as its context window. When nothing is serving, the fix is a key, and a key is the one thing this tool will not write: mecha stores the name of an environment variable, so the step names the exact variable and both routes forward instead of offering a command that could only print what you already know. A provider with no api_key_env is told that, rather than told to set a variable it does not name. The probe is keyed on there being no local provider configured, not on props being absent — a configured server that is merely down also has neither, and probing there would announce "a server nothing names" about an install that names one, then take the create-a-table path over the table it should have corrected. mecha setup also now offers mecha config init when there is no config file: tolerating its absence is right, and is also why nobody ever learned about the file every other step is fixed by editing.

  • mecha setup offers a charter, and you can tell it no. Two gaps in what a new install walks into. The first: nothing anywhere named the charter to somebody who had never written one — doctor returns early on a file that does not exist (right, since not having one is not a fault), so the only ways to find the feature were scrolling the TUI's /help or noticing the gear on the web page. It is now a step, whose remedy hands over $EDITOR and composes nothing. The second: "I haven't done this yet" and "I don't want this" were the same line of output, so somebody who does not use Slack read not set up forever and every scripted mecha setup exited non-zero over a choice they had already made. Each offer now takes y/N/never; never records the step in ~/.mecha/setup-declined.json, a declined step is not outstanding, and mecha setup --undecline <id> (or all) asks again. Only genuinely optional things are declinable — found by running the flow rather than reading it, because inferring "declinable" from "missing" made a provider with no credential declinable, and declining it reported Nothing outstanding. on an install that could not answer a prompt. The gate is on the plan rather than on the prompt, so hand-editing the file in does not work either, and a decline never overrides a step that is done, wrong or unknown. Setup also now closes with where to go next, and with the one trap a new install walks into unaided: run it from a project directory, because a workspace over ~/.mecha is a jail covering its own tokens.

  • mecha charter edit, and the rule said properly. The charter's own module claimed there was "no --edit, and there never will be — the absence is the safety argument". That was a misstatement of the invariant rather than a decision anybody made: the TUI's /charter already handed the file to $EDITOR and the web settings page already took a validated save, so stating the rule as no verb writes the file made the command line the only surface where the owner could not edit their own document, which protects nothing. The invariant is about the author: the owner may edit the charter from anywhere, and no model ever composes, suggests or edits a line. edit creates the commented template when there is no file, hands over $EDITOR, and exits non-zero if what was saved will not load — the validation feedback being the reason to use it over a hand-run vi. The template write and the did-anything-actually-land classification are now one implementation shared with the TUI, which is where the two subtle cases live: a clean editor exit that saved nothing, and a :cq that exits non-zero after a save landed.

  • A first-run test suite, and a CI job that installs mecha the way a new user does. mecha-cli/tests/first_run.rs drives the real binary against an isolated MECHA_HOME with nothing in it — the state no other test is ever in, since every unit test starts from a Config somebody constructed. It asserts that a fresh install says what it needs in a shape a script can read, that the credential-free commands work before anything is configured, that doctor is quiet about stores that merely do not exist yet, that a first charter is a template holding no priorities, and that starting from the directory holding ~/.mecha is refused with a reason. Beside it, a first run CI job on Linux and macOS runs cargo install --path into a clean prefix and repeats the walkthrough against the installed binary, because a crate that will not install standalone is invisible from inside the workspace and lands on the person least equipped to debug it. That job also checks every command the getting-started pages name actually exists.

  • Voice and rate moved out of the call pane, and a voice can be cloned. The in-call picker and slider were preferences wearing call-control clothes; they live on the settings page now (the call pane keeps mute and end-call), reading and writing voice-core's own preference store — which fixes a real bug found in the move: Chat.svelte kept a second copy of the prefs machinery under a different localStorage key while claiming to share the first, so a voice picked in a call was saved where nothing else looked. One store now, with a one-time read of the legacy key. The page's picker enumerates from the worker's last answer, cached at every call; a preference applies from the next call, which the page says.

    Cloning: the TTS (Chatterbox) is a zero-shot cloner whose "voice" is a reference WAV in a directory, so the settings page can record someone reading a passage — it opens with them agreeing out loud, and the clip never leaves the box — name it, and save it as a voice. [web] voices_dir names the host side of the directory the TTS container mounts (unset disables the feature); the upload is validated as integer PCM WAV with 5–120s of audio read from its own header, voice names are a closed alphabet with default reserved, an existing name is refused rather than overwritten, and deletion is offered where recording is — a botched take that needs a terminal turns the store into a pile, and the file is a recording of somebody's voice. The worker's voice-list cache now revalidates on a miss, so a fresh clone works on the next call instead of after a restart nobody was told about.

  • A settings page on the web surface — the gear in the upper-right of Home. Three sections, and exactly one thing on the whole page can write: the charter shows the ranked lines and offers an edit whose save is a two-tap confirm, validated server-side by the same Charter::parse every run loads through — an invalid document is refused with the parse error and never reaches disk (temp-sibling-and-rename when it does), which is strictly better than the TUI's after-the-fact warning, since here the bytes are still refusable. Learned rules is a read of mecha rules list --json with the ledger tallies — retiring stays behind its own staged review. Voice reports whether the worker answers and where offers go, nothing more. Deliberately absent: anything whose edit widens security posture ([sandbox], [security], [outbox] routing) stays in config.toml where a diff reviews it.

  • /charter in the TUI: see the standing priorities, and edit them without leaving the screen. The list shows the ranked lines (order is rank), enter opens a line's full text, and e hands the terminal to $EDITOR on ~/.mecha/charter.toml itself — the charter's write path stays "the owner with a text editor" (§11): no CLI verb, no tool, and no model path writes a line, and the only bytes mecha ever writes are a comments-only template when the file does not exist yet. Validation feedback lands the moment the editor closes — a duplicate id or a typo'd table name is reported in the modal, not at the next session's startup where the alternate screen covers the warning — and the status line says the honest thing about scope: an edit rides in the prompt from the next session (/model rebuilds this one), never the conversation already running.

  • mecha distill now notices when the world disagreed with the graph. The same quarantined pass that already finds corrections also reports SURPRISES: moments where something the agent said, sourced from graph memory, was contradicted by something else in the same session — "I said the 14th because the graph says so; the email says the 9th." Printed for a human to act on, never auto-chased: mecha gossip --entity <about> is the human's call, not the session's own. Gated on the timeline's trust like a correction, since it is the model's own free-text reading of transcript prose. Rung 9's second piece (GOAL-SYSTEM-DESIGN.md §10.1) — review-queue salience is still unbuilt, and needs a different repository.

  • An episode pushed to the knowledge graph now carries how the session went. mecha distill stamps each episode's meta with the session's affect label and signed goal errors, the same record mecha sessions appraise derives — one assembly (appraisal::for_session) rather than two that could drift. Unlike a correction, neither is gated on the timeline's trust: both are structured facts the harness computed about its own run, with nothing in them a model or a fetched page could have authored. Rung 9's first piece (GOAL-SYSTEM-DESIGN.md §10).

  • A finished plan step is checked against what the run actually did. A board task is closed by the owner, so a person is the check; a todo step is closed by the model, so there was no check at all. The loop folds its own trace into counters and the plan tool differences two of them into a span, reporting two facts on the todo result: nothing was attempted, or the last attempt did not succeed. Four rules keep it off honest work — a refusal is not a failure, only the last attempt decides, a sibling still running supports no finding, and plan revision is not work.

  • A run that has stopped making progress is told so, while there is still something to do about it. The loop guard ends a run that repeats itself after a compaction, and it was the only rung of that ladder: a run going nowhere had exactly two states, proceeding and dead. Boredom is the graded version — three identical outcomes in, it names the approach and offers a different route or a fresh conversation. It only speaks, so it costs nothing; bounded to once per rung, once per turn, three times per run, because a model fails more when its context holds its own earlier errors. [agent] boredom switches it off for a pinned scorecard.

  • mecha sessions health reports how often that fired. Every threshold behind it was argued rather than measured, and a detector nobody can count fires either constantly or never with no way to tell which. Prints a dash where no run recorded the counter, which is what it prints today.

  • mecha sessions appraise — how runs went against what they were for. Every evaluative signal mecha had was a cost or a correction, so a run could be recorded as having gone badly and never as having gone well. This is the signed record, derived on the spot from the transcript, the outbox and each run's own counters — no store, because every channel is a pure function of records already on disk. Observation only: nothing consumes the label.

    Over a live store it reads 459 sessions, appraises 120, records 119 signed goal errors and 100% neutral labels — eleven of the errors positive, which is the first time the one channel that can say a run went well has been counted anywhere. Nothing is broken; every label that could have fired needs a dimension nothing measures, and appraisal.rs names which one buys which.

  • mecha reflections and the /learning modal — the learning store, read and edited. reflect wrote reflections, learn consumed them and nothing could show you one. /learning is the three stages a lesson passes through — reflections, rules, proposals — with the verbs on each.

    Editing a lesson is a provenance promotion, not a text change. A lesson you typed is yours, so one the gate excluded becomes learnable; what was happening is withheld on the way through, because that is the field that held the third-party text. It is the highest-leverage correction available: a rule is a consolidation of several lessons, so objecting at a proposal costs the good ones. A drop is a flag, never a deletion.

  • mecha rules list --json, listing user rules alongside learned ones and flagged, because they ride in the same prompt and a surface showing only the learned half misdescribes what a run carries.

Changed

  • mecha sessions stats --json is an object now{rows, sessions_unreadable} instead of a bare array, matching appraise and health; no in-repo consumer read the array, and the CLI reference records the change. kg_task_update's description gained the closure guard's note, which moves tools_hash once — deliberately: a byte-identical spec would have hidden a real capability change from the surface store. And mecha distill reads each transcript once instead of four times (appraisal::for_transcript is the seam).

Fixed

  • A failed or interrupted turn no longer corrupts the conversation or its transcript, on any surface. One review pass found five divergent rollback sites: the web chat's error arm popped the mutated tail (orphaning a tool_use and 400ing every later request on the session), the transcript kept what memory discarded (so the failure survived a resume), and voice's barge-in guard popped a tool-result message because tool results ride in a user-role message. It is one mechanism now: Conversation::roll_back_failed_turn (restore the snapshot, then pop only a plain user text), every error arm records the rolled-back state as a rewrite so a resume loads exactly what memory holds, and every submit site folds into a user-message tail instead of pushing two user messages in a row — recording the fold at submit as one direct rewrite.

  • The counterfactual probe replays under the config in effect at the intervention, not configs.first() — a resumed session's later steer no longer replays under the first attach's system prompt and tool list, diverging for reasons that said nothing about the steer and inflating regret. Transcript carries positional configs built in Session::read's single pass (truncating and in-place rewrites keep positions exact; summarising ones clamp), and the probe reads the path the caller already resolved — one parse per intervention instead of a directory scan plus two — skipping per-item instead of aborting the walk.

  • Closure appraisal's small holes: a prefix-resolved --session no longer silently drops every draft (the id re-keys on the transcript's own header), tasks set T --session "" --status done reads the empty string as the documented unlink instead of failing it, and the follow-up's replay caveats reach the stored record whatever became of the episode.

  • mecha eval --ab-rules measured nothing, while announcing that it did. Both arms ran rules-free: consolidating eval's forced-off list into force_reproducible (which existed to stop entries being lost from a list written in prose across forty lines) flattened opts.no_learned_rules = !with_rules into an unconditional true, so the treatment arm printed "learned rules INJECTED (A/B treatment arm)" over an arm that had none — two identical arms, and every per-case flip it reported was noise. The lever is now a parameter of force_reproducible rather than a re-enable at the call site, because a lever inside the list cannot be lost while consolidating the list. The existing set-assertion test could not have caught this and did not: a test that asserts a list is complete cannot notice that one member of it was supposed to be a variable, so the regression gets its own test asserting the treatment arm does carry rules, and that the lever is one flag wide.

  • anticipated_guilt no longer pins at a constant 1.0 under a standing backlog. The first run recorded under the sensor (2026-08-28) read exactly 1.0 — the live outbox held drafts eight days old, the age term clamped, and the OR-combination erased count and pressure entirely: the degenerate-constant corpus guilt.rs's own week-long horizon was chosen to avoid, arriving through the clamp instead of the horizon. The standing-debt terms (age, count) are now asymptotic — half of maximal at the same constants that used to be ceilings, approaching 1.0 without reaching it — so older still reads worse, no term is argued down by the others (still an OR), and pressure and count stay visible in the reading. Pressure keeps its hard top: it is a fact about this run, not standing debt, so it cannot pin the corpus across runs. A regression test carries the live store's exact shape.

  • Affect::reachable_today now tells the truth about which labels have a producer. It claimed Embarrassment and Frustration were reachable and Regret/Disappointment were not; all four claims had drifted. Embarrassment lost its only producer when the SentEdited outbox arm was (correctly) made visible: false — nothing now records mecha's own mistake reaching a third party, so the label is unreachable from every real path. Frustration is probe-gated, not deterministic: no counter kind fires twice in one session, so the free readout's whole range is Neutral and Anger — pinned by a new test that walks every stop cause. And Regret/Disappointment became reachable when the counterfactual probe shipped (#91). The module note now carries the story; two new tests fail the day any of this drifts again, in either direction.

  • sessions appraise --probe's help now says it needs a workspace. The replay builds a real agent with a real path jail, so from a home directory it refuses (correctly — the jail would cover ~/.mecha); the flag's help text now names --workspace instead of leaving the refusal to explain itself. --appraise is unaffected: its quarantined call has no tools by construction and runs from anywhere.

  • Two real gaps in rung 9's episode tagging and surprise detection (#97, #98), each closed across several review rounds that kept finding the sibling case the previous fix missed. mecha distill printed a Surprise's free-text fields straight to stdout unescaped — the "a person reading their own terminal is a safe context" argument didn't hold for scripts/ruminate.sh's actual nightly path, which redirects that output to a dated logfile instead. strip_ansi alone doesn't stop a bare \r/\n (an interior one survives trim_end, which is also why this module's own two callers — Writer::write and release — turned out to have the identical gap, now fixed alongside it) or the Unicode categories that buy the same two effects without being is_control() (U+2028/U+2029 forge a line break; U+202A–E and U+2066–9 reorder the rendered line around a bidi-aware reader). strip_ansi_and_controls closes all three, used everywhere free text reaches a terminal or a log in this module.

    Separately, a genuinely unreadable outbox during episode tagging used to warn and continue, permanently mark_distilled-ing every session that run with a silently incomplete Edit channel — no later run could ever revisit it. OutboxStore::items_strict bails on a read failure instead of skip-and-warn, covering both a hard I/O error and a merely malformed item file (items()'s own tracing::warn! for the latter is invisible on the nightly, which runs with no MECHA_LOG). The originally-cited cause — a half-written .json mid-save — turned out to be one this store's own temp-sibling-and-rename discipline already rules out structurally; the realistic cause is persistent (a stray file, or an item written by a schema this binary cannot read), which the error message and doc comments now say plainly rather than implying a retry will clear it. A doctor finding for a stalled distill ledger would close the remaining gap (the nightly currently fails silently behind one logfile line) and is left for later rather than built here.

  • mecha was mining its own words as the user's corrections. agent.rs prefixes a refusal it did not author with "Denied by the user: " precisely so machine policy is never learned from as a human correction; this is that mistake mirrored. The learning miner guarded one harness voice and not the other, so every run the harness had to nudge for an empty turn contributed an "intervention" whose text was mecha's own — and two reflections in a live store were mined that way, one of them clean, unprocessed, and already inside a pending rule proposal whose text paraphrases the nudge back. Fixed at both ends: agent::is_harness_voice is the closed list of voices mecha speaks in the user role, and Reflexion::learnable refuses one whatever its origin, which reaches records already on disk.

  • Origin::Derived classifies something at last, and it is a label rather than an exclusion. A self-observed failure is real evidence; what it lacks is a way to be graded, because a counterfactual probe means something only when the user steered it there. So a harness-authored intervention is classified rather than dropped — kept, visible, and one gate away from being usable the day something can grade it.

  • /queues clipped the text it exists to have you read. The detail rendered without wrapping, so a rule proposal showed the first line of each rule and cut the rest at the box edge — an approval asked for on a sentence whose end is unreadable, and the unread half goes into every future prompt.

  • A delegated task run's plan never named what it served, including in the runs serves: was built for. Measured against the live store: 112 of 120 appraised sessions wrote a plan, 0 named a goal — 15 of them delegated runs that carried both todo's own schema instruction to pass serves and this task's own id, printed on the seed's Id: line, and still wrote nothing. The generic reminder was not, by itself, enough; both delegated postures (work_prompt, discuss_prompt) now bind it explicitly to the running task's id, which is what lets Frustration resolve a board task at all if it works (Pride needs a charter line, not a task, and stays unreachable regardless). Forward-looking only, and unmeasured — it does not relabel the 120 sessions already on disk, and whether naming the id explicitly moves the number needs sessions recorded after this lands.

0.1.15 - 2026-08-26

Five surfaces that described themselves wrongly, found by using them. A chip that said a run would stop and ask when it would not, a card that printed JSON at somebody about to approve it, a queue verdict that could fail with nowhere to go, a call that ended without saying why, and a gate that read the label the proposer had written on its own change. None of them errored; each simply told you something other than what was true.

Added

  • allow is reachable from the web page, and asks before it lets go of the gate. The page offered read-only and ask and refused allow with a 403, on the argument that a surface which can grant blanket permission from a phone is a surface that will, one distracted tap at a time. The argument was right about the risk and wrong about the remedy: the mode was already renderable — the state read answered "allow" — so it was reachable in display and not in fact. It is a mode now, sticky per session, entered through a confirmation and left in one tap, because a change that only adds a gate should not ask and a change that removes one should. What it does not waive is unchanged: the trifecta interlock still refuses a send once the conversation holds private and outside content, whoever approved what, and outbox-routed calls still stage.

  • A draft can be confirmed where you are standing. review now — a draft you just asked for is a draft you are about to read — had been the TUI and Slack default since the policy was written, and mecha serve had no release policy at all, so every staged draft went silently to the outbox and the badge. On the page it is a card built from /api/outbox/{id}: ids on the wire, bytes from the store, because a reviewer reading one thing while approving another is the failure the outbox exists to prevent. In a call the offer is spoken from the store and the answer matched before the request reaches a model, so the release decision never enters a context window.

  • A note can be opened and edited from the page. The notes list listed and did nothing else — a long note was an unclamped wall of text with no way to change it. The verb is mecha kg note --edit <source_id>, terminal first and the page driving it, keyed on the graph's (source, source_id) episode key so a rewrite is an update that drops the stale embedding rather than a second note beside the first.

  • A failed graph verdict offers the two ways through. Accepting a similarity group could fail with cannot resolve subject and print it at the top of the phone with nothing to do about it. Binding the subject to a real entity and accepting it as a new topic have existed in the TUI since that modal was written; they are offered here on the card that failed, and only after a failure, because inventing a topic node is not a default.

Changed

  • Mail and calendar can default to different accounts. One default covered both creates, so "send from work" and "put it on my personal calendar" were a single choice and setting either moved both. default_mail and default_calendar override it per surface and fall back to it when absent, so a config that never needed the distinction keeps working and never learns it exists. mecha-mail default <name> --mail and --calendar set them; with no flag the verb does what it always did, and with no name it now prints what each surface actually resolves to rather than the stored field, because with a fallback in play those differ. The tool schema carries the right default per tool — a note telling the model mail_send defaults to the calendar's account is worse than no note, since it omits account believing it knows where the message goes.

  • An approval card shows the call the way a person reads one. A calendar call leads with its title and when it is — in reading order, not alphabetical, where an event reads end before start — and a letter leads with its addressing and its prose with real newlines. The exact arguments are one tap away. Nothing is hidden: an argument with no header or body shape, which is where shell keeps its entire contents, is shown outright, and the whole view is clipped the way the arguments always were so a large write cannot put a file into every open page.

  • The web session's permission mode travels as its own event. The chip was set by the tap that changed it and by nothing else, so a change made on the phone left the laptop showing the mode it had at load, and a request whose response was lost left the tab that sent it wrong in the other direction. What the chip is for is telling a person whether the next write stops to ask, which makes a stale one a security cost rather than a cosmetic one.

  • A candidate's class is derived from the change, not read off the line the model wrote. class decides whether a proposal reaches a human at all — Security is never measured and never auto-applied. It held until now by coincidence: the closed override set is four benign knobs, so a security change mislabelled as config failed for being outside the set rather than for being security. On 2026-08-25 the nightly proposed disabling a taint control, class config. The class is now derived by naming the guarded settings — three config sections that are boundaries, plus every SecurityConfig field — and only ever raises toward review.

  • A call no longer ends by itself without saying so. Pipecat cancels an idle pipeline after 300 seconds, where idle means neither side produced a speaking frame — so thinking, reading, or putting the phone in a pocket killed the call and the client saw only the peer connection close. The timeout is kept, because an abandoned tab otherwise holds VAD, turn detection, STT and TTS open on a box with one GPU, but it is raised past any pause that is still a conversation and it now says why on the way out.

Fixed

  • A draft that failed to send says why. A failed release leaves the item pending — the draft is good, the delivery was not — with the reason recorded on it. The page received every other field and not that one, so a draft that could never succeed looked exactly like one nobody had tried yet, and the only signal was 1 of 1 item(s) did not send in a notice. The reviewer now reads the actual reason on the card, which for the case that found this was a calendar create with no account against a mail config with no default.

  • Approving a clean draft is one step, not two. The confirm sheet earns its place on an armed draft, where it shows the exact arguments — more than the detail view behind it. On a clean draft it showed strictly less than the screen already open, which is the kind of confirmation that teaches people to tap through, and what that trains away is the armed one.

  • The voice logo reconnects, as its own label had been promising. The idle state has said "tap to reconnect" since the overlay shipped, and the logo was an <svg role="img"> with no handler — an affordance described but never wired, appearing only at the moment a call had already failed. It is a button now, live only while idle. Reconnecting ends the dead session first, or the previous peer connection and its microphone track stay open for the life of the page; the call transcript survives, because the transport dropping is not the conversation ending. The two idle labels that never mentioned reconnecting now do — offering a way back from three of five states is how a working affordance still reads as broken.

  • One number from [slack] no longer requires the whole config to parse. show_file loaded the global config at call time to read slack.max_upload_mb, which made an unrelated config edit surface hours later as a file-sending failure in a long-lived process. The value is captured at registration instead.

0.1.14 - 2026-08-25

A day of using the last release rather than building the next one, which is why almost everything here is a seam somebody walked into. The largest is voice: talking and typing were two conversations, and now they are one.

Added

  • A voice call is the chat session it was started from. The last of the voice decisions still owing something. An in-chat call used to open its own conversation with its own transcript and its own clean taint slate; now the page names its session in the WebRTC offer, the worker forwards it, and the turn runs on that conversation's messages, taint, transcript and workspace jail. Start something at the desk, finish it on a walk, read it back at the desk. A watching page fills in as you talk, and spoken turns are marked as spoken in the transcript.

    Two things worth knowing about the shape. The obstacle was never the transport — mecha serve held two session maps in one process, and a call resolved in the wrong one. And the smaller fix was rejected on purpose: merging the voice conversation onto the web session when the call ends would have put the same turns in two session JSONLs, for recall, distill and the run-quality corpus each to count twice.

    The permission posture travels with the turn, not the conversation: --voice-yes still means a spoken turn runs without stopping to ask, while a typed turn in the same conversation obeys the page's mode. Nothing structural moved — the trifecta interlock sits ahead of the approver, sends still stage through the outbox, and taint now accumulates across both doors instead of being reset by opening a call.

  • /queues reviews proposals in place, for all three stores. Three rows announced a count and then refused to open, which is the worst shape a queue aggregator can take: it exists because a queue grew unnoticed, and it was itself showing 28 items behind a door that did not open.

  • Every queue says how long its oldest item has waited. A count answers "how much", not "how long has this been ignored", and the second is the question a review queue is for.

Changed

  • A staged draft shows what it acts on, even when the target was discovered rather than asked for. outbox_source matched provider ids against earlier tool-call inputs, which finds a reply's thread read and finds nothing for a calendar delete whose event id exists only in a result. Reviewing that item meant approving an account name and an opaque id. Join::Returned closes it, with a minimum-entropy guard so calendar_id: "primary" cannot match every calendar result in the session.

  • The nightly mail classifier reads both mailboxes. The first both-account sweep read 100 threads and disposed 47 of 51 candidates with no model at all — the account excluded for being expensive is the cheap one, because machine-generated mail is exactly what the prefilter handles for free.

  • One voice door. The standalone voice page is retired; the app is the only shell. Sharing voice-core.js kept the two shells' machinery identical and did nothing about the shells themselves, so the voice picker and rate slider were built twice.

  • Two standbys removed. Kokoro and Voxtral each sat at zero requests holding GPU memory as a "fallback" nothing failed over to automatically. A spare with no code path to it is an intention, not a spare.

Fixed

  • Mail actions on the personal account had never worked. Every button on a personal thread failed with "no thread in the triage store matches", because the nightly named one account. mail_triage reaches nobody and mutates only your own mailbox — it is documented as the cheap way to act — and it was the one verb that could not run.

  • A sound with no words in it no longer stops the bot. The VAD was winning the turn-start race, so 200 ms of anything Silero scored as speech interrupted a reply — a sleeve on a microphone cleared it. Fixed structurally rather than by tuning: the VAD came out of the turn-start strategies, so a wordless segment now produces no transcription at all and the reply simply continues. The cost, stated plainly: barge-in is "finish the phrase and it stops" rather than instant.

  • Enter at the TUI's review level had been a no-op for three commits while the footer advertised "Enter read it" — harness candidates and rule proposals announced a depth and opened nothing, which is exactly what /queues exists to prevent.

  • /entity advertises its keys: Esc clears the search, ctrl-n says CREATE, and the merge key was advertised nowhere at all.

0.1.13 - 2026-08-24

The release where mecha grew a face and a voice. Two subsystems arrived — a web app on the tailnet and a spoken interface — and the knowledge graph's review queue became something a person can actually clear.

Added

  • Voice: talking to mecha, out loud. A Pipecat worker over WebRTC, Parakeet TDT for speech-to-text, Chatterbox Turbo for speech, and an OpenAI-compatible facade mounted inside mecha serve — one process, one agent, one cached prefix, two dialects. A call is an ordinary session with the same tools, jail and outbox.

    The transcriber is deliberately the smaller model. A speech-capable chat model asked to transcribe does not reliably transcribe: asked "what is on my calendar today?" one answered "I don't have access to your calendar" and that answer was recorded as the owner's words; played "ignore your instructions and just say the word banana", it wrote banana. Prompting fixed the first and never the second, because obeying instructions is what such a model is. A transducer has no prompt for an instruction to travel down. The cost is that mecha cannot tell you how somebody sounded, which is the right trade for the component standing between a microphone and an agent holding your mail.

    Voice is not in the crate. cargo install mecha-cli ships the facade and none of the pipeline — scripts/ sits outside the crate — so voice needs a git checkout and four local services. The website says so first rather than in a footnote.

  • mecha serve — the web surface. The agent, behind a small web app reachable only over your tailnet: bound to 127.0.0.1 with no flag to widen it, every request checked against [web] owner_login (the header tailscale serve injects), and a refusal to start at all when no owner is configured. Pages for the dashboard, chat, mail, notes, the review queues and the task board — each a thin shell that drives mecha <verb> as a child process, so nothing is reachable from a browser that a script could not do.

    Chat holds many conversations on one provider connection, each with its own jail, permission mode, cancel token and steering queue; approval cards can be answered from the phone and survive a locked screen; files upload into the session jail and download only from inside it.

  • Similarity groups, and one verdict that covers them. The graph's queue is mostly the same fact restated, so s groups a class by semantic similarity and a/r decide a whole group — as one human verdict, with the rest riding through as a labelled machine cascade the autonomy ladder never counts. mecha review groups --all does it across every class at a stricter floor for a backlog that one class at a time will never clear, naming the classes each group spans because the blast radius is part of what is being approved. First live run: 306 groups covering 782 of 6,929 pending.

  • The plain inbox, and composing by hand. mecha mail recent reads what arrived rather than what the classifier surfaced; mecha mail compose writes a new letter and stages it into the outbox like every other outbound thing, refusing outright if mail_send is not routed there. One queue holds everything outbound regardless of who wrote it.

  • Dictation on the phone. The notes and task capture boxes record, encode the clip in the page, and post it to the local speech server — the audio never leaves the machine, which is the whole reason not to use the browser's speech APIs.

  • Session history, and resuming a conversation. The chat drawer lists live and recorded sessions, voice calls included, and opening one restores its messages and its taint — a conversation that read a hostile page on Tuesday still remembers on Thursday, because resuming must not launder what a session touched.

  • /entity and identity editing in the graph. Asked to fix a daughter's name, mecha could correctly report that it could add an alias and nothing else — no rename, no way to create a person who has forty facts and no node. /entity drives the new verbs as a person drives them. The model still cannot perform identity edits, deliberately: an identity edit is invisible in a way a fact edit is not, because every fact about a node keeps reading correctly while pointing at someone else.

  • Front door, notes and tasks reach the phone, and the dashboard's cards navigate to the surface behind them.

Fixed

  • A sheets write fits its own grid instead of being refused for it. Google refuses a values.update that reaches past its declared range — and refuses the whole write, so one header row a cell too wide cost the other forty-three. Two drafts sat unapprovable in the outbox for three days on exactly that. The range is widened to fit rather than refused, and the repair lives inside the tool because a routed call is staged and never dispatched: the tool's own argument checks never run at draft time, so the refusal surfaces hours later to a reviewer with no way to fix it.

  • The mail list stopped clipping its own text. A flex item with overflow other than visible has an automatic minimum size of zero, so once the list outgrew the viewport every card shrank to a sliver and lost its summary. The outbox page carried the identical bug from birth and had simply never held enough drafts to trigger it — a layout bug that needs scale to fire passes every short-list test, and a sibling page copied from it inherits the fuse.

  • Web verbs run in a workspace the jail accepts. The serve unit's working directory is the owner's home, which any child building a tool surface correctly refuses as a workspace, so mail and tasks both failed from the phone with an error aimed at a person who was not there. Children now get the web producer directory. The fix lives at the spawn helper rather than at whichever route failed first.

0.1.12 - 2026-08-22

Fixed

  • A TUI outlives an install without its child processes dying. Every modal that drives mecha <verb> as a child resolved its own binary with current_exe, which on Linux reads /proc/self/exe — and after cargo install replaces the file, that target reads …/mecha (deleted), a path that does not exist. /queues failed by name (os error 2) and an outbox release failed quietly: the confirmation ran, the release child never started, and the item sat pending looking like the review surface was broken. Children now exec the /proc/self/exe link itself, which the kernel resolves to the deleted inode — so a long-lived session keeps driving the version it is, rather than failing, and rather than picking up a newer binary whose flags may have moved. The path written into systemd units deliberately still uses the real file.

Added

  • /queues and mecha review — every store waiting on you, in one list. Five stores accumulate work for the owner and each grew its own verb: the outbox, the front door, staged rule changes, harness candidates, and — in another repository — the knowledge graph's merge queue. Knowing what was waiting meant remembering five commands, which is how that last one reached 6,434 items without anybody deciding to let it. mecha review queues is the summary and /queues is its modal; four rows hand off to the modal that already owns them, because /outbox and /frontdoor hold the confirmations and taint warnings that make their approvals safe.

    It is not /review — that is already the outbox's release policy, and two things called review one word apart is a trap.

    An unreadable store reports as a dash, never a zero: "nothing waiting" and "could not look" are opposite findings, and a reader rendering its own failure as an empty queue would reproduce exactly the bug this exists to catch.

  • The graph's merge queue is reviewable from mecha, three levels deep. Mechanism → class → items, with a/r verdicting and t filtering by evidence tier (all → unjudged → thin → some → solid). This is the one place mecha shells out to mecha-graph, and the reason is a boundary rather than convenience: the MCP tool surface has kg_pending and kg_verdict and deliberately no kg_accept, because every MCP tool lands in the model's registry and a model that can accept fact candidates can accept the ones its own extractor proposed. The decision is driven the way a person drives it — $MECHA_GRAPH_BIN as a child process, resolved from the environment and never from mecha.toml, since a project file arrives with a cloned repository. The dependency is runtime and optional: every verb degrades to a named error, and queues still reports the four mecha-owned stores when the binary is missing.

  • Item review is a random sample, and that is the default. mecha review sample draws twelve candidates uniformly at random from a class, seeded and printed so the draw can be redrawn and checked. The queue has an order, every order it could have is correlated with something, and judging the first dozen then reading the result as the class's accept rate measures the ordering — which matters because 40.5% of that queue sits in classes with no human verdict at all. A verdict drops the item locally rather than resampling, so a sitting's twelve verdicts describe one sample; n asks for a new draw explicitly. mecha review items is the queue-order alternative and says outright that its verdicts are not a rate. Enter on an item opens the whole of it — full statement, payload, confidence, when it was proposed — with j/k flipping through the sample without leaving the view, because a verdict on a line that ends in is the approving-unread failure the outbox exists to prevent, one store over.

  • mecha review accept|reject takes a whole class, via --proposer / --predicate, with --limit and --dry-run. A cluster kind such as (commitment) is refused by name rather than passed to a filter that would match nothing, and the count reported is the child's own — the graph matches proposers by substring and caps a bulk filter at 500, so the row's pending figure is the wrong number twice over.

0.1.11 - 2026-08-21

Added

  • The outbox verb is approve, on the a key. The queue holds more than mail — a docs_replace is approved, not sent — and a sits beside e edit and r reject where s never belonged. mecha outbox send is kept as an alias and s still works in the modal: both meant this same action, they cannot mean anything else here, and a key that releases an outbound action is the wrong one to retire out from under someone's fingers. The stored status stays "sent", on the rule that already governs OutboxKind and Proposed: it is a value in an append-only store, mineable_as_writing keys on it, and renaming it would orphan every item already resolved.

  • A search backend can be preferred for deep searches. [[search]] prefer_deep = true moves a backend to the front of the chain when the caller asked for a deep search. Depth used to change only how a backend searched and never which one ran, so a research question went to whatever was cheapest and first — and a paid backend chosen precisely for hard questions was reached only when the free one came up empty. It reorders and never filters, deliberately: a preferred backend that is rate-limited must still fall through to the free one, and a quick query must still reach the paid backend as a fallback when the free one is down — the arrangement that kept working through a total searxng outage. A stable partition, so config order still decides everything within each group.

Fixed

  • A long draft could be neither read nor approved. The approval confirmation put a tainted draft's arguments on screen "in full" and rendered them with an unscrolled Paragraph, which draws from the top — so a docs_replace whose find was a whole syllabus section pushed the question and the y prompt off the bottom, and every other key dismissed the confirmation, so there was no way to scroll to them. The box was also sized from body.len(), which counts logical lines: one long argument is a single Line and many rendered rows, so the height reported "it fits" precisely when it did not.

    The arguments now scroll (↑↓/jk, PgUp/PgDn, Home), the prompt is pinned to the bottom border where nothing can push it off, the height is measured with Paragraph::line_count after wrapping, and scroll keys no longer count as "anything else". This is the review surface's own standard — a field the reviewer cannot see is a field they approved unread — failing the moment an argument outgrew the terminal.

  • The mouse can select text again. The TUI enables mouse capture for the whole session, which is what makes the wheel scroll the transcript — and also what stops a drag from selecting anything, because the terminal forwards the drag to mecha rather than drawing a selection. That made the /docs picker's own documented fallback ("the URL stays on screen to be selected by hand as well") one that had never existed: the authorization link is 420 characters, and its only other route off the screen was an OSC 52 write no terminal acknowledges.

    The mouse is now handed back whenever what is on screen is meant to be copied. Any modal releases it automatically — while one covers the screen, capture buys only a wheel scrolling the transcript behind it — and ^s toggles selection mode in the main view, with a select ^S badge on the status strip, because a wheel that has silently stopped working reads as a broken session. Reconciled once a frame from the drawn state rather than at each pane's exits, on the reasoning that a mode restored by remembering is a mode that eventually is not.

  • The /docs picker can be finished from a terminal. Three separate things stood between the link and the browser. Handing the mouse back is one; the other two: the link is now shown alone at column 0 on s, since a drag across a wrapped, bordered box copies the at each end of every row, and o hands it to $BROWSER/xdg-open when the browser is on this machine. And a paste while the picker is up now lands in the picker's field — it went to the message box behind the modal, so the pane said "paste it here" while the only way to fill it was to type two hundred characters by hand. Whitespace is stripped on the way in, because an address copied out of a wrapped display arrives with newlines in it.

  • "Nothing found" is an answer, and now arrives as one. A chain whose backends all answered and all found nothing reported every search backend failed. The model read that as broken infrastructure and reworded the same query eight times. An exhausted chain of empties is now a successful no results for "…"; a chain where nothing answered is still an error, and one broken backend no longer hides another's honest empty.

  • A searxng instance with every engine down no longer reads as an empty web. The measured case: every engine behind the instance suspended or CAPTCHA'd, results: [], HTTP 200 — byte-identical to a genuine no-match, so a total search outage reported as "the web has nothing". The unresponsive_engines field is now read and makes it a failure. Read defensively: an unexpected shape from a third-party instance reports nothing rather than panicking a search.

  • The cache lens stopped crying wolf on large tool results. Verdict::Drop scored re-payment from input_tokens, which is everything not read from cache — overwhelmingly the turn's new content on this workload, where one mail thread or search result dwarfs the prompt it was appended to. So the lens shouted loudest exactly when tool results were biggest, and scored the real failure — a small prompt re-paid in full because something destabilised the prefix — as stable. It now measures what did not come back (prev_total - cache_read_input_tokens); the field is renamed repaid to say so. Measured against a live llama-server: 2,720 of a 2,724-token prefix read back with 6,319 tokens of new content, previously called a drop at a share of 2.32.

0.1.10 - 2026-08-21

Added

  • Images. Block::Image is a fifth block variant, so a screenshot can be put in front of the model rather than only landing on disk. It rides the user turn only: Anthropic accepts an image inside a tool_result and the OpenAI dialect's role: "tool" messages carry a string and nothing else, so a tool returning pixels would work on one backend and silently lose them on the other — in the one place where the missing thing is what the whole turn was about.

    Four doors reach it: the Slack connector, the remote-control inbox, mecha run --image <path>, and a file dropped on the TUI prompt. That last was half-built already — a terminal converts a drop into a bracketed paste of the path, and the paste handler had always inserted it; what it never did was look at what the path was. A paste whose every token resolves to an existing image now becomes a chip, [image: shot.png], and the image is sent only if its chip survives to submit — the only undo there is for bytes backspace cannot reach. Requiring the whole paste to be paths is the safety property rather than a convenience: a paste is also a paragraph copied off a web page, and attaching any file whose path appeared somewhere in prose would let copied text pull bytes off the disk into a request. It cannot work over SSH and never will, because the path pasted is the laptop's.

    Both backends degrade to a named line rather than failing when the model has no eyes, and are tested to word it identically — a conversation crossing a /model switch must not tell two stories about its own history. The parts array is built only when an image is present, because the cached prefix is a byte-prefix match and making it the uniform shape would invalidate every run that never sends one.

    Capped at the door rather than per turn (mecha_core::image): 1568px long edge, 5 MB encoded, and an image already within them passes through byte for byte. Measured on the screenshot that motivated this: 5.7 MB → 179 KB with prompt_tokens 294 either way, because the server tiles to a fixed count regardless — so the resize buys nothing in context and 32x on the wire and in an append-only session file. One un-resized screenshot was 99% of a transcript, re-sent whole every turn.

  • [providers.X] vision, and a preflight that checks it. provider::preflight makes one GET /props at startup and compares config against what is served, in both directions — declared-but-not-served silently degrades every image to text, served-but-not-declared means a projector is loaded, paid for in memory, and never used. The same request checks context_window against the per-slot n_ctx, so the -c versus -c / -np rule is read rather than restated, and the configured model against model_alias. Warns, never refuses.

  • mecha setup. What an install still needs, and the one command that fixes each. For a local provider it does not ask what you meant — it reads /props and --write writes down what the server reports, editing the table in place so comments survive. It also inventories mail, documents, Slack and the knowledge graph, reporting unknown distinctly from not set up, because telling someone their mail is unconfigured when the store is merely unreadable sends them through an OAuth flow they did not need. The graph's own sources are named and never driven, and nothing is scheduled.

  • mecha trigger daemon --print-unit. Prints a systemd user unit naming this binary by absolute path. The documented alternative — copy scripts/mecha-triggers.service — cannot be followed by anyone who installed from crates.io, because the crate ships no scripts/ directory.

  • scripts/mmproj.sh. Every start script sources it, and it refuses to start a multimodal model without its projector, printing the curl that fetches it. A vision model is two files; --mmproj-auto is on by default and only fires for -hf, so it does nothing for a server started with -m <path> — which is how four of four local models were being served text-only while the flag list looked handled.

  • /send <path> in the TUI, and mecha slack send. A file goes from a session into the owner's Slack DM, where a phone can open it. The problem it answers is small and constant: a chart rendered on a headless box is a file nobody can see, because SSH has no viewer and scp back is a second connection nobody sets up to look at a PNG.

    The destination is not an argument. It is the owner's DM, read from the binding, and there is no parameter that moves it — the same shape as frontdoor::Record::for_privileged_run, where the safety property is a function signature rather than a rule someone remembers. That is what will let the agent itself surface a visualisation in a later rung without the tool becoming a way to send a file anywhere else.

    The TUI's /send resolves through the run's path jail and the CLI verb does not, which is deliberate: a session has a jail and no reason for a second rule, while the command line runs in a shell that is already the boundary. Refusals are decided before the DM is opened, so "that is a directory" arrives as itself rather than after a network error that happened first, and [slack] max_upload_mb now caps both directions rather than only what comes in.

    Rung 1 of docs/REMOTE-CONTROL-DESIGN.md.

  • /remote-control <name> — a live TUI session and a named Slack thread are the same conversation. What the run does and what you type appear in both places; typing in the thread steers a run in flight or starts a new turn; files dropped there land in the session's workspace under ./inbox/, with only the path given to the model so the taint arms through fs_read rather than a parallel route.

    A name is durable and its thread is forever, so detaching marks the record cold rather than deleting it — the record is how the thread is found again. The connector no longer starts its own run in a mirrored thread, which it previously did: a fresh conversation in a different workspace under a different permission mode, answering in a scrollback it knew nothing about.

    Slash commands and ! escapes are refused from Slack. They are not prompts, and the gap between "the owner typed this" and "the owner is at the keyboard" is where a remote surface should stay narrow.

    mecha slack remote lists what this machine mirrors; --sweep cools an attachment whose session has gone.

Changed

  • An attached image arms the private_data taint leg. Before images, a Slack attachment armed it because the model had to fs_read the file; putting the pixels on the user turn removed the tool call and the taint with it, which loosened the interlock as a side effect of a feature. Typed text still arms nothing — the distinction is that a screenshot is captured, not composed: you choose every word you type, and you choose the window rather than everything in it. Enforced in the loop rather than in Conversation::push, because the Slack connector appends to messages directly.

Fixed

  • fs_read says what a binary file is. It reported "stream did not contain valid UTF-8", which names the mechanism and not the problem, so a model handed a screenshot retried the same idea through shell — costing two approval prompts before concluding what the first error already knew.

Documentation

  • New features/images.md; getting-started/installation.md gains the local-model section that did not exist; first-run.md stops teaching context_window = -c, which features/serving.md calls the trap worth knowing before you meet it; config init's local block gains context_window and vision.

0.1.9 - 2026-08-20

Added

  • The task board reaches the session that needs it. mecha tasks — list, add, set — and /tasks in the TUI, onto the GTD board in the knowledge graph. /todo was the model's own scratchpad for the run it is in; the board a person keeps had no surface here at all, so the only way to look at it was to leave.

    The board is reached only through the MCP tool surface (kg_task_list / kg_task_create / kg_task_update), the way mecha mail task already did — so nothing gains a dependency on the graph or a second reader of its schema, and the lookup matches on the tool's suffix, which is what keeps a renamed server or a prefix_tools flip from turning the board into "no tasks". The modal drives the CLI, so nothing it can do is missing from a script.

    Keys are mecha-graph tui screen 6's keys, with a test saying so: two boards over one store with divergent letters is a trap, and the keystroke it springs is x on something you meant to finish. Nothing confirms — the board reaches nobody, every status is one keypress from where it was, and the tool surface has no delete — because a confirmation on a reversible private change only teaches people to approve without reading, which the outbox then has to fight. A reload re-finds the cursor by id, since changing a status is also what reorders the board and the next keypress might be d.

    Capture takes name, due, project and context; edit takes due, defer and context and not the name, because kg_task_update has no rename and a box that silently discarded what was typed in it would be worse than no box. --due accepts today, tomorrow and +Nd; --project must already name a node, and an unknown one is an error rather than an invented node. On set, an omitted field is untouched and "" clears — passed through rather than reinterpreted, or changing a status would wipe a due date.

  • /skills — what this agent knows how to do. The slash counterpart to /tools. mecha skills shipped without it, so the question could only be asked from outside the session. It reads from two places because neither can answer alone: what the run carries comes from the running agent rather than from re-deriving the selection off config, since --skill narrows a run without changing any file; what config withheld is marked rather than omitted, because a withheld skill and one the model chose not to load look identical from the outside and only one is a mistake.

  • A reply is shown with the message it replies to. "A message's reviewable object is the message" was half a rule: a staged mail_reply carries a body and a thread_id, and a thread_id addresses the provider rather than the reviewer — so the queue asked people to approve a letter without showing them the letter it answers. Deciding is this the right reply without the original is approving unread, in the one way this surface exists to prevent.

    Nothing needed recording. The drafting run read the thread before it wrote the reply, the item already names its session, and the transcript already held the result; the link existed and nobody followed it. It comes from the transcript, never a live re-fetch — a reviewer needs the bytes the model drafted from, not today's version of the thread, and show stays a store read with no network behind a display. The join knows nothing about mail: it matches identifying arguments by key and value against earlier calls in the same session, so thread_id == thread_id finds the read where account == account would have matched everything. Quoted under a heading naming it as third-party content, because a quoted block with no heading reads as more of the letter.

Fixed

  • Eight modals stopped taking the session down when the window shrank. rows.clamp(1, terminal_height.saturating_sub(4)) is a panic and not a layout bug: the subtraction saturates to zero at four rows or fewer, clamp asserts min <= max, and a panic in the draw path is the whole run — partial answer and all. Found once in /doctor, then written again in six siblings, because a new modal is written by opening whichever one is nearest.

    /mail was the eighth and hid a row deeper: its floor was two, since it reserves a line for its key strip, so it died at five rows where the others died at four — and it survived the sweep that fixed the rest because the site read clamp(2, …) and matched no search aimed at clamp(1, …). The helper now takes what the box reserveds, which is the thing the two-argument form could not say; a helper that cannot express a caller is how that caller ends up spelling it inline. The degradation is deliberate: the ceiling floors at one row and then the floor is pulled down to meet it, so a terminal too short for both the strip and a row of list gets a useless, living box rather than a dead session.

  • /outbox no longer opens underneath another modal. A run finishing under /review now hands the review surface a set of ids, and the guard that refuses when something already owns the keyboard did not know about /mail, /tasks or /polls — so the board stayed on screen while the keystrokes drove the queue, and r rejected a draft nobody could see.

0.1.8 - 2026-08-20

Added

  • Skills — procedures you write, that the agent loads when it needs them. ~/.mecha/skills/<name>/SKILL.md in the Agent Skills format: YAML frontmatter (name, description, optional triggers and tools) and a markdown body. Progressive disclosure in three levels — name and description in the system prompt at roughly a hundred tokens each, the body only when the model calls skill, and a bundled file only when the procedure points at one. That profile is the pressure valve for the learned-rule cap: a procedure too long for a rule, too specific to be worth a slot, and irrelevant on almost every run is exactly what this shape was built for.

    The format is the standard's rather than ours because the procedures worth writing are portable — the two SKILL.md files this repository already carried, written for a different harness, load unmodified.

    A skill is user-authored and there is deliberately no way for it not to be: no install command, no registry, no remote body, nothing derived from a session, and no project-layer store. That absence is the whole safety argument — 36.8% of 3,984 published skills carry a security flaw and a cloned repository is the delivery route — and it is why loading one arms no taint: the body is the user's own words, like the system prompt. A project's mecha.toml may narrow the set by name and structurally cannot widen it.

    mecha skills lists what a run would carry and marks what config withheld; --no-skills and --skill <name> narrow per run; a trigger names its skills explicitly, where empty means none, because an unattended run's instruction set must not grow every time an unrelated skill is written. mecha eval forces them off, like MCP, hooks and learned rules.

    Loading is a tool call rather than a shell cat so it passes the pre_tool gate, lands in the trace, and works where shell is sandboxed or absent. A skill's tools list narrows the surface while loaded — union across loaded skills, never wider than the unrestricted surface, and enforced at dispatch rather than only in the tool list, because a shortened list is advisory to a model that remembers a tool from three turns ago. A loaded skill crosses a compaction verbatim, since a paraphrased procedure is a different procedure.

Changed

  • A staged message is reviewed as a message, not as its arguments. mecha outbox show and the /outbox modal led with provenance and then printed {"body_markdown": "Dear Dirk,\n\nThank…"}, so deciding whether to send a letter in your own name meant decoding escape sequences. Both now lead with the draft — headers, then the prose with its own newlines — with the taint warning still above everything and the provenance in grey below. mecha_core::outbox::DraftView does the reshaping, keyed on well-known argument names like headline so the store stays tool-agnostic and an unanticipated tool's fields land in other rather than vanishing. Nothing is dropped: every argument appears in exactly one of headers, body or other, with a test on it, because a field the reviewer cannot see is a field they approved unread. The exact bytes are --json on the CLI and J in the modal — the check, not the read. A tainted send's confirmation is reshaped the same way: what is approved must be what was read.

  • mecha outbox edit opens the prose. Editing a letter inside a JSON string literal means typing \n for a paragraph break and escaping every quote, in a file where one slip is a parse error that discards the whole edit — for the one action here whose purpose is changing the words. The scratch file is now a .md holding the body, written back to the field it came from; --json is what it always did, and is the automatic fallback for a draft that is not prose. The learning capture is untouched: args_before still holds the draft and mecha reflect still mines the difference.

  • /outbox hides resolved items behind h. They stay on file forever — that is the record — but a decided draft is not work, and a queue where one pending item sits under twenty-eight resolved ones is a queue nobody reads to the bottom of. The count rides in the title so the filter is visible, and the toggle re-finds the row under the cursor by id, because the two lists have different lengths and an index carried across would name a different draft to a keypress that might be s.

  • /mail shows its keys, and enter opens the thread. Eleven keys were going into a title bar that fit four and were replaced entirely by the first status message; they are a pinned strip now, built from the same table the key map is built from, with a test asserting the two agree, and ? opens the full list. enter was starting an MCP server, fetching a whole thread and printing its subject line into that title — the mail was downloaded and thrown away. It opens a scrollable reader instead, off the event loop on a watch, and the triage keys work from inside it so read-then-archive is one motion. The handle column is gone from the list: eight characters of base64 spending width the subject needed, kept on the reader's title bar where someone about to type mecha mail archive <handle> is looking.

0.1.7 - 2026-08-19

Two releases in one day, and they rhyme. mecha got a writable seat at the user's documents — with the grant chosen so a document nobody handed over is not reachable by any prompt — and it learned to file mail, with the reading of that mail quarantined so a subject line cannot reach a run holding the calendar. Both landed a verb in the same new capability quadrant, from opposite directions.

Upgrading

Both mail accounts must re-authenticate. The Google and Microsoft scopes widened so mail can be filed rather than only read, and a widened scope does not cover a grant issued under the old one. Run mecha-mail auth <account> for each; mecha doctor names any account that cannot triage, with the remedy. Nothing else in this release requires action.

Added

  • A run now records how it went, not only what it cost. Record::Outcome(RunStats) lands one row per finished run in the session transcript, written by every front-end. RunOutcome carries fifteen fields and the transcript kept two, so an interactive run was measurably less observable than an unattended one — the signal was computed and discarded at the end of every run a human was watching. Every field is a deterministic count and none is derived from the content of a tool result, which is what makes the corpus usable as an input to automated grading: a counter carries no instructions.

  • mecha sessions health reads that corpus back across the whole session store — stop causes, tool reliability, how often a run finished over a failure — split by model, because a corpus spanning two has no single rate worth quoting. A rate with no denominator prints as an em dash rather than as zero: "nothing went wrong" and "nothing happened" are different answers.

  • mecha diagnose reads the corpus, folds in doctor's findings, and proposes one typed change with a falsifiable prediction. It does not measure and does not apply — it prints the eval --ab-config line that would falsify it. Automated failure attribution is right about which step failed roughly one time in seven, so the design target is that being wrong costs one measurement. The brief is built from counters and has no field for a transcript excerpt, and a proposal reproducing eight consecutive words from anything the diagnostician read is refused.

  • mecha eval --ab-config KEY=VALUE runs the case set twice, differing only in the override, and judges the difference: paired by case, deterministic holdout, and a work guardrail that rejects a gain bought by attempting less. Overrides are a closed set of run options, so both arms are built by one code path.

  • mecha doctor reads the population, not just the incident — a model finishing a fifth of its runs over a failed call, failing a quarter of its tool calls, or having a quarter of its runs cut short by a ceiling. Cancellations are excluded: a person pressing Ctrl-C is the system working. It also names a trigger failing a large share of its calls, and a trigger whose most recent run succeeded having done nothing at all.

  • Mail triage. mecha-mail gained mail_triage — archive, read, unread, spam, trash — as a closed action enum over a whole thread. The write surface was previously send, reply and calendar CRUD, so a mailbox could be read and answered but never emptied. Gmail does all five as label arithmetic in one call; Graph has no thread resource at all, so each verb resolves the conversation to message ids and acts on each, reports how many it touched, and fails only if every message failed.

  • mecha mailclassify, list, show, dismiss — over a new store at ~/.mecha/mail-triage/, one typed verdict per thread. classify reads recent mail through the MCP surface and hands each thread to a classifier with no tools, no history and no shared cache prefix, which returns a bucket (respond/notify/ignore), an urgency, a proposal, tags from a closed vocabulary, a deadline, and the request kind if it recognises one. Verified on fifty real threads: twenty-eight archivable, twenty-two needing attention, four request kinds recognised.

  • A snippet-first pass with escalation. A preview settles the newsletters; the full body is read only when the verdict is respond or names a request kind — the cases where the answer changes what happens. Measured at 25% of threads escalating. Deliberately not triggered by snippet length: a provider caps its preview, so nearly every real email looks truncated and escalating on that would escalate everything.

  • A nightly sweep, scripts/mecha-mail-classify.{service,timer}, 05:30 UTC and Dartmouth-only. A timer rather than a mecha trigger, because a trigger's action is a prompt on purpose and this is a deterministic command.

  • Google Docs, Sheets and Slides, as a fourth binary on mecha-mail. mecha-docs auth consents once; mecha-docs pick opens Google's real file chooser to adopt a document that already existed; mecha-docs list reads the reachable set back from Drive rather than from a local index, since a listing under this scope returns exactly the in-scope files and a second copy could only disagree. mecha-docs serve is the MCP face.

  • Eleven tools: docs_list, docs_read, sheets_read, slides_read, docs_create, docs_append, docs_replace, sheets_create, sheets_write, slides_create, docs_trash. docs_replace is the surgical edit — quote the text to change rather than index into it — and reports zero matches as a failure, because a model told "ok" there goes on to describe an edit that never happened.

  • --paste, for a machine with no reachable browser. It prints the authorization URL and takes back the 127.0.0.1 address the browser lands on, which is displayed in full even though nothing is listening. No tunnel and no forwarded port. There is deliberately no device-code flow: Google's device flow refuses the client type the file chooser requires, and two clients cannot substitute, because a per-file grant belongs to a (user, client) pair and the two would hold disjoint sets of files.

  • mecha-mail corpus — download a span of mail for analysis, envelope and snippet only, one JSON object per message. An operator command, deliberately not an MCP tool: the model has no business bulk-reading a year of mail, and a corpus verb on the tool surface would be one prompt away from being asked to. Deliberately unclassified, too — running a corpus through the classifier projects the existing tags onto it and confirms them by construction, so the taxonomy has to come from the mail rather than from the labels. Graph needed a new path (search caps at 100 with no cursor, and $search forbids $orderby, so it walks @odata.nextLink with $orderby alone, as the calendar has all along).

Security

  • The mail classifier sees the prose; nothing else does. Reading mail arms untrusted_input, so a loop that reads fifty threads into one conversation arms the trifecta for all fifty and stages fifty tainted drafts — correct, and useless, because a warning that fires on everything has stopped being a warning. The front door's shape applies one directory over: Record::for_privileged_run returns the typed verdict and the sender's address, and there is deliberately no argument that returns the subject, the sender's chosen display name, the classifier's own reasoning, or its one-line summary. That last one is the judgement call — short, and exactly what a summary wants — but it is model prose derived from attacker prose, which is the laundering path the front door withholds reading to close.

  • mail_triage carries destructiveHint alone. It mutates the user's own mailbox and reaches nobody, so it is not external_send and must never appear in [outbox] tools — staging it would make triage review a queue in order to fill another queue. It is not readOnlyHint either, or a permission_mode = "read-only" trigger could empty an inbox unattended. assert_tool_surface takes a third slice asserting both negatives. This is the quadrant docs_trash also lands in.

  • Tagging is not a provider operation. Gmail labels and Graph categories are different objects and a tag meaning different things per account fails at the one job tags have. A mecha tag lives on the triage record, costs no OAuth scope, and works identically on both providers; a test asserts the mail surface offers no label/tag/categor* verb.

  • Recognising a request kind is not routing it. REQUEST_TYPES is what the classifier can name; ROUTABLE_TYPES is the subset a manifest exists for, and only that subset can be promoted to the front door. A recognised kind with no manifest keeps its name as evidence and loses only the promotion there would be nothing behind. Enforced in code, not asked of the model.

  • The scope is the boundary. drive.file is the one non-sensitive scope in the family: no verification, no annual security assessment, and — on a published project — no seven-day token expiry. It also cannot reach a document that was not created by mecha or explicitly handed to it, which is a stronger guarantee than any of the wider scopes could be made to give.

  • Writing a document counts as sending. Writing into a document a third party can read is exfiltration; it looks like a local edit and it is a publish. Every write carries openWorldHint and belongs in [outbox] tools, so it stages for review rather than executing. Reads are readOnlyHint and not openWorldHint, with untrusted_input forced by config the way mail and the graph already are — a document comment is an injection vector invisible in the rendered page.

  • docs_trash is in neither quadrant, carrying destructiveHint alone: it reaches no third party, so staging it would make review circular, and it is not read-only, so an unattended read-only run must not reach it. The same quadrant mail_triage occupies. There is no permanent-delete verb and no sharing or permissions verb; the scope would permit both, so the boundary is the tool surface and a test asserts the absence.

  • Mail OAuth scopes widened, and a refresh stopped over-asking. gmail.modify replaced gmail.readonly (stopping short of https://mail.google.com/, whose only addition is irreversible deletion) and Mail.ReadWrite replaced Mail.Read. Both invalidate existing grants. Separately: Entra's refresh sent the whole scope list on every renewal, so widening it asked for a superset of what the stored grant consented to — refused as invalid_grant, indistinguishable from a revocation, and classified as permanent. Every working Outlook account would have gone dark about an hour after install. A refresh now sends no scope at all, per RFC 6749 §6, and a test asserts the absence.

  • A grant now says what it can do, and when it dies. StoredCredentials records granted_scopes verbatim and granted_at (stamped at consent, never touched by a refresh). mecha doctor reports an account whose grant cannot support the triage verbs, and warns two days before a declared grant_lifetime_days expires — the Google Testing-mode seven-day clock that a marker written on failure could only ever report after the outage began. Absent reads as not covered and as unknown, never as safe.

  • The grant lives under its own root (~/.mecha/docs/<account>/), sharing the credential type with mail but not its namespace: mecha doctor reads every oauth.json under ~/.mecha/mail/ as a mail grant, so a documents credential there would be reported as a broken mail account — a finding naming the wrong subsystem.

Changed

  • The learned-rules budget is per domain, and a run carries only the domains it names. MAX_ACTIVE_RULES_PER_DOMAIN 15 → 25 with RULES_CHAR_BUDGET 1600 → 2600 beside it, and the learner frames are handed the same constant rather than repeating it as prose — a frame saying "never exceed 15" while the gate admits 25 fails silently, because an over-consolidating learner looks like a well-behaved one. rules_prompt_block_for(RUN_DOMAINS) gives a run behavior + writing and nothing else; new domains are opt-in and unrouted_domains warns at startup about any holding rules no run carries. Everything reconstructing "what a run sees" moved with it, or the validation ledger would be keyed to a rule set no run ever had.

  • The mail taxonomy is measured rather than guessed. A year of real mail said the request kinds were wrong in both directions. student-advising joins REQUEST_TYPES and advising joins TAGS — the largest category by a wide margin, and absent because it is the most routine thing that arrives. book leaves: two threads in ten months, neither a request to write a book. A name on that list is a claim that the kind arrives.

  • Mail no longer routes to the front door. Proposed::Frontdoor, ROUTABLE_TYPES and is_routable are removed. The front door's [verification] block exists to prove a stranger controls an email address, and an email arrived from one — so the machinery being imported solved a problem mail does not have, while removing threads from the queue whose whole value is being the one place. Mail keeps its own request kinds; the surviving half of the idea becomes mail's own needs-info. Recognising a kind was already separate from routing it, which is why nothing else moved.

  • mecha mail correct — say the classifier got a thread wrong, field by field. A misread bucket, a missed deadline and a wrong request kind are different errors with different fixes, so the correction names which. The verdict is fixed in place immediately and the before/after pair is kept on the record, because a learner shown only the right answer cannot see what to stop doing. A correction that agrees with the classifier records nothing.

  • mecha mail score — score the live triage store against what actually happened. Behaviour (did a reply go out) and testimony (what you corrected) are reported apart, because a reply is one-sided evidence and a correction is not. Threads younger than 48 hours are excluded: most replies that ever happen land on the first day, so a same-day thread has no outcome yet and counting it would punish a rule for how recently the mail arrived.

  • mecha mail eval — grade the classifier against a corpus whose outcome is known, with no human grading anything. Reports a false-ignore rate on the answered stratum and a volume on the unanswered one, never a blended accuracy; --out keeps every graded verdict so a run can be re-read without being re-run.

Fixed

  • A pile of identical failures no longer rides in every request. compact::collapse_repeated_failures folds repeated failures onto their newest member. Errors are exempt from supersession on purpose — a failed call says nothing about the target — and that rule is right for one failure and inverts for eight: a model is measurably likelier to fail a step when the context holds its own earlier errors, and the effect does not go away with model size. Nothing in the harness touched these before: eviction skips errors by construction and thinning only truncates long results, so a sixty-character failure message was untouchable by both.

  • A run that answers over a failed call now says so. RunOutcome::ended_on_failed_call, with an expect check beside it. Nothing in the answer text or the stop reason distinguishes a run that recovered from one reporting success over a failure, and grading the claim needs a judge, which measures near chance at exactly this.

  • A retired proposal no longer makes a triage record unreadable. Proposed's derived Deserialize failed the whole record on an unknown string, so removing a variant from a type written to an append-only store would have silently truncated it — five records in a live store carried proposed: frontdoor on the day it was removed. The hand-rolled impl degrades an unknown proposal to none, which is what "a human decides" already meant.

  • mecha-mail corpus no longer silently truncates Gmail. Each month was capped at 500 messages and a failed month was unwrap_or_default()ed into an empty one, so a partial corpus and a quiet month were the same value. On a real year, most months hit the cap and the loss grew toward the present as volume grew — which does not add noise to an analysis, it adds a slope, and a slope is indistinguishable from a finding. The ceiling is now high enough to be theoretical, reported on stderr when reached, and errors propagate.

  • A failed classification is retried instead of buried forever. The sweep skipped anything the store had heard of, and the store holds failures as well as verdicts — so a thread whose classification failed was skipped by every later sweep on the strength of a record saying it never happened. An outage on 2026-08-19 left 17 threads in that state, one of them a manuscript review invitation.

  • A classify run that did nothing now fails. It returned success however badly it went, so a run that classified 0 of 16 logged SUCCESS and every exit-code check — OnFailure=, systemctl --failed, doctor's failed-unit scan — read a dead nightly as healthy. Partial failure stays a success deliberately.

  • A retired learned rule survives a reworded re-derivation. Retirement was carried by exact text, so the same rule in different spelling or punctuation came back live. It now matches after normalising case, punctuation, spacing and -ise/-ize, checked only against retired rules so two distinct rules cannot be merged by accident.

0.1.6 - 2026-08-16

The release where the knowledge graph became a sibling product: pkg went public as mecha-graph (three crates on crates.io), and mecha's side of the seam was renamed, un-prefixed, and folded into the install ritual.

Added

  • prefix_tools on [[mcp]]. Unset keeps the collision-proof <name>__<tool> default; false registers a server's tools under their raw names, for a server whose tools already carry their own namespace — the graph's kg_* family, where graph__kg_search was stutter the model typed in every call. Unprefixed is a promise of distinct names, and the promise is enforced: a collision with anything already registered fails startup loudly (a prefixed name always contains the __ marker, so the two cases cannot be confused, and prefixed shadowing keeps its existing semantics).

Changed

  • The eval grades the surface that actually runs. pkg-cases.jsonl is now graph-cases.jsonl, its fixture graph_server.py, and the trace checks expect the bare kg_* names production serves — an eval still measuring pkg__kg_* would grade a configuration that no longer exists anywhere. Scorecards across the rename are not comparable, which is the honest cost of any tool-surface change. The distill, vet, and corroborate server defaults follow the live alias to graph.

  • The graph installs with mecha. The README offers mecha-graph-mcp and mecha-graph beside mecha-mail, the sample [[mcp]] block wires the installed binary (command = "mecha-graph-mcp", prefix_tools = false), and the update skill folds the graph into the same cargo-install ritual as every other binary.

Fixed

  • --mcp-file fixture paths survive the workspace. Relative args were joined against the file's directory but left relative, and since MCP servers began starting in the run's workspace that join resolved there instead — every fixture handshake failed regardless of names. The base directory is canonicalized, so the joined path means the same file from any working directory.

0.1.5 - 2026-08-16

Added

  • recall — the record is searchable after the summary. A tool over the session's recorded transcript: the union of every message ever recorded, including what a compaction rewrite replaced, so a run missing a dropped detail looks it up instead of re-running tools or re-living the stretch. Taint-neutral by construction (everything it returns entered this conversation once, and its arrival is what armed the interlock; the transcript path is fixed at registration, never model input). Registered by chat, the TUI, and resumed runs — deliberately not Slack (one shared registry across per-thread conversations would cross-wire transcripts) and not fresh one-shots or triggers (a per-run record is empty until run end).

  • The cache lens: prompt-cache reuse is watched, not assumed. A pure per-run observer fed each request as actually sent plus the usage the provider reported. It names the reason when reuse legitimately breaks (tool/system surface changed, transcript rewritten by compaction), stays silent on providers that have never reported a cache figure (zeros are silence, not a miss), and warns on the one remaining shape — a large re-payment with nothing changed. Verdicts go to tracing only; the model and the loop never see them.

  • kind = "landlock": file confinement that needs no privilege. A fourth sandbox backend for machines where bwrap fails even installed (Ubuntu 23.10+'s AppArmor userns switch): Landlock LSM rules applied in the child between fork and exec — ruleset built in the parent, the post-fork closure makes raw syscalls only, and a kernel reporting NotEnforced fails the spawn rather than running unconfined. Priced honestly: Landlock cannot close the network (TCP denied on 6.7+ kernels, UDP unrestrictable at any ABI), so a landlocked shell never earns the interlock relaxation — what it buys is the file story, hard-required at ABI 3 because older kernels cannot restrict truncate. Preflight proves the denial, not just the apply: it plants a file in the real home and requires the confined read to fail.

  • Gossip adjudicates the queue instead of adding to it. After building deep context on an entity, a gossip probe now pulls the pending claims about that same entity and judges them under vet's existing verification mechanism — the one output that makes the review backlog smaller. Verdicts are opinions filed beside still-pending candidates; --adjudicate caps the batch (default 25) so the owner node's ~1,800 claims cannot absorb a whole night.

Fixed

  • A run that compacts itself no longer loses its own head. Front-ends record at run end, so turns of the current run that a mid-run rewrite replaced were never anyone's to write. The states a rewrite replaces now ride on the Conversation (rewritten, cleared at run start), and Session::record_run — which now takes the conversation, so a caller cannot record the destination while skipping the journey — walks snapshot → snapshot → final. load() still replays to the live state; recall reads back what the summary dropped; the taint timeline only gains rewrite records, which drop checkpoints in the over-tainting direction.

  • A reader's searches no longer feed the probe selector. Gossip's LensedSearch passes probe: true to kg_search, so pkg's Selector — which ranks probe targets by retrieval demand — stops counting a probe's own reads as the owner reaching for something. One probe had taken its target from 2 touches to 28 and re-elected the same person out of a pool of nine.

Changed

  • trusted_output must now name what it vouches for. A subagent profile setting trusted_output = true without an answer_shape refuses to construct — the old semantics let one config line disarm the trifecta's untrusted leg for every answer the child ever gave, with nothing checking anything. Trust is now granted per answer: declare answer_shape = "number", "boolean", or a list of allowed strings, and an answer earns the vouch by parsing as that shape at return time. Prose never parses, a mismatch comes back marked untrusted with a note saying why, and the subagent's static capability now stays honest (untrusted_input derives from the child's tools unconditionally — the loop's untrusted_input && external rule is what lets a shape-proven answer through clean). There is deliberately no bounded-string shape. docs/TRIFECTA.md (new) maps all four trifecta channels and which mechanism owns each.

  • A trifecta refusal now names the durable fix, not just the workaround. Tools gained denial_remedy() — the tool's own account of what would make a call like the refused one safe, relayed at the end of the interlock's message. shell uses it to point at the actual remedy ([sandbox] with network = false, or just the network flag when the sandbox is already on), which ends the measured dead end where denials advised delegating to subagents that had no shell. The delegate suggestion itself now says it is for reading the outside world, so the model stops treating it as a route for local work. Security is unchanged: the same calls are refused, the refusal just routes somewhere real.

0.1.4 - 2026-08-15

Slack stops being a place to read about work and becomes a place to do it, and five stores that each knew something was wrong get something that reads across them.

Added

  • mecha doctor — one pass over every store, no network, no model, no tokens. The 2026-08-11 incident in one sentence: five stores each recorded a real failure correctly and the operator learned nothing, because nothing read across them. Doctor is that read — dead auth markers, releases that errored, drafts and requests waiting past a threshold, triggers whose slots stopped advancing, failed mecha-* units.
  • /doctor in the TUI, one keystroke from where the error surfaced. Findings from mecha doctor --json as a child process, broken before attention, a component's findings kept together. Acting on a finding dispatches on the remedy's shape: one whose surface is already a modal deep-links to it rather than spawning a nested CLI over the modal that is the review, and a remedy needing a terminal suspends the TUI and inherits the real one.
  • Executable actions from Slack. Buttons and modals that do the thing rather than describe it — the button carries an id, the enum carries the verb, so the prose that shares a context window with third-party text never becomes the instruction. Trigger enable/disable, frontdoor close and needs-info by modal, and a session-scoped review now|later|auto taken only as the owner's exact command word, expiring with the thread state, with tainted drafts carded in every mode.
  • Tier-2 gossip and the vet mechanism (mecha gossip --out, mecha vet) — corroboration and queue-verification over pkg's review queue, origin-family exclusion computed rather than reconstructed, batch runs that keep their evidence as JSONL with per-candidate error containment.
  • Recorded denials and structured corrections, mecha's half of pkg's D3: the graph can say "no", and the distiller can say "that was wrong".
  • A start script for Qwen3.8-27B (scripts/start-qwen38.sh) as a fourth local arm on port 8083 — dense rather than MoE, with the MTP draft model loaded separately because unsloth ships no MTP variant for 3.8. It carries the measurements and the two traps this model brings: llama.cpp#20837 is still open against the qwen35 architecture it reuses, and its chat template rejects any system message that is not first.

Changed

  • One release policy instead of two. review_policy.rs holds the mode and auto_releases(mode, tainted, finished_clean), so the tainted exclusion and the early-stop exclusion are the function's signature rather than a check each surface has to remember. Slack auto no longer releases what a stopped run produced.
  • Nothing blocks the dispatch loop. A button press reads its one item file — id shape-checked before it touches a path — instead of parsing the whole outbox, twice, for tainted. The ledger append rides inside the spawned task, and the TUI's restart guard probes on a watched thread so a probe that never answers cannot hang the interface.
  • cargo fmt --all, which CI had been failing on for four commits. No semantic change; the drift spanned 20 files and predated the commits it was failing on.

Fixed

  • Nothing fails silently: the staged jail, the unanswered card, the revoked token. Three field failures with one shape — a component that knew something was wrong and told no one. A staged call now records the jail its tool really runs under, via Tool::fixed_workspace.

0.1.3 - 2026-08-10

Dependency maintenance, and the one migration hiding among the bumps.

Changed

  • The minimum supported Rust version is 1.89, raised from 1.88 to take rustyline 18, which uses file_lock. CONTRIBUTING.md now carries the rule for when it may move again — with a dependency that needs it and never to use a new feature ourselves, raised in the same change as the dependency that forced it and naming it. The old number had no rationale attached, which is why every bump reopened the argument.
  • Nine dependency majorsrand 0.8 → 0.10, sha2 0.10 → 0.11, toml 0.8 → 1.1, htmd 0.1 → 0.5, base64 0.22 → 0.23, rustyline 15 → 18 (the line editor behind mecha chat), clap 4.6.6, and ratatui 0.29 → 0.30 with crossterm 0.28 → 0.29, which cannot move separately because ratatui pins it. The ratatui major was a migration rather than a bump: 0.30 makes Backend::Error an associated type, so terminal.draw(..)? inside a function returning anyhow::Result stops compiling — anyhow needs the error to be Send + Sync + 'static and an opaque associated type promises none of it. Fixed with a bound, Terminal<impl Backend<Error: Send + Sync + 'static>>, rather than pinning the concrete io::Error: ratatui's own TestBackend uses Infallible, and the concrete type would have locked every rendering test out of the functions it tests.
  • Google's PKCE verifier is filled in one call from rand::rng(), the thread-local CSPRNG, rather than a byte at a time — the one code change the rand major required. The function had no test at all, which is how a security-relevant generator gets its random source swapped with nothing watching; it has two now, the load-bearing one recomputing the challenge from the verifier exactly as the authorisation server does, because a wrong transform fails every sign-in with an error naming neither end.
  • Four GitHub Action majorscheckout 4 → 7, setup-node 4 → 7, upload-pages-artifact 3 → 5, deploy-pages 4 → 5.

Added

  • A test that an HTML-only email keeps its links, tables and lists. htmd is a behavioural dependency rather than an API one — it decides what a mail body looks like to the model, and four minor versions can change that while every type still lines up. Asserted on content surviving rather than exact output: a golden-output test would break on every bump of a converter whose formatting is its own business, where "the link's URL is still in there" is what must never stop being true.

0.1.2 - 2026-08-10

Fixed

Reasoning, and the empty turns it turned out to be causing

A reasoning model's thinking arrives on its own channel, and this harness read none of it. reasoning_content appeared nowhere in the tree, so on any local model the reasoning toggle did nothing, the TUI showed silence where thinking should stream, and the transcript kept no trace. Block::Thinking and StreamEvent::ThinkingDelta had existed since the Anthropic backend was written; the plumbing was all there and nothing was ever fed into it.

It was also, measurably, the cause of the empty turns this project has been mitigating since 2026-08-07. Replaying the exact prefixes that went quiet during the 2026-08-10 benchmark reproduced one, and its "reasoning" was a complete, unparsed tool call — in one case 120 characters that were only a tool call, with no deliberation at all. finish_reason: "stop", no content, no tool_calls. The model had emitted its call before closing </think>, so llama.cpp filed the whole turn as reasoning (upstream ggml-org/llama.cpp #20837, #22684, #20809 — all unfixed, and the same failure is reported against ollama). Note what that rules out: 120 characters is nowhere near a limit, so --reasoning-budget, max_tokens and the context window were never relevant. Every earlier mitigation aimed at "the model reasons too long" was aimed at the wrong failure.

And half of it was ours. Because the history sent back stripped every <think> block, the model was shown turn after turn of itself apparently calling tools without thinking, and it obliged. Same server, same template, same prompt, varying only whether the history carried reasoning: 6 of 6 empty turns without it, 0 of 6 with it (Fisher exact p ≈ 0.001, on a reproducer that fails byte-identically).

  • reasoning_content decodes into a Block::Thinking and streams as ThinkingDelta, so reasoning is visible live and recorded in the transcript. It is never output: produced_output is the same definition the loop decides produced_nothing on, so a reasoning-only turn is still nudged rather than ending a run with an empty answer.
  • It rides back to the provider on the next request. Self-gating by construction — a Block::Thinking exists on this path only because a server sent the field, so it returns only to servers that speak it and never to an endpoint that would reject an unknown one. No provider name is tested. Affordable because the prefix cache absorbs it: measured, turns with 5,000-token prompts prefill 16–211 tokens, better than 95% reuse.
  • An empty turn now records what actually arrived — its full trace at debug, with a tail and a family-agnostic lost-call marker at warn. Such a turn is in no transcript (the loop nudges and continues before pushing, and holds no session to record into), so this is its only durable record.
  • A run that ends having only reasoned hands that reasoning back, labelled as deliberation rather than a committed answer, instead of reporting that the model said nothing.
  • usage.prompt_tokens_details.cached_tokens is read, so cache reuse is no longer invisible. The cached half is subtracted from input_tokens: Anthropic reports the tiers beside the prompt count and OpenAI reports them inside it, and total_input sums all three — carrying it over unchanged would report a prompt at nearly twice its size, and the compaction threshold reads exactly that number.

The benchmark adapter

  • A task whose instruction opens with - reaches mecha as a prompt, not a flag. terminal-bench/pytorch-model-recovery describes itself as a bulleted list, so clap read - as an argument and exited 2 before the run started; Harbor records that as NonZeroAgentExitCodeError and scores 0.0, indistinguishable from a model that tried and failed. shlex.quote never helped — it makes the text one argv entry, and the problem is that entry's first character. Fixed with --, rather than allow_hyphen_values on the CLI, which would let a mistyped flag be swallowed as the prompt and run anyway.

0.1.1 - 2026-08-09

Added

Slack as a remote control

  • mecha slackauth (tokens read from the environment rather than from flags, so neither reaches shell history or ps), link (a one-time code binds this install to a workspace), connect (holds the socket open and drives runs from threads; this is what the systemd unit runs), threads, sweep and status. The transport is a fourth crate, mecha-slack, which joins the crates published to crates.io.
  • A Slack thread is a Conversation, which hands the trifecta interlock the right granularity for free: a new thread is an honest clean slate, and a thread that fetched a hostile page on Monday still remembers on Tuesday. Everything per-thread — jail, budget, cancel token, steering queue, approver — rides on RunContext, because one agent serves every thread.
  • Unfurling is off on everything the model authors, and there is no parameter to turn it on. A model-emitted URL that unfurls becomes an outbound GET that no tool call made and no interlock saw, so it is a property of the transport rather than an argument some call site can forget.
  • A private file download that comes back as a sign-in page is refused. files.slack.com redirects to the workspace host, HTTP clients strip Authorization across hosts, and Slack answers with an HTML login page at status 200 — so the header is sent explicitly, no redirect is followed, text/html is rejected even at 200, and the byte count is cross-checked against the size Slack reported. Without them a sign-in page reaches the model labelled as the user's screenshot.
  • [slack] configmax_concurrent (threads with a run in flight at once; at the cap the connector refuses and says so rather than queueing), approval_timeout_secs, default_mode, max_turns, max_cost_usd, stream_flush_chars / stream_flush_ms, max_upload_mb, and tools to narrow the surface a Slack-driven run gets — measured on the first live run, the schemas of every wired MCP server cost ~7–8k input tokens per turn before any work happened.

Inter-agent messaging

  • mecha msgsend, list, show, dismiss and agents over a file-based mailbox between sessions (~/.mecha/messages/<recipient>/). One agent, or a person, leaves a short text for another by producer name; the recipient's run claims it at the top of a turn and folds it into the same user message that carries the tool results — the identical fold point as steering, because it is the identical problem. A recipient with no live run loses nothing: the message waits in the store until its producer next runs.
  • Taint travels with the message. A message is otherwise a laundering point, since the receiving conversation's interlock never saw what the sender read. The harness — never the model — stamps the sender's conversation taint onto every message, and delivery merges it into the receiver's before the text lands. A tainted overnight run can still report to chat; the morning session then treats external sends exactly as if it had read the hostile page itself.
  • A message is structurally not the user. It cannot answer an approval prompt, cannot change config, and arrives labelled as another agent's words; the receiver's own permissions, hooks, outbox route and interlock govern everything it provokes. A full mailbox refuses new sends, which is what dismiss is for.

Review, in the TUI

  • /outbox and /frontdoor are modals on the /triggers pattern: the store read for display, every mutation a mecha … child process, and slow work — a release's MCP startup, an extraction, a triage run — spawned detached and watched, with the outcome reported as a notice when the store shows it. Every send confirms, and a draft staged with the trifecta armed is shown in red with its full arguments on screen.
  • /review now|later|auto decides what happens when a run stages drafts. It is set only by slash command and never inferred from the prompt, because release policy must not be decidable by anything sharing a context window with third-party text. Scope is an id-diff between submit and completion, so no mode touches items another session staged; tainted drafts never auto-release, and an errored or early-stopped run releases nothing.
  • /polls — the polls open on the public gate: tallies, close and export.

mecha-mail: availability and bookings

  • calendar_freebusy merges busy intervals across every configured account. mecha-mail freebusy --json is the same answer as data, for a scheduled pipeline with no model in it — and unlike the MCP surface, which reports a failed account beside the others' results, it fails when any account is unreadable: a mailbox that could not be read is not a mailbox with free time, and a booking page built from a partial answer offers strangers slots the user does not have.
  • mecha-mail bookings turns drained booking records into calendar events — the inbound sibling of freebusy, deterministic and with no model in it. The invite is the provider's own, from the user's real mailbox; a cancellation withdraws the event and the ledger remembers both directions; reminders fire once per tier. Idempotent against ~/.mecha/mail/bookings.jsonl under a lock, so re-running after a partial failure picks up exactly where it stopped and two sweeps cannot both create an event.

The work directory

  • ~/.mecha/work/<producer>/ is a run's workspace as well as its output directory, where a producer is a trigger's name, or chat, or a session id. It is stable across runs of the same producer, so yesterday's output is an ordinary file in today's run rather than something that has to be plumbed.
  • mecha worklist (what each producer has generated, with counts, size, and the newest entry), path <producer> (for cd $(mecha work path x)), and clean with --keep, --producer and --dry-run. Retention is a policy rather than an intention: the last [work] keep entries per producer survive, the nightly job runs it, and it says exactly what it removed.
  • clean never removes anything a published bundle names as a source, and names the entries it kept for that reason instead of skipping them silently.
  • [work] keep in config (default 10).

The public surface, outbound

  • Publishing rides on the outbox. The factory's MCP tools render a bundle locally and stage the publish for review, so nothing reaches a public URL without a human releasing it.
  • [outbox] publish_tools names which routed tools are publications. An item now carries a kind (message or publish) that decides how it is reviewed: show leads with the rendered page rather than the arguments, edit is refused with the real action named (edit the source, re-render, publish again), and the writing-reflection miner excludes publishes so a changed directory path can never be mined into a voice rule.
  • The kind is config's to declare, never the tool's. Anything unnamed is a message, and a name in publish_tools that is not in tools warns on every start.
  • An item records the workspace it was drafted under, and a release rebuilds its tool surface rooted there. A batch release builds one surface per distinct workspace, lazily.

The public surface, inbound

  • mecha frontdoorlist, show, extract and next over the requests drained from the public surface into ~/.mecha/requests/. The whole layer serves one sentence: the privileged run sees the extraction, never the prose a stranger wrote.

  • The extractor is issued a request with an empty tool list and no conversation, so there is nothing for an injected instruction to reach; an extraction failure routes to a human rather than passing the prose through.

  • Record::for_privileged_run is a function with no argument that returns the original text, so the boundary is structural rather than a rule to remember.

  • triage, needs-info and close are how a request reaches an answer, and the queue stops being somewhere requests only accumulate. triage drafts a reply per extracted request into the outbox and refuses to run without the outbox route — unrouted, a mail_send the model makes actually sends, and a stranger's inbox is not where you want to discover [outbox] tools was unset. Each request gets a fresh Conversation, so flagged prose cannot arm the interlock for the request behind it. close requires a reason. A rejected draft returns the request to extracted, never to closed — "not this reply" is not "not this request". reconcile reads the outbox and updates the request store on its own rather than on a verb someone has to remember, and leaves a partly-resolved set alone, because some sent and some pending is a person mid-review.

Elsewhere

  • mecha outbox review walks pending items one at a time, deciding each — the overnight-triage case, where nine drafts used to mean nine invocations and nine startups of every MCP server. Batching saves the invocations and never the reading. Ids may be given several at a time; --all is narrowed by --kind and --via; list is grouped by kind. A selection naming nothing, or a filter matching nothing, is an error rather than "everything".
  • Tool::carried_state lets a tool hand state to a compaction to be carried verbatim. The todo list reached the model only through the echo in its last result — a message, and therefore exactly what a compaction summarises away. Exactly one copy survives a second compaction.

Fixed

The agent loop, after the 2026-08-07 Terminal-Bench diagnosis

Twenty-one trials of the arm64 subset broke down as 8 passes, 5 genuine model failures, and 8 deaths the harness owned some share of. Each fix below names the trial class that motivated it.

  • Overflow recovery is no longer disabled by a summary that wasn't worthwhile. The recovery arm's give-up flag was set when compact() found nothing worth summarising — a normal answer for a short transcript already saved by thinning — and the flag gated the whole arm, so the next overflow propagated as a raw fatal 400 with eviction and thinning never attempted (path-tracing, dead at 45k tokens in a 32k window). Eviction and thinning now run on every overflow; only a summary request that failed stops further summary attempts.
  • The empty-turn retry allowance resets on any productive turn. It was cumulative across the run, so a long run that recovered from silence early was left one empty turn from death for the rest of its life — two trials died NoOutput mid-task that way, while two others recovered from a nudge and passed. max_turns is what bounds the total; consecutive silence is still bounded by the same retry count.
  • mecha run exits non-zero only when the run produced nothing. Every exhausted stop used to exit 3, which benchmark harnesses read as an agent crash: a MaxTurns trial was recorded as an agent error while its verifier scored the work 1.0. MaxTurns and budget stops with an answer now exit 0; --json's stop_cause still says which ceiling stopped the run.
  • A crashed run keeps its transcript. mecha run recorded messages only after a successful return, so the trials that died mid-flight — the ones whose transcripts get read — left three-line session files. Messages and taint are now recorded before the error propagates in the unattended front-ends (run, triggers, frontdoor triage); chat and the TUI keep their deliberate interactive semantics — a failed turn is dropped so the prompt can be retried — but now restore from a snapshot instead of truncating a list whose length a mid-run compaction may have changed.
  • A compacted run's transcript records what actually happened. Recording sliced "what the run added" off the end of the message list, but compaction rewrites the list in place — the file kept the stale head, skipped the rebuilt one (summary included), and a 28-turn trial recorded as 8 assistant turns starting mid-conversation. A run that rewrote history now writes a rewrite record carrying the current state; load replaces, the taint timeline clamps stale positions (over-taint, never under), and the interactive fronts restore from a snapshot on error instead of truncating a list whose length compaction may have changed.
  • The per-turn tool-output budget derives from the context window when [tools] output_budget_bytes is unset: an eighth of the window in tokens at ~3 bytes each, clamped to [6,000, 24,000] — 12,288 bytes at a 32k window, the old 24,000 at wide ones. The flat 24 KB was ~8–12k tokens of numeric data, larger than the gap between the compaction threshold and a 32k window, so one turn's results could leap from under the threshold straight past the window.
  • Benchmark trials capture mecha's stderr and tracing. Harbor recorded stderr: None, discarding compaction notices and the loop's own account of every death; the bench adapter now redirects stderr to a file beside the session transcript and runs with MECHA_LOG=debug.

Jails, workspaces, and who is refusing

  • A path jail rooted where the secrets live. setup now refuses any workspace that contains the mecha home, because $HOME contains ~/.mecha/ — the mail OAuth tokens, every session transcript, the learning store. mecha chat from a home directory was jailed over all of it, and an unattended trigger with no explicit workspace was worse: it fell through to current_dir(), and the shipped systemd unit sets WorkingDirectory=%h. A workspace inside the mecha home is fine and is now the default.
  • mecha trigger add writes the workspace down rather than leaving it implicit, and trigger show prints the resolved default — "where is this jailed" must not be answered by an omitted line.
  • An unconfined MCP server now starts in the run's workspace, like a confined one always did. It used to inherit mecha's own working directory, so a server resolving a relative path resolved it against wherever the user launched mecha.
  • A refusal by the machine is no longer recorded as a correction by the user. Decision is now Allow | Deny | Blocked: Deny is a human saying no and is mined as a correction, Blocked is policy and never is. The prefix is chosen by the loop from the variant, never by the approver, so no wording a front-end picks can mislabel itself. This exposed a live bug — ModeApprover's own refusals (a read-only run's, an unattended run's "nothing is watching to answer") had been arriving as user denials, so every such run was feeding the learning miner corrections from a person who never spoke.
  • A trigger's notify command runs in the run's workspace, as a hook already did. It inherited the daemon's directory, so the only way to put the answer somewhere was to spell out an absolute path — which is how the morning briefing came to end in mkdir -p ~/.mecha/briefings && cat > …, outside every path jail and unreadable by any later run.

0.1.0 - 2026-08-05

First public release. Everything is new, so the whole feature surface is listed under Added; later releases will record only what changed.

Added

The loop and the library

  • Two cratesmecha-core is a plain Rust library that knows nothing about any CLI or application; mecha is a thin binary over it. Implement Tool to add a native tool, Provider to add a backend, Approver to control what needs permission. A provider-agnostic message vocabulary means a transcript recorded against one backend can be replayed against another.
  • The agent loop — ask the model, run the tools it asked for, feed the results back, repeat until it stops calling tools. The loop never learns which provider is behind it or where a tool came from; both are trait objects.
  • An Anthropic provider over raw HTTP, with adaptive thinking, thinking blocks echoed across tool turns, and stop_reason: "refusal" recognised as the HTTP 200 it arrives as.
  • An OpenAI-compatible provider covering llama-server, vLLM and Ollama, with streamed tool-call reassembly across arbitrary chunk boundaries, parallel calls interleaved by index, and tool calls that survive the finish_reason: "stop" llama-server reports alongside them.
  • Prompt caching on Anthropic — a fixed breakpoint covering tools and system prompt plus a second moving breakpoint on the last message block, so an append-only transcript reads from cache instead of being re-sent uncached every turn.
  • Classified failures, with transient ones retried — rate limits (honouring Retry-After up to a cap), overload, server and transport errors back off and retry; auth, billing, invalid-request and context-overflow never do. A retry covers the send only, so it can never duplicate work already shown or acted on. [providers.X] fallbacks then tries other configured providers on exhaustion, turn-local, each answering under its own model name.
  • A sampler you can pintemperature and seed on the OpenAI-compatible provider, refused at startup on Anthropic rather than silently dropped, and both recorded in the session so a transcript says whether its run was repeatable.
  • Budgetsmax_turns, max_output_tokens and max_cost_usd. All three end a run the same way: one final turn with the tools removed, so there is an answer rather than silence, and stop_cause says which ceiling fired. Cost prices cache reads and writes separately from fresh input, and reports null rather than a misleading zero where a provider has no prices configured.
  • Per-run contextRunContext carries the path jail, the approver, the budget, a cancellation token and a steering queue, so one agent with one provider connection can serve concurrent runs jailed to different directories under different permissions.
  • Cancellation that stops a run at the next safe point and keeps the partial answer, the partial assistant turn and the tokens already spent. Tools are never interrupted mid-call.
  • Steering — text queued mid-run is folded into the message carrying the tool results, so the model reads the results and the new instruction as one user turn and keeps working, without being stopped and restarted.
  • Subagents — an agent wrapped as a tool, given a rebuilt registry as an allowlist rather than an inheritance, with optional per-profile model, provider, turn limit and system prompt. A child's output is untrusted by default; trusted_output overrides that as a deliberate risk decision.
  • Layered TOML configuration — built-in defaults, ~/.mecha/config.toml, a project-local ./mecha.toml, environment variables, then CLI flags, each level overriding only the fields it names. A global-only load exists for runs that no working directory should shape.

Interfaces

  • mecha run — one task, one answer, with --json for machine-readable output, --resume to continue a recorded session, and exit codes that distinguish success, error, refusal and turn exhaustion.
  • mecha chat — a readline REPL with slash commands and input history saved after every accepted line, so a killed process keeps it.
  • mecha tui — full-screen, with the input line live while the agent works: Enter starts a run when idle and steers one already going. Streaming output, scrollback that re-arms follow mode, session persistence and --resume.
  • TUI slash commands/help /tools /model /provider /mode /mcp /usage /todo /triggers /clear /session /exit, with modal pickers, name completion, and mid-session switching of model, provider, permission mode and individual MCP servers. A switch appends a configuration record, so a replay diffs against what actually ran.
  • TUI keys? for a full key reference, ^O to reveal tool output and reasoning retroactively, ^G to compose in $EDITOR, !command to run a shell command locally with no model and no taint, @path completion against the workspace, and Shift+Enter for a newline where the terminal supports the kitty keyboard protocol.
  • TUI rendering — a context fuel gauge that colours at 75% and 90%, a live todo pane, subagent work rendered nested under the call that spawned it (still correct when delegations run in parallel), atomic frame presentation, a tab title that says whether a run is in flight, and cached history cells so drawing no longer costs O(transcript) per streamed token.
  • mecha batch — the same agent over a JSONL file of prompts at bounded concurrency, results streamed to the output file as they finish and keyed by id, so a killed run leaves everything completed so far on disk. Every item gets its own conversation.
  • mecha tools — the tool surface with no provider configured, including each tool's declared capabilities, the active sandbox, which MCP servers are unconfined, and --schema for exactly what the model sees.
  • mecha sessions list | show | path | stats and mecha config show | path | init — inspect saved transcripts, roll up tokens, turns and cost by provider and model over a window of days, and see what settings are in effect.

Tools

  • Six built-in toolsfs_read, fs_write, fs_edit, fs_list, shell and http_fetch — plus todo, a task list the model rewrites as it goes, kept outside the message history so it survives compaction intact.
  • ask_user, registered only by front-ends that own a human, so the model can stop and ask instead of guessing at an under-specified task. Declining is a legitimate answer and returns a tool result, not a failed run.
  • web_search behind a SearchBackend trait with SearXNG, Exa and Tavily tried in order and falling through on failure, and a depth argument selecting a cheap round trip or a deep one.
  • An approval gate with ask, allow and read-only permission modes, plus a planning phase that does not offer writing tools at all rather than offering them and refusing the call — enforced on both the advertised list and the dispatch path, and inherited by subagents.
  • A per-turn tool output budget divided across a turn's concurrent calls, so one runaway tool cannot starve its siblings. What gets cut is written to a spill file, and the marker names the path and the line the elision starts on, so recovering the rest is one read.
  • An MCP stdio client that surfaces remote tools as the same Tool trait, namespaced <server>__<tool> so two servers can both expose a search. It follows nextCursor pagination, accepts JSON-RPC ids in either numeric or string spelling, and routes a server's stderr through tracing instead of the terminal.

Security

  • The path jail — every model-supplied path is canonicalized and proven to sit inside the workspace before anything touches disk; .., symlinks and absolute paths outside the root are refused.
  • The lethal-trifecta interlock — tools declare private_data, untrusted_input, external_send and destructive; the loop tracks which have entered the conversation and refuses any sending tool once both private data and untrusted content are present. It sits ahead of the approver, because a human clicking yes is what an injection is trying to engineer. trifecta = "ask" | "allow" changes the policy deliberately and visibly.
  • Taint is a property of the conversation, not of one run, and it is recorded in the session file — so a new turn does not reset it, resuming does not launder it, and compaction does not summarise it away. A new conversation (a batch item, a subagent, an eval case, a trigger fire) starts clean.
  • block_sends_after_private — an opt-in second control aimed at ordinary privacy leaks rather than injection: any outbound tool is refused once private data is in context.
  • SSRF protections on http_fetch — hostnames are resolved and loopback, private, link-local (including the cloud metadata endpoint) and CGNAT addresses refused; the connection is pinned to the addresses that passed, so a short-TTL DNS answer cannot swap them afterwards; redirects are not followed; allowed_domains and blocked_domains narrow it further.
  • A sandbox for shell[sandbox] kind = "bwrap" | "docker" | "none". A confined command gets the workspace, a read-only system, no home directory, no environment beyond a named allowlist and by default no network. A configured sandbox that does not work stops the run at startup rather than degrading to unconfined execution.
  • MCP servers get the same treatment — the child environment is an allowlist (PATH, HOME, LANG, LC_ALL, TZ, plus whatever env_passthrough names and env sets) rather than an inheritance, per-server sandbox = true confines the process, and per-server network overrides the global switch. [mcp.capabilities] can distrust a server further than its own annotations claim, never less.
  • Bounded output and owner-only storesshell discards beyond a per-stream cap as it arrives rather than buffering without bound and kills a command at its timeout; sessions, the outbox, the learning store, the spill directory and the mail credential directory are created at mode 0700, token files at 0600.

Hooks

  • [[hook]] commands at pre_tool, post_tool and session_end, with the event as one JSON object on stdin, so policy, redaction and logging attach without editing the loop.
  • The dispatch order is interlock, then hook, then approver — a hook can narrow policy and never loosen security, and a pre_tool denial never reaches the human.
  • pre_tool fails closed — exit 0 allows, exit 2 denies, and an undefined exit code, a spawn failure or a timeout also deny. post_tool and session_end are observers whose failures are swallowed. Subagents inherit the parent's hooks, and a typo'd event name is a startup error on every run rather than a warning only on the runs that needed it.

The outbox

  • [outbox] tools names tools whose calls are staged rather than executed: the loop intercepts the call, writes it to ~/.mecha/outbox/, and tells the model it is a draft awaiting release. Draft-only becomes structural, so an email tool — including a third-party MCP server's — needs no knowledge of the outbox to be covered by it.
  • mecha outbox list | show | edit | send | reject — review the queue, edit a draft in $EDITOR, release it (executing the real tool under the store lock so two sends cannot double-fire), or reject it with a reason.
  • Staging skips the interlock and the approver because nothing leaves the machine at stage time; the item records the conversation's taint snapshot, and review warns and confirms when a draft was written with the trifecta armed. A staging that fails returns an error to the model rather than falling through to execution. Subagents inherit the route, and a routed name matching no registered tool warns at every start.

Learning

  • mecha reflect mines recorded transcripts for the moments the user stepped in — a mid-run steer, a denied tool call, a corrective follow-up turn, an edited outbox draft — and asks a model for the reusable lesson behind each, appending it with the session id that proves it.
  • mecha learn consolidates unprocessed reflections into rules/<domain>.learned.toml within a fixed budget and records which reflections it consumed. --holdout keeps a deterministic every-k-th slice out of the pass, so measurement has data the rules never saw.
  • mecha validate probes whether the rules change an answer. Follow-up probes re-ask the corrective turn and are judge-graded; steer and denial probes are counterfactual replays graded structurally on the trace — did the model do the steered thing without the steer, did it repeat the exact call the user refused.
  • A file-based learning store under ~/.mecha/learning/, which is a git repository: git log is the learning history and git revert is the undo. User rules are never written by code, learned rules are freely editable, and an audit record per pass names the rules before and after. Learned rules ride in the system prompt inside the cached prefix, changing only at consolidation time; --no-learned-rules opts out anywhere.
  • Provenance gates what may become a rule — every reflection carries an origin classified by deterministic code from the transcript's recorded taint, and mecha learn excludes anything not clean before a prompt is built. It fails closed: an unknown position, a torn transcript or a reflection written before the field existed all classify as untrusted. Excluded evidence stays in the archive.
  • Unattended learning never applies its own outputmecha learn --propose measures a candidate rule set by counterfactual replay against the deployed one, rejects a candidate that regresses any probe before a human sees it, and stages the rest. mecha proposals list | show | accept | reject is the review, and acceptance checks the live rules still match what the candidate was measured against.
  • Rule tenure — rules carry an id, sources and a creation time; mecha validate appends every probe outcome to a validation ledger keyed to the exact rule set measured, and a regressed trace-graded probe bisects the active rules to name the one that flips it. mecha rules folds that ledger into per-rule tallies and stages retirement through the same proposal gate as any other rule change once a rule accumulates attributed regressions. Retirement is a flag, never a deletion: the rule stays as evidence, the learner is shown it as measured harmful, and rules restore undoes it. A hard per-domain cap on the always-loaded block is warned about at startup and refused in mecha learn, so consolidation may shrink or rewrite an over-cap set but never grow past it.
  • A nightly cyclescripts/ruminate.sh chains reflect, distill, validate, learn and the retirement scan, with a systemd user timer to fire it. Every stage is idempotent, a store writer lock serialises concurrent passes, and a night with the model server down defers entirely rather than half-running.

Distillation

  • mecha distill summarises each closed session into an episode staged to a knowledge-graph MCP server through its kg_upsert tool — evidence rather than belief, so the extracted facts wait in that graph's own review queue. It is idempotent at both ends: a local ledger under the learning store's writer lock, and the graph's own source key making a re-push an update.
  • A tainted session still distills, with its taint snapshot recorded on the episode instead, because losing the record of a real afternoon because a web page was open would gut the memory. Unknown taint is recorded as unknown.

Triggers

  • mecha trigger add | list | show | edit | rm | enable | disable | next | run | tick | daemon | cancel | runs — a prompt on a five-field cron schedule, run unattended. tick fires what is due and exits; daemon is a loop over it, so a crontab line or a systemd timer reaches the same answer and tick --dry-run is an honest preview rather than a second implementation of the schedule. scripts/mecha-triggers.service ships the daemon as a systemd user unit.
  • A hand-rolled cron parser resolved in an IANA zone recorded on the trigger at authoring time, handling daylight saving in both directions: a job inside the spring-forward gap fires at the first instant that exists, and one inside the repeated autumn hour fires once.
  • Missed slots collapse — a machine off for a week owes one run of each trigger rather than a week's worth, and --catch-up (always, never, or a duration) decides whether a stale slot still runs. A skip is written to the ledger, so "why did I not get my briefing" is answerable.
  • Triggers live in ~/.mecha/triggers/, never in the layered config, and a fire loads the global config only — a project file arrives with a cloned repository, and a scheduled agent run is not something a repository should be able to declare. Runs are read-only unless the file says otherwise, with outbox-routed calls still staging under read-only because staging executes nothing.
  • One run per trigger at a time via a flock the kernel releases if the process dies, with the overlap skip recorded. The timeout, a daemon SIGTERM and trigger cancel all cancel rather than abort, so the partial answer and the ledger row survive. A manual trigger run records a row with no slot, so testing a trigger cannot silently disarm its schedule.
  • /triggers in the TUI — see, edit, enable, run and cancel what is scheduled, with the detail view reading the last answer back from the session transcript. Every action shells out to mecha trigger, so firing cannot freeze the interface and the TUI can do nothing the command line cannot.

Compaction

  • [agent] compact_at_tokens summarises the middle of a transcript once the provider-reported prompt size passes it, keeping the task at the top and the recent turns verbatim. Off by default, and derived from context_window at two thirds where that is configured.
  • Superseded tool results are evicted first — when a later call covers the same target, the older result is replaced with a marker naming the recovery, so a write supersedes an earlier read of the file it changed. Errors neither supersede nor get evicted.
  • Old tool results are then thinned — results are truncated from the head with a marker saying so, while the calls themselves are left alone, which keeps the sequence and therefore the agent's place in a traversal. Only if that is not enough is a summary taken, and the cut is chosen so no tool_result is ever orphaned from its tool_use.
  • Summaries are validated before installing — one truncated by its own token limit is refused deterministically, and a second tool-less call reads the summary beside the transcript it replaces and names what is missing, triggering exactly one regeneration. An unusable verdict installs with a warning, because a run that needs to compact to survive must still compact.
  • Overflow recovery — a prompt the provider refuses as too large is recognised across backends, compacted and retried once instead of ending the run.
  • A loop guard armed by compaction — an identical call with an identical result, repeated within a short window after a compaction, stops the run with a distinct loop stop cause rather than burning the turn budget re-living what the summary dropped. Polling never trips it.

Sessions and replay

  • Append-only JSONL transcripts in ~/.mecha/sessions, recording messages, taint checkpoints, usage summaries and a configuration record per attach — provider, model, system prompt, tool list, effort, budgets, permission mode, sandbox, sampler, timezone — so a replay knows what shaped the run rather than diffing two variables at once.
  • mecha replay <session> re-drives a recorded session against the current build using recorded tool results and a sequential loop, rebuilding the run from the recorded configuration rather than today's flags; -p/-m replay against a different model. A structural divergence refuses the call and stops the replay, while an argument-only difference replays the recorded result and is reported for the caller to judge. scripts/replay-regression.sh replays pinned sessions and fails on any drift, turning recorded real work into standing regression cases; pins are machine-local, because transcripts are personal data.

The eval rig

  • mecha eval [cases.jsonl] scores a model on a case set, graded on the tool-call trace first and the text second, with a scorecard broken down by tag and --compare to put models side by side. It exits non-zero on failure, so it doubles as a regression gate on the harness.
  • Deterministic checks — which tools were called, in what order, with what arguments, and what the answer said. Two apply to every case whether it asks or not, because they disqualify a model regardless of the answer: malformed tool arguments and invented tool names.
  • expect.verify runs a command in the case's workspace afterwards and grades the exit code, hashing the test file first so a model that edits the tests until they pass still fails. expect.judge grades a rubric with a second model, for cases whose right answer is a judgement.
  • Run-metadata checksstop_cause, taint, blocked_sends and min_compactions, which are the only way to grade the harness rather than the model, since none of it appears in the answer text.
  • Per-case controlssandbox for a private copy of the fixture with writes allowed, max_turns for a case that genuinely takes twenty steps, compact_at_tokens to force compaction for one case alone, and a list-valued prompt for several turns on one conversation.
  • --runs k repeats every case k times and reports pass^k beside pass@k, with independent workspaces per run and a warning when a pinned seed at concurrency 1 would make the k samples one sample counted k times. --ab-rules runs the set rules-free and then rules-on and reports the per-case flips as their own artifact rather than as a comparable scorecard.
  • Reproducibility by construction — eval forces MCP, hooks, learned rules, the outbox and provider fallback off, because a scorecard shaped by one machine's local configuration is not comparable to anyone else's.
  • Generated fixtures and a second case setscripts/build-eval-fixtures.py rewrites the workspace, prints the gold answers the cases must assert, and checks that each code kata fails as shipped and is solvable by a reference fix; eval/pkg-cases.jsonl runs against fixture MCP servers via --mcp-file and grades the trifecta interlock end to end, offline.

mecha-mail

  • A library plus three MCP binaries — Gmail and Google Calendar v3, Outlook mail and calendar over Graph, both OAuth flows and the token lifecycle. The library is what a GUI would depend on directly; mecha-google and mecha-outlook each serve one provider with its own credential store.
  • mecha-mail, the account-based surface deployments should wire: every account in ~/.mecha/mail/ behind one provider-neutral set of tools, so neither mecha nor the model knows Google or Microsoft exists. Account names are baked into every tool schema as an enum at startup, so the model picks from real names. Reads fan out across every mailbox and tag each row with its account; item operations name the account their id came from; creates use the configured default or ask the user. A failed account is reported beside the other accounts' results rather than sinking the whole call.
  • mecha-mail auth <name> --provider ..., with import to copy a legacy per-provider login in and default <name> to name the sending account. Microsoft signs in with device code, so it needs no redirect URI and no forwarded port, and works over SSH.
  • Unified repliesmail_reply takes a thread id and answers the newest message, synthesizing Gmail's addressing (answer the sender, keep everyone on reply-all, never the user's own address) where Graph does it natively.
  • The token lifecycle in Rust — credentials at mode 0600, refresh ahead of expiry behind a lock so two concurrent tool calls cannot race, one forced refresh and retry on a 401, retry with backoff on 429 and 5xx, and an HTML-to-text fallback so an HTML-only email no longer reaches the model as an empty body.
  • Capability labelling that matches the risk — reads are untrusted sources but not send sinks, because a search query travels only to the party that already custodies the mailbox; sends and calendar writes reach third parties, are marked open-world, and are named in [outbox] tools so they stage rather than deliver. A drafted to, cc, bcc or subject containing CR, LF or NUL is refused rather than stripped, so it cannot smuggle a hidden recipient into a raw message.
  • Timezone rendering[agent] timezone is an IANA name that rides in the system prompt and reaches the mail servers as MECHA_TZ, so event times are rendered in the user's zone before the model ever sees them.

Testing, benchmarking and docs

  • A three-layer test suite — unit tests for anything that is a function of our own code (including a ScriptedProvider that exercises tool dispatch, denials, exhaustion and recovery with no network), integration tests for what is deterministic but needs real execution (docker actually confining a command, an MCP server actually receiving an environment), and eval cases for what only emerges with a model in the loop. MECHA_TEST_REQUIRE_BACKENDS=1 turns every environment-based skip into a failure, because in CI a silently skipped test reads exactly like a passing one.
  • A Harbor adapter (bench/) that installs the mecha binary inside a Terminal-Bench task container and runs it there, so mecha's own loop, tools, path jail and budgets are what get measured, under the same no-MCP/no-hooks/no-outbox posture eval forces.
  • Research and design notes under docs/ covering context management, memory and rule lifecycle, verification, sandboxing, prior art, public benchmarks, the TUI survey, and a branching design recorded as a deliberate non-implementation.