Commit Graph
58 Commits
Author SHA1 Message Date
esengine 59fc0b6520 fix(sandbox): strip the token's privileges, refuse a launcher started from a restricted process, and state what Windows bounds
The restricted token now drops every privilege but the traverse check, so backup
semantics cannot write outside the roots even from an elevated session. A SID is
synthetic only if no account resolves it. The launcher refuses to run under a
restricted token (a command's own launcher inherits it), grants only session temp
folders, hands the child just the standard handles, runs it in a job with UI
limits, and the default DACL and the grant mask are narrower. Links are not
followed at grant time and a link root is refused. REASONIX.md, the guide, the
Settings text and the release note list what is and is not bounded.
2026-10-19 19:54:07 -07:00
esengine 8126f5cf1e chore(security): declare the Git push path and the host-only route list sensitive
The push is the one git action Reasonix performs that leaves the machine, and
the host-only route list decides which requests a paired device may make. Both
now demand review plus security_review and are held to the coverage they have:
gitpush 84.0, serve/git_push.go 77.0, serve/host_routes.go 93.0, and
control/git_push.go 0 (its behaviour is covered by the serve and boot effect
tests, which the per-package gate does not count).
2026-10-18 09:17:17 -07:00
esengine 11aa4014d0 fix(providers): keep key-carrying provider requests on the configured origin
Provider clients took netclient.NewHTTPClient, so a redirect from the
configured address was followed by Go's default policy and the key went with
it. Add redirectguard.StayOnOrigin, build the probe, provider, System One,
balance and doctor clients through endpointclient.New, and guard the default
clients of the model listing, balance and System One calls. A refused redirect
is a permanent error that names the target host and reaches the user as
redirect_refused instead of a network fault retried ten times.
2026-10-11 14:00:48 -07:00
yhh ca730caab2 feat(control): grant a workspace extra folders, vetted and treated as workspace
Extra roots are the project grant's folders in the user's home. They are a set
of their own: write roots and the OS jail read them, the guard on Reasonix's
own state does not treat them as exemptions, the turn tail names them, and the
scan, readiness and receipt of the executor and of every sub-agent count them
as workspace. One judgement (FolderGrantRefusal, shared with workspace trust
and POST /sandbox) refuses drive roots, home and its ancestors, storage roots,
key and credential directories and the forbid list, in either direction,
comparing file identity rather than spelling, plus folders the user does not
own, UNC and device-namespace paths, and overlaps with other write roots. Every
load vets each grant again. Withdrawing a root rebuilds every pane on the
workspace, after this pane's lock is released.
2026-10-11 05:40:15 -07:00
yhh af6dfb36a2 docs(memory): split the saved-fact list item to bring REASONIX.md back under its prose budget 2026-10-06 21:54:43 -07:00
esengine 9c3ab6ffda docs(memory): the saved-fact index is not in the stable prefix
101f2ca12 stopped composing the fact index into the cached prefix, but
SESSION_MEMORY_RETRIEVAL.md and REASONIX.md still said it joins the prefix
at session start. Only pinned bodies and standing instructions do; the
index is reached through the memory tool.
2026-10-06 21:29:55 -07:00
esengine 1bc7fc11e9 docs: add the change discipline rules to REASONIX.md 2026-10-02 22:55:56 -07:00
c020627 9a999f7b7b docs: port the retired-Wails wording cleanup to studio
Wails stopped being the desktop shell when the Electron shell landed, but
seven places on `studio` still describe it in the present tense. Six of them
were already fixed on `main-v2` by #10018; they only need the same edit
carried over here.

- `CHECKPOINTS`: the desktop rewind control calls `controller.Rewind` over the
  desktop host protocol, not the Wails binding.
- `TASK_CONTRACT`: the constraint keeps the desktop host JSON contract intact.
- `RECOVERY`: `doctor` inspects install problems without loading the desktop
  shell, and macOS keeps LaunchServices launching the desktop app bundle.
- `DESKTOP_CRASH_DIAGNOSTICS_RUNBOOK`: the budget is pre-shell work, and
  report/metric persistence runs after shell startup.
- `SESSION_MEMORY_RETRIEVAL`: the example project file no longer points at
  `desktop/frontend/wailsjs/`, which went away with the Wails shell. Studio
  commits its generated files under `internal/ext/extension/protocol/`.
- `REASONIX.md`: the frontends behind `control.Controller` are the chat TUI,
  HTTP/SSE serve and the Electron desktop.
- `issue-auto-label`: the classifier prompt no longer tells the model the
  desktop app is Wails.
2026-09-26 14:08:56 +08:00
esengine 45f28b6778 feat(sandbox): bash egress can be limited to named domains
Network in the bash sandbox was one bit: open to everything, or shut. With
[sandbox] allowed_domains set, bash's external traffic goes through a
host-side proxy (internal/safety/egress) that admits only the listed hosts;
denied_domains outrank them.

- The proxy serves CONNECT and absolute-URI HTTP on loopback, decides from
  the requested name, refuses names that resolve only to loopback,
  link-local, metadata or this host's own addresses, and chains through the
  upstream HTTP or SOCKS5 proxy the environment names.
- macOS: the Seatbelt profile shuts every destination but loopback, Unix
  sockets and the proxy port, and closes the loopback ports of the proxies
  the environment and the system proxy settings name. Loopback stays open
  so tests and dev servers keep working. Bind, inbound and outbound are
  separate grants, because (local ip "localhost:*") on network* matches the
  local end of every outbound connection and admits all of them.
- Each confined launch carries a token as proxy userinfo; what the proxy
  refused under it is appended to the bash result by the host, so a policy
  refusal reaches the model as one instead of a bare connection error.
- The list only narrows open network: it applies where network is on and
  the sandbox confines; a repo may set one only where the user has not, and
  its denials only add to the user's.
- Linux and Windows warn that the list is not enforced there.

Known gaps, stated where they live: a host proxy on a loopback port nobody
declared stays reachable, name lookups still reach the system resolver,
the decision reads the requested name (domain fronting is not caught), and
MCP servers are not routed through the proxy.

internal/safety/egress is declared sensitive, with its coverage floor.
2026-09-24 20:26:16 -07:00
yhh 9ca5da9aa5 refactor(agent): the context window owns its state and says what it needs
Projection, compaction and token estimation were 121 methods on *Agent,
free to touch any of its state. They now form one subsystem inside the
agent package:

- windowState holds what the window owns for a conversation: the
  installed projection, the compaction in flight, the pressure latches.
  It used to sit as eight loose fields on sessionRuntime.
  sessionRuntime.reset hands a new conversation to windowState.reset, and
  the lifetime guard in sessionstate_test.go now holds both structs to
  their reset and carry-over lists.
- The window's methods live on *contextWindow, which carries only its
  state, the session, the configuration, the services and a windowHost.
- windowHost is everything the window asks of the loop: eight callbacks
  (the todo and host-context tails, tool facts, the provider projection,
  preparing a sampling request, the extension compaction intercepts,
  failure annotation) and Session. It was derived from the compiler, not
  written by hand, so it is the whole dependency.
- The loop reaches the window through a.window(). The agent's public
  context API (ContextBudget, CacheState, LoadProjectionSidecar and the
  rest) is unchanged and forwards to it.

The new repolint rule window-state holds the boundary. Only methods on
the window's own types may read sess.win, and the owners are declared in
tools/repolint/windowstate.go. REASONIX.md names the boundary.

Behaviour is unchanged. No test was added or removed. Tests reach the
window's internals through a.window() like the loop does.
2026-09-23 23:38:19 -07:00
yhh a534ead0ab refactor(delegation): sub-agent orchestration leaves the agent package
The task tool, read-only tasks, fleets, parallel tasks, the sub-agent
store and records, profiles and the child's capability grant move from
internal/runtime/agent to internal/runtime/delegation. They are clients
of the agent: they build child agents and run them. The executor loop
now names none of them.

What the loop itself does for a child stays in agent. child_run.go holds
running an agent as a child and composing its answer and host receipts.
child_registry.go holds shaping the child's tool surface. Delegation uses
a small exported surface for these: AppendHostReceipts, AttachReviewReport,
CompleteSubtaskContract, ExpandToolPatterns, MaxToolOutputBytes,
NormalizeToolSchemas, SubagentImageNote, VisionRefFor, WithCallContext
and the host-receipt labels.

Tests followed the code. The ones that check the agent's own judgements
against the real delegation tools (batch skipping, the plan-mode barrier,
contract hints, delegation profiles across the proxy) run as agent_test
in the agent directory through export_test.go. The user-turn retention
test is split so each package pins its half. Test helpers used on both
sides are copied, since neither test binary may import the other. One
test more than before, from that split; none dropped.

Baseline: the moved files carry their debt unchanged under the new path,
and task.go gains the one import line the move costs (-allow-widen).
2026-09-23 22:36:35 -07:00
yhh 79ee58ff70 refactor(billing): pricing is data, asking for a balance is not
model/billing held two things: what a call costs (fixed-point money, the
official rate tables, quotes and the ledger) and asking a provider for its
wallet balance over HTTP. Configuration, the event stream and its wire form
needed only the first, and reached up a layer to get it.

The data and arithmetic move to contract/pricing; model/billing keeps the
wallet query (balance, cache, the response shapes) and reads pricing like
everyone else. Names and behaviour are unchanged; callers say pricing.X for
what moved. MigrateLegacyUsage's orphan finding travels with its file
(-allow-widen for the carried entry).

layer-order debt: 40 -> 32.
2026-09-23 18:58:40 -07:00
yhh 394da0a313 refactor: the kernel's packages live in the layer they belong to
internal/ was 141 packages side by side, so nothing but a reader's memory
said which were foundations and which assembled the rest. Each now sits in a
layer directory, and the directory is the declaration:

  base      L0  foundation with no domain in it
  contract  L1  types and interfaces the layers above agree on
  model, tools, safety, ext, state, platform
            L2  subsystems, peers of one another
  runtime   L3  the turn engine
  session   L4  the controller
  assembly  L5  the composition root
  frontend  L6  cli, serve, acp and the host adapters

Only paths change: package names, code and behaviour do not. Imports,
repolint and coverage baselines, sensitive paths, CI package lists and docs
follow the new paths; release notes keep the paths they were written with,
and sample paths inside tests and fixtures keep theirs.

repolint gains layer-order: a package may import its own layer and those
below it, read from where it lives. The 47 imports that point upward today
are recorded as that rule's opening debt (-allow-widen, a new rule's first
baseline) and can only go down. Most of it is config, which still reaches
into eight subsystems and is the next thing to take apart; the rest is
event, history and a few tools reaching into runtime for types that belong
in contract.
2026-09-23 18:28:21 -07:00
esengine 049ee1a75d feat(computer): the agent can read and operate this Mac's other applications
Two tools over a native helper. computer_read lists the applications a person
can see (what=apps), reads one's accessibility tree with host-issued [aN] refs
(snapshot), or captures its front window (screenshot). computer_act runs steps
in order on one application — click by ref or at x/y in the latest
screenshot's pixels, focus, set_value, type, key — stops at the first failure
with its code, and returns the application's tree afterwards. An application
is named by its bundle id.

Nothing takes the person's pointer or the foreground. A click is an
accessibility action on the element under the point, and only an element of
the application the call names takes it; keys go to that application's
process. An element with no accessibility action answers computer.no_action
instead of being clicked some other way. The helper draws its own cursor where
it acts, and Escape pressed while that cursor shows stops the run between
steps (computer.stopped). Terminals, the keychain, password managers, System
Settings, authorization prompts and Studio itself are refused before the
helper is asked. A missing Accessibility or Screen Recording grant is requested
from the system once per helper, and the failure tells the model what the
person has to switch on and where.

desktop/computer-helper is the helper, in Swift: JSON lines on stdin/stdout,
built for macOS 12 with ScreenCaptureKit checked at run time. internal/computer
owns everything it is not: which applications, what a ref means, and the
screenshot geometry that turns image pixels into screen points. It is a leaf
package and declared sensitive, with a coverage floor of 85.8 from tests that
run the test binary as a fake helper; live_test.go drives a real application
through the real helper when REASONIX_LIVE_COMPUTER names one.

Permission: both tools form the Computer rule group, granted per application
as Computer=<bundle id>. A call on another application needs a person the way
typing a secret into a site does — auto, a glob and a bare rule do not answer
it; a person, the session grant they gave for that application, an exact rule,
or YOLO does. Headless auto refuses and says why. computer_act is an unknown
mutation for evidence, since an application can write into the workspace and
names no paths; computer_read is none.

The tools reach a provider only in a host that has a helper:
reasonix-studio-host takes -computer-helper, and no other host passes one, so
the default prefix and its goldens do not move. The built-in schema budget
is widened 6049 -> 6346 for these two schemas, which only a Studio session on
macOS carries.
2026-09-17 09:42:38 -07:00
esengine 20a7a0cf6b feat(browser): drive an installed Chromium over its debugging protocol
A kernel package for an agent that has to see and operate web pages, with no
frontend attached: the tools and the Studio panel sit on top of it.

It finds a Chrome, Edge or Chromium already on the machine (never downloads
one), runs it on a profile of its own, and drives it over
--remote-debugging-pipe; Windows cannot hand a child descriptors 3 and 4, so
there it falls back to a loopback port. One process per profile is shared by
every session through a Pool, since a profile admits one process.

A page is rendered from the accessibility tree, one element per line, with a
ref the package issues, never reuses, and retires when its document navigates
away — so a stale ref fails as browser.stale_ref instead of landing on
whatever replaced the element. Inputs are real pointer and keyboard events; a
pointer input whose point another element would receive is refused as
browser.covered, naming that element. A JavaScript dialog stops the page from
answering anything that runs on it, so page-bound commands fail at once while
one is open and are abandoned when one opens mid-call. Every failure carries a
Code.

checkURL bounds a page to http(s), about:blank and files inside the workspace
once symlinks resolve; a page reached past that by a link is refused when read
or acted on. The package is declared sensitive, and a utility-layer leaf.

Live tests (REASONIX_LIVE_BROWSER=<executable>) cover both transports, forms,
selects, covering elements, dialogs, popups, stale refs and screenshot
coordinates; they skip where no browser is installed.
2026-09-17 07:47:37 -07:00
esengine f352dfbecc chore: remove the IM bot gateway
Studio has no bot surface. On this line the QQ / Feishu / WeChat gateway
is reachable only as `reasonix bot`, a CLI command no Studio user starts,
and it carries about 12.9k lines: internal/bot, internal/botruntime, the
subcommand, its config section and its docs. It is also where the recent
Windows heap-corruption and gateway-wedge fixes had to land.

What goes with it:

- `reasonix bot`, its completion spec and its usage line in three locales.
- The typed [bot] config, its defaults, render block, model-ref and
  migration hooks, and the 0.x QQ import.
- GatewayAPI, the control port only the gateway named, and the two
  passive inbox variants only it called; neterr.IsTransient and
  textutil.FitGraphemeBytes, which only it used.
- The bot entries in repolint's layer and frontend-parity declarations,
  the bot race-test target in CI, BOT_GUIDE.md and its images, and the
  [bot] example.

What stays, and why:

- The user config is shared with 1.x installs, which still run the
  gateway. [bot] now decodes into an opaque table and is written back
  unchanged on every save, so a settings change here does not switch
  that gateway off or erase its allowlists. Credential variables the
  table names are still kept out of subprocess environments.
- surface.Bot stays: usage records on disk carry it, and dropping it
  would re-attribute them to another frontend.
- SECURITY.md and the crash-report worker still describe the bot, since
  they serve every release line.

Budgets only fall: frontend-parity drops by 20 edges and the reclaimed
headroom is recorded with -update.
2026-09-17 02:02:48 -07:00
esengine 24599c2fca refactor(control): controller.go loses what already had a home
Seven groups of methods move to the file their subject already owns: skills to
skill.go beside skillSet, checkpoints and summarize to checkpoint.go beside
checkpointManager, memory to memory_command.go, the in-flight and interrupted
turn bookkeeping to turn_cancel.go (where appendUniqueString's four callers
already were), branches to branches.go, session artifacts and recovery notices
to session_snapshot.go, and the goal verbs to a new goal_control.go rather than
into goal.go, which is over the ceiling already.

controller.go goes 3232 -> 2024. The essay, frontend-parity, complexity and
function-size budgets are unchanged to the point — this is the same code under
the same rules — while file-size drops 1208, which is what a 2400-line overage
becoming a 1200-line one is worth. The baseline is updated with -allow-widen
because an extraction moves each file's share, and REASONIX.md asks for that to
be visible in the command.

The approval bridge earns more than a move. gateApprover decides whether a tool
call runs, and it sat in controller.go — outside the sensitive paths, which
name approval.go and approval_orchestration.go and could not reach it there.
approval_bridge.go is declared sensitive, so the review and the coverage floor
now cover it: 77%, against 94% and 91% for the two files beside it.

Two coverage floors move on their own merit — approval_bridge.go is new, and
shellsafe rose to 81.6% when the redirect guard got the tests its own comment
promised. The rest are restored to what they were: `covergate -update` had
raised every floor to one run's reading, and providerbroker's 83.6% was seen at
83.1% on the next, which is a gate that fails for no reason.
2026-09-16 03:16:19 -07:00
esengine 577213b919 refactor(studio): stylesheets carry no commentary
668 comment blocks, 1500 lines, met one aside at a time while reading
rules. Almost none of them restated the CSS: they are measured colours,
layout mechanics, and why the obvious rule is the wrong one — knowledge
worth more than its placement.

It moves to two companion documents read end to end. TOKENS.md holds the
vocabulary's law: why --float exists apart from --raised, what the
categorical palette was measured against, why the type floor is 11px.
LAYOUT.md follows app.css rule by rule.

No declaration moved: both files are rule-for-rule identical to what
they were, apart from the diff gutter taking --faint and the plan's
completed rows losing the opacity that dimmed them twice.

REASONIX.md states the rule and where a constraint goes: to the
component that has to honour it, or to the stylesheet's companion when
the decision is about the medium itself. It also states that comments
are written in English — commentary in two languages is two codebases,
and the half a reader cannot read is the half that rots.
2026-09-14 20:46:53 -07:00
esengine 01eda36380 docs: the comment rule says what register it means and who holds it
"Never: incident or conversation history" is a rule nobody recognises breaking.
A writer with a fix in hand narrates the fix, and the result passes every gate
there is, because the essay rule compares line counts and nothing reads
register. Meanwhile the instruction to write code that reads like the code
around it is unambiguous, and much of the tree reads as prose — so the tree
teaches the habit back faster than the rule removes it.

The rule now states its test — a reader who has never seen an earlier version
must lose nothing, and past tense about this code's own behaviour is the tell —
gives the paired example, says where the narrative does belong, and says that it
outranks the surrounding style. It also says no gate reads register and why one
will not be built: separating narrative from a statement of constraint needs a
phrase table, which is the one thing this repo does not build. That rule is held
by review, and saying so is better than implying a check exists.

repolint's paragraph no longer claims to enforce all of it.
2026-09-14 01:16:39 -07:00
esengine e6db271d9f feat(config): the DeepSeek catalog is one declaration, and its rates are one table
The flash model is `deepseek-flash` everywhere: the id it is served under
lost the generation it never needed. An installed entry is renamed rather
than left behind, and the rename moves every key that named the old id with
it — a price or an effort list stranded under the old key stops answering
for the model that replaced it, which reads downstream as a setting the
user edited reverting to its default.

A preset is now what settles which of its models read images. Reading a
name misses every Kimi, Qwen, MiniMax and Claude model these presets
declare, and `deepseek-flash` spells nothing at all; the spelling stays the
last resort for an address nothing has declared, and stays a correctable
suggestion rather than a claim. The connection that existed only to reach an
image-taking model folds into the entry beside it once that entry reaches
the same model on the same key — unless its user configured it, because
folding would answer their edit by deleting where it lived.

A preset changing shape reaches installs holding the old one through one
declared shape per generation and one upgrade that brings a matching entry
forward. The guard is the whole shape, because an entry holding what we
shipped is ours to move and anything else its user curated. That retires
nine hand-written migrations that each recognised one vendor by hand.

Official list prices live once, in `officialRates`, oldest generation first.
A price change appends rather than edits: the superseded rate is what lets
an installed config be recognised as holding one of ours and brought
forward, instead of quoting a retired rate for the rest of its life. `config`
reads them through `billing.CurrentRate` and `SupersededRates` and restates
none of them — a table written twice is one that drifts, which is how a
vendor's USD rate ended up published on one side only.

`make pricecheck` reads each vendor's own page and compares it against that
table, and says how long ago a person last confirmed each one. It reaches
the network, so it stays out of lint and out of CI. A page it cannot read
exits non-zero: unread is its own verdict and never counts as agreement.
2026-09-13 18:47:33 -07:00
yhh 3bd382c365 fix(repolint): make check opens files git has not been told about
The narrowed standards gate built its universe from `git diff --name-only
HEAD`, which answers what the worktree changed against HEAD and says nothing
about a file git has not started tracking. Three new files reached `make check`
as clean while the full sweep found a violation in one of them. Not a wrong
judgement — a measurement universe missing a piece, and a green meaning only
that the gate never opened the file.

The universe moves into Go as `-changed` rather than growing another expression
in the recipe, because the defect was in the universe and a universe computed in
a Makefile cannot be tested. `trackedChangedPaths` is kept as its own function
though nothing in production calls it: it is the incomplete universe, and a test
that cannot express the incomplete one cannot prove the complete one fixed
anything. That is what the second test does — one finding, two universes, two
verdicts — and the first fails on purpose if `git diff` ever starts reporting
untracked files, because the witness would be gone.

Deleted paths stay out. A path the change removed cannot be scanned, and handing
one to the scanner asks it to fail on an absence the change intended.

REASONIX.md now names `make check` as the owner of that universe rather than
naming the flags behind it. The rule being preserved is that the evidence entry
point is the make target; which flags implement it is not the contract, and
writing them down is how the document went stale the first time.

CI is unaffected: it runs the full sweep with no narrowing, so the hole existed
only in the local check cited as evidence that a change owed nothing.
2026-09-08 20:54:38 -07:00
esengine ba82314ff0 docs: what the studio round settled, as rules rather than as history
Seven changes to the desktop's presentation layer, and what is worth keeping
from them is not the diff. Each rule below is held by a guard that fails
when it is broken, which is why they are standing instructions rather than a
changelog:

  38c1477  transcript visual hierarchy
  49b5746  turn-policy ownership
  637c0ff  certified primitive callback roots
  47e518f  anchored pane-close confirmation
  3966791  settings discovery and apply truth
  b0af1d1  motion has explicit causes
  2ebfff6  lifecycle-aware inspector composition

Seal A of the action census stayed held throughout, and the one time Seal B
moved it was the instrument being corrected rather than debt being added:
certifying a primitive's callbacks brought the Picker call sites into the
universe, one of which switches the model and had never been counted.
2026-09-08 07:28:34 -07:00
esengine 260390efe6 fix(serve): a disk that would not write is not a bad request
Seven exits in catalog.go answered 400 for everything, and behind each of them
were at least two different things: a skill nobody declared and an activation
file that would not open, a server that would not start and a lock another
process held. The endpoint was not the owner of any of it — it was reading
whatever error arrived and picking one status — so a failed disk told the user
their request was wrong, and a frontend had nothing to tell the two apart by.

The census came first and it changed what this fixes. There is no stable domain
family here to code: exactly two domain conditions exist, both fmt.Errorf, and
one of them was spelled out in three separate files. And they are the same fact
— a named thing is not there — which this tree already carries an identity for.
So the domain half needed no new codes at all. It needed the owners to say what
they meant: skill.NotFoundError where skills are resolved, config.ServerNotFoundError
where configured servers are, and serve's second copy of the MCP judgement
deleted rather than typed a second time.

Deliberately not one ErrUnknownCapability. Skills and MCP servers both happen to
have an unknown-name condition today; that is a similarity in wording, not a
shared owner, lifecycle or namespace. If a third and fourth turn up with the
same lookup contract, that is when it is a fact worth merging.

The activation store now says which way it failed. A malformed file is
UnparsedFile — the identity that already carries a path, a line and an excerpt,
because it is the one internal-looking failure a person can go and fix — and
read, write and lock failures are one sentinel, because they are one answer to
whoever flipped the switch: it did not stick, and none of them is their doing.
Notably the malformed arm is fixed by this alone: reverting the transport
classifier leaves that test green, which is what "the owner is where this
belongs" looks like from the outside.

The join in SetMCPServerEnabled kept one fact and lost the worse one. A server
that will not start and whose switch was put back, and one whose switch could
not be put back, are not the same place to be left standing — the second is
persisted state disagreeing with the runtime, and it is now checked before the
failure it accompanies rather than folded into it.

Dependency answers 409 rather than a status invented for it. The request was
fine and the world does not satisfy it, which is what this tree's other "not a
fault" refusals answer; whether a server that will not start deserves a class of
its own is for the MCP failure taxonomy to settle, and that is its own probe.

An unclassified failure is now internal rather than a bad request. A failure
nobody named is not evidence that the request was wrong, and the old default
said it was.

One stale claim came out of the same file: skillEnabled's doc still said the
runtime serves the old prompt index until a rebuild, which stopped being true
before this branch started and was fixed in four other places.
2026-09-05 19:25:15 -07:00
esengine e8b6fbcc8e feat(control): standing instructions are asked for, not announced
The last open row in the projection rules. Standing instructions rode the turn
already — they were never in the prefix, which is why classifying them as prefix
configuration would have been a move rather than a description — but they
reached a live session only through the writers that published them. An edit by
an editor, a script or a branch switch was in the canonical set and not in the
next turn.

They are now rendered from disk each turn and compared with what the model has,
the same shape the skills listing took. Discovery is the narrow one: the
instruction documents, not the fact store and its recall index, at 272µs p50
against a turn measured in seconds. A session that never resolved a workspace
renders nothing rather than discovering from the process directory, which is
what it was accidentally doing the moment discovery moved onto the turn.

Two rows this closes are not edits. A removal is owed once as a removal: silence
would leave the rules the model already holds reading as current, which is the
failure a deletion is most likely to cause and the one nothing would report. And
a rewrite of the same bytes is not a change — the comparison is against the
rendered block, so touching the file costs nothing.

Delivery, not composition, settles a debt. A turn can still be blocked by a hook
or refused by an extension after it composed, and clearing the debt there would
leave the model holding state nobody sent it; runSettled is the hand-off point
because everything that can refuse the turn has already run. A provider failure
after that is not a lost delivery — the composed message is in the session and
rides the next request, which was measured rather than assumed.

Two tests changed because they measured the old contract. Both composed twice
without running a turn and expected the second compose to be silent; under
delivery-settles they correctly owe it again, so one now pins exactly that and
the other settles explicitly where a real turn would.
2026-09-05 06:55:57 -07:00
esengine 48ce86cb12 docs: freshness may not rest on being told
13a made the durable skill switch a live registry input, which is the same rule
the out-of-band file write already forced and worth stating once rather than
rediscovering per input: a switch flipped, a file edited past every API and a
plugin dropped into a directory are one question, and the registry answers it
from canonical state.

Its second half matters as much and is easy to lose: a change no projection
renders is not a change. Without it, "detect every mutation" invites mtime and
event streams back in, and both re-send listings nobody's model would read
differently — freshness bought out of the cache stability it rides on.

The section also stops implying the original hypothesis was right. Moving memory
and skills out of the prefix was the plan this phase started with; measurement
found them already there and freshness ownership missing instead.
2026-09-05 06:37:53 -07:00
esengine e9c96c3d6a docs: four rules for who owns the model's view of dynamic state
12a and 12b were one question asked twice. The prefix design was already right;
what was missing both times was the rule for when a once-delivered projection
becomes owed again, and both times the answer had been left to whoever happened
to write the state.

The four rules are what the matrix in internal/boot/context_projection_test.go
now holds, written down so they bind the next dynamic fact rather than being
rediscovered by it. Prefix ownership names pinned memory as the one declared
exception rather than leaving it as an unexplained violation, which a rule
phrased as "no canonical fact may move the prefix" would have made it. Freshness
says what "eventually" is not. Ownership is the one this phase actually cost:
the owner of a fact owns the invalidation, because a projection kept fresh by
callers remembering is one forgotten call from stale. Determinism is already
true and is frozen before something makes it accidentally false.

The open row is stated rather than quietly carried: standing instructions still
reach a live session only through the writers that publish them, so an edit to
REASONIX.md made outside them waits for the next build. That is measured, not
assumed — the same probe that closed the skills row shows this one open — and it
is the shape the listing had before it started asking. Fixing it is a decision
about memory, not a consequence of this phase.

The cache-first convention loses "memory" from its parenthetical: the index left
the prefix some time ago and only pinned bodies remain, so the list was naming a
fact that had moved.
2026-09-05 05:48:41 -07:00
esengine cd5d82ff6b refactor(control): move the approval orchestration, and the gates that guard it
What waits on the user and which call answers it — tool approvals, plan
decisions, asks, the pending-prompt replay a reattaching frontend needs, and
the modes that decide whether any of it is reached — is one responsibility, and
it was the last cohesive cluster sharing controller.go with everything else.
approvalManager already owns the bookkeeping; this is the orchestration half,
the part that emits, records a receipt and rebuilds a gate.

Verbatim move, 24 declarations: all 194 Controller methods present, none
duplicated, no body differs by a byte. controller.go 4,649 -> 4,092, which is
5,837 -> 4,092 across the three splits.

The move is only half of it. internal/control/approval.go is a declared
sensitive path, so code leaving it for a file that is not one would drop out of
both effects that declaration has: the review gate, and the coverage floor.
REASONIX.md declares the new file too, which is why this could not be a tidy-up
— and the floor is real, recorded at 90.7% and sabotage-checked by raising it,
which fails the gate.

Two things surfaced while recording it, neither mine:

internal/providerbroker/** was declared sensitive without a floor ever being
recorded, so it has been passing the gate vacuously since it landed. It has one
now. Its coverage flickers between 83.1% and 83.6% across runs (5 and 2 of 7),
so the floor is the observed lower bound: a floor set at the higher number is a
gate that fails at random, which teaches people to rerun CI rather than read it.

internal/sandbox/** rises 73.5% -> 78.1%, which is a ratchet catching up with
coverage already written.
2026-09-02 16:48:53 -07:00
yhh bb9823743e feat(remote): make a first connect stop being a setup task
Two things stood between an SSH host and a working session, and neither
was the connection. That host needed a reasonix installed on it, and it
needed the model provider configured a second time, with its own API key
and its own route to the internet.

Model credentials now stay here. A remote session resolves providers
over a reverse forward back to a broker this machine runs on loopback:
the Selection and Request cross the tunnel, the provider is built and
run at home, and the answer streams back. The far side holds an address
on its own loopback and a token, and nothing else — no endpoint, no key,
no headers, no proxy settings — so a host with no egress to the model
API works, and the key is never written to its disk.

The provider runs here rather than the tunnel reverse-proxying its HTTP
because endpoint identity decides request shaping in dozens of places
across the adapters: DeepSeek's beta prefix endpoint, Kimi's K3
contract, Gemini's model resource names, MiniMax's reasoning split. All
of them read a base URL, and a remote pointed at 127.0.0.1 would take
the default branch of every one, silently.

For the same reason errors cross as identities rather than sentences.
IsContextOverflow reads *provider.APIError's status and the code inside
its body; flattened to a string it classifies as nothing, and a remote
turn would fail where a local one compacts and replays.

Getting the kernel there is a transfer, not an install — the binary is a
static CGO_ENABLED=0 build. So auto has the host download its own
release first: this side resolves SHA256SUMS and hands over the URL and
the digest, and the host fetches over its own link, which for a machine
in a datacenter is far faster than a laptop pushing 62MB of extracted
binary. The digest is resolved here on purpose: a host fetching both the
archive and its checksums would be verifying one against the other from
the same network. npm moves from first to last, because it installs the
identical static binary and needs Node to do it, and it stays only
because a network that mirrors npm while throttling GitHub is a real one.
That order lives in one list both the installer and the probe read, so a
probe cannot promise a route the installer would not take.

Two guards this needed. A serve resolves providers over the address it
was launched with, so one bound to a retired broker is not reusable. And
retireOutdated, which stopped only kernels below the version floor,
becomes retireReplaced: whatever made a record unreusable, leaving its
process running orphans it, since that record is the only note this side
keeps of the pid.

Per-host `provider = "local" | "remote"` picks which side answers, and
defaults to local. `reasonix remote serve start` never publishes a
broker: that serve is meant to outlive the command that started it, and
a broker published from a command that has exited is a port nobody holds.

Verified against a real SSH reverse forward, not only against the codec:
a kernel holding no credentials completes a turn, an overflow keeps its
classification, and a tunnel dropped mid-answer reads as an interruption
the agent replays rather than a completion it commits. The fetch script
is executed rather than string-matched — it installs a served release,
refuses a mismatched digest, and leaves nothing behind on a 404. The
three EnsureServe cases model a POSIX remote and skip on Windows; they
were cross-vetted for linux and run in CI.
2026-09-02 00:06:48 -07:00
esengine dddbe21a1a refactor(net): one redirect guard, three trust decisions
The same judgement existed three times: validateCLIUpgradeRedirect,
validateOfficialRedirect, and the update package's TrustedRedirect. The
first and third are line-for-line identical apart from an error prefix and
the name of the host predicate; the second is the same rules folded into
one condition. The third had never been wired to a client at all, which is
the shape this kind of duplication fails in -- a copy nobody is looking at
stops being maintained before anyone notices it stopped being called.

internal/redirectguard holds the mechanism: HTTPS only, no credentials, no
port, a hop limit, and a host list the caller names. ErrRefused gives the
refusal an identity so a caller tells one from a transport failure through
errors.Is rather than by reading the sentence.

The host lists stay apart on purpose. Which hosts a download trusts is
that path's own security decision, and that the CLI upgrade and the remote
CLI provisioner both name only GitHub is a fact about where we publish,
not one about them. Merged, a change to either would silently move the
other. Mechanism shared, policy separate.

The layering rule is what had made this impossible: it refused a leaf
importing anything under reasonix/, including another leaf, while its
stated intent is that a utility drags no dependency graph along. A closed
layer reaching inside itself drags nothing, and Go already refuses a cycle.
So the rule now says what it meant -- a leaf may import a leaf -- which is
a sharpening rather than a loosening, and releaseasset can share the guard
instead of keeping the copy the old rule forced it to keep.

redirectguard joins the sensitive list beside netclient: it decides which
host a download may land on, and a new security boundary outside that
governance is one nobody reviews. One declaration, both effects -- the
review gate and a coverage floor, recorded at 92.6%.
2026-08-31 09:12:56 -07:00
esengine 3105838461 fix(agent): project host-owned step ids where the model can read them
complete_step calls step_id PREFERRED, and the host has held one for every
item since plan projection. The model was never shown one. todoState never
rides in the prompt, todo_write answered with counts alone, and all three
list renderers printed bare ordinals — so the only place an id ever appeared
was the model's own todo_write arguments. A fold takes that out of view and
the id is gone from the conversation while the host still holds it.

What the model then cites is the ordinal it was shown, which is exactly the
citation that goes stale when a replan inserts a step above it.

evidence.TodoCitation is now the single rendering, used by todo_write's
result, complete_step's inventory, and both incomplete-todo prompts.

The fold carries the ids in its own output rather than leaving them to the
round after it. The request a fold feeds is frozen before that round exists,
so a sign-off sent in it could still only cite an ordinal — the same failure
this change exists to remove, reproduced once per compaction. A fold whose
kept tail still shows the ids appends nothing. Rewind and snip go through
Session.Rewrite instead and are re-projected at the next turn tail.

Compaction never calls Session.Rewrite — it installs a projection and leaves
the canonical transcript alone — so a rewrite-reason trigger would have
tested green against a signal the real fold path never emits.

Not covered, and separate: rebuildTodoState derives canonical todos from the
transcript's todo_write calls, so a resume after a fold that dropped them
starts with no canonical list at all. The identity does not survive there to
be projected.
2026-08-30 08:07:42 -07:00
esengine 7d4d582e53 fix(agent): classify the response boundary before committing a turn
A response cut at the output limit reached the model as "invalid arguments
for bash; it requires "command"". The host knew the cause — finish_reason
was length — but finishReasonMessage only ever emitted it to the sink, so
the model saw a downstream symptom and attributed it to its own formatting.
The lesson a real session drew from this was to stop batching independent
calls, which is the opposite of what the tree asks for and costs a round
per call.

classifyResponseBoundary runs before the assistant turn reaches the
conversation. A truncation can only have cut the last call, so that is the
only one it drops, and only when its arguments do not parse: earlier calls
were emitted whole and still execute, and a cut that landed after the last
call closed drops nothing. The dropped call neither runs nor enters history,
and the reason rides the turn tail as a host fact.

stopUnexecutedBoundaryCalls is not this: it fires on the round budget and
never reads the usage it is handed, and by the time it runs the assistant
turn is already committed.

malformedArgumentsDetail now quotes the bytes the parser stopped on with a
caret under them. An offset into a payload the model cannot see is what got
'>' blamed for a missing delimiter.
2026-08-30 07:59:54 -07:00
esengine 75a9f31600 docs: name the macOS host-mediated delegation surface as unmeasured
The profile's allow-default second line leaves Apple Events and
Mach-service lookup undenied, and the probes that would have settled
whether a delegation reaches outside the sandbox were inconclusive rather
than blocked. Recording it as undenied-and-unmeasured keeps two readings
out of the tree: that no deny rule means an escape exists, and that an
inconclusive probe means the sandbox stopped it.

It is outside the governed endpoint set, so it does not reopen the
authority work -- the claim was written narrowly enough to survive it.
2026-08-29 19:28:14 -07:00
esengine 4bf264dbcb docs: say separation, not independence, of the sandbox dimensions
The evidence is that one dimension's enforcement cannot be assumed to
cover another -- a write restriction is not confidentiality, a read
restriction is not authority, a network restriction is not authority.
That supports modelling them apart; it is not the pairwise independence
proof the previous wording could be read as claiming. Integrity also
predates this round, so "each became necessary after a measured bypass"
overstated the history.
2026-08-29 19:17:46 -07:00
esengine 1cd0f1e08b docs: record the three sandbox protection dimensions
HostProtected was one word covering three independent things, and this
round found each of them by measuring a bypass of another: a read deny on
a socket did not stop a connect, and a private key was unreadable while
the agent signed with it anyway.

Expressed by, not held by: a config field states an intent, a backend
primitive plus its evidence is what holds a claim. That distinction is
the whole point of the closing sentence -- three false-assurance bugs in
this round (an absent directory reading as a denial, a truncated probe
reading as over-blocking, a single-GOOS lint reading as clean) cost more
than any missing protection did.
2026-08-29 19:14:09 -07:00
esengine 809abc775a feat(sandbox): separate external network from ambient host authority
`network` was carrying two unrelated capabilities. Measured on Seatbelt:
`(deny network*)` blocks Unix sockets too, so a user who turned off egress
also lost ssh-agent -- `ssh-add -l` and SSH-signed commits stop working,
neither of which needs the internet. In the other direction the default
`network = true` handed every command the host's agent socket, and a probe
confirmed what that is worth: with the private key deleted from disk, a
confined shell still got the host agent to produce a valid signature.
Denying the read (ForbidReadRoots) does nothing about it, because
possession of a key and the authority to use it are different things.

Spec now carries HostAuthorities alongside Network. Policy names services,
not paths: each backend resolves ssh_agent/docker/podman the way their own
clients do (SSH_AUTH_SOCK, DOCKER_HOST, XDG_RUNTIME_DIR), so a non-default
daemon is governed rather than quietly ungoverned, and Windows can satisfy
the same contract without owning a Unix socket. macOS masks with
network-outbound rules ordered deny-then-grant, since SBPL takes the last
match and a grant has to survive a blanket denial. Linux binds /dev/null
over the endpoint, last, so no later mount re-exposes it; --unshare-net
misses pathname sockets and --unshare-ipc is the wrong tool entirely.

Default grants ssh_agent and nothing else: signing is routine in a coding
workflow, a container daemon socket is host-wide execution. The zero Spec
grants nothing, so a new call site is confined until it says otherwise --
which is why MCP servers now get no host authority. The key is user-global
like [secrets]: a cloned repo must not grant itself the docker socket.

The acceptance matrix is the point, and its third row decides whether the
axes really came apart: egress denied while the agent still answers. Also
pinned: the governed set is three named endpoints. DBus, Wayland, X11,
gpg-agent, keyrings and arbitrary sockets remain reachable, so the claim
is "these endpoints are governed", never "host IPC is isolated".
2026-08-29 18:44:44 -07:00
esengine a9ec7e40f9 docs: a defect owes its reachability, the way a metric owes its definition
The project-check study reported a bypass as reproducible on the strength of a
test that assigned a private field. The field is written once at boot and never
again, so no run inside one process reaches that state, and the corpus said so
the first time it executed: parity, no divergence. The test proves a conditional
mechanism given the state; that is a different claim from a production defect,
and only the second earns priority.

So a destructive test now owes six lines before it is written up as something to
fix — who produces the state, through which transitions, across which
persistence boundary, under whose authority, and whether a real run has been
seen in it. Severity is reachability times frequency times authority, not
whether something can be bypassed.

Authority is in the contract because the same divergence can mean opposite
things. An agent that rewrote the criterion it was being held to is what
baseline protection exists for; an owner who changed the project's policy
between two runs may have superseded it on purpose. A study that cannot say
which it observed cannot say what the right behaviour is.

The segmented probe that motivated this is in the corpus: leg one blocked on the
gate still demanding the criterion loaded at boot, which is the same finding
from the other side — the declaration on disk had already changed and the gate
had not noticed.

Cache-impact: one rule added to REASONIX.md, which is in the stable prefix.
2026-08-29 09:31:51 -07:00
esengine af5dd27dfe docs: close the handoff study, and write down what cost it three explanations
The subagent handoff line is resolved for the evidence there is. Protocol
salience and closure behaviour were never defective: every judged child
reported, and nothing followed a real closure in either arm. What was defective
was the host's vocabulary, which answered a lowered claim and an intact one with
the same word, and that is fixed. Structural terminal enforcement is recorded as
unjustified rather than pending — it would be a defensive invariant, and the
case for it has to be made on delivery risk, not on this data.

The discipline is the study's other output. Three mechanism explanations died
the same way: a command of unknown extent counted as a proven write, one child's
latest adjudication read as every report's, and a syntactically accepted call
read as a closed one. Each was a convenient proxy standing in for the state the
metric was named after, and each produced a confident wrong story. The rule now
sits beside the other judgement rules, and the four-line contract a metric owes
before it becomes a finding sits in the benchmark README with the five
substitutions that caused these.

Cache-impact: REASONIX.md is in the stable prefix, so this adds one rule to it
and no session carries a different prefix mid-run.
2026-08-29 09:06:49 -07:00
yhh 90d834b915 feat(repolint): a capability one frontend drives is owed to every port that declares it
REASONIX.md says behavior goes on the controller so all three frontends
inherit it, and nothing read that back. serve is handed SessionAPI, which
declares SwitchBranch, and never calls it: session branching ended up
chat-TUI-only by accident rather than by decision, and 149 more edges are
in the same state.

frontend-parity reads the structure that settles it — a capability at
least one frontend already drives, a frontend whose declared port carries
it, and no call — and ratchets on missing edges, 150 today across 100
capabilities. A frontend whose port does not carry the capability reads
n/a: that scope decision is port.go's, and the compiler already keeps it.
port_segregation_test asks the opposite question at sub-port granularity,
so neither subsumes the other.

The matrix comes from go/types over compiled export data, never from
selector names: Close and Status are capabilities and also stdlib
methods, so a name match records os.File.Close as serve driving
Lifecycle.Close — a false "wired" that hides debt, the one direction a
ratchet must not fail in. It also resolves `ctrl := m.ctrl` in
chat_tui_watchdog.go, which no receiver-name heuristic can. The check is
pinned to CGO_ENABLED=0 GOOS=linux, the CLI's own release build, because
internal/cli carries _windows and _unix files and a GOOS-dependent file
set would put a developer's baseline at odds with CI. Warm, go list
answers in half a second; with no build it fails loudly rather than
passing.

An exception goes in the scope table with an issue and a reason, weighs
what the edge weighed, and lands against this file's own budget, so
declaring one moves debt into a diff a reviewer reads instead of deleting
it. The table ships empty: every row is a decision for its own change.

Verified by removing the cli Cancel wiring, which took controller.go from
87 to 88 and the ceiling from 150 to 151, and failed.

Two things about the baseline diff. Recording a new rule raises every
file it touches from zero, which the ratchet refuses without
-allow-widen; that refusal fired and this is the justification. The same
-update also reclaimed slack the tree had already stopped using and that
only -update can collect: complexity 1562->1529, essay 3276->3250,
file-size 42270->42038, function-size 6728->6639, struct-state 47->41.

One boundary is measured and left open. A capability whose last consumer
goes away leaves the candidate set, so its row retires and the debt falls
instead of failing. A port declaring a capability nobody drives is a dead
capability — 39 of them, 122 edges — and that is orphan's shape, which
orphan cannot see because it counts functions and never methods.
2026-08-28 05:23:03 -07:00
yhh 03a2c8c412 feat(evidence): the host owns provenance, and actions cannot vouch for themselves
Five holes let a turn answer the gate that was auditing it. Each is closed by
the same move: the fact comes from what the host observed, never from what
execution was free to rewrite.

- A `go test ./...` quoted inside a here-doc body satisfied a declared project
  check that never ran. commandSegments split the raw text on separators, so the
  body's lines became segments. SplitOutsideHereDoc drops the body and keeps the
  commands beside it.
- A here-doc cost the whole command its decomposition, so a turn that ran its
  tests next to one read as having skipped them, and `python - <<PY` walked past
  the Delivery gate that refuses `python -c`. The shell tables now read the same
  fact permission already reads.
- `cd backend && go test ./...` shared an identity with the root run, so a green
  root suite cleared a red subdirectory one. Canonicalisation now drops spelling
  and plumbing, never the context a segment before the check decides.
- An unclassified receipt took the pipeline's exit status for the check's, so
  `go test ./... | tail` could clear the failure it was hiding.
- missingMutation was wired end to end and never incremented. An unprovable
  change is now a debt only observation settles; a check that runs afterwards
  speaks for the state, not for the effect.

Obligations are derived by diffing the ledger, never announced: nothing can emit
or erase its own debt, and the delta reaches the model with the result that made
it. Delivery stops refusing opaque interpreters — what the host cannot audit is
recorded unproven and owed, not blocked at the door.

Criteria the task began under are the host's. Declared checks keep a captured
baseline the current declaration cannot retract; test criteria are captured from
the pre-image before any call that could overwrite them, and evaluated through
`go test -overlay` against the code that replaced them. A rewritten test passing
answers for itself, not for the bytes it replaced.

Also: the credentials error names the file it reads instead of an env var it
never consults, the MCP rail shows the way out instead of the endpoint's words,
and run --metrics carries the turn's own verdict beside its outcome.
2026-08-28 00:30:37 -07:00
yhh 1a64030c11 feat(repolint): the desktop's copy of a wire type is held to the kernel's
agentgraph's own account of why it exists is that every consumer folds the same
deltas, so the graph a headless run reports is the graph the desktop draws. The
desktop's fold is a second, hand-written copy — the type and the merge both —
and nothing checked the two carried the same fields. Adding one field to the
kernel this week meant remembering to add it in two more places by hand. The
next person will not remember, and neither toolchain will say so: they are
compiled separately and meet only at runtime, over JSON.

wire-parity reads the Go struct's json tags against the TS interface's
declarations and fails either direction — a field one side sends and the other
cannot read, and one it reads that nothing sends. A field the encoder skips is
not on the wire and is not demanded, so an internal field costs nothing.

The pairs are declared rather than inferred, for the reason the sensitive paths
are: no spelling tells a mirrored contract from a struct that merely carries
json tags. Only the graph is declared today, because it is the one that claims
both sides fold identically.
2026-08-26 04:23:25 -07:00
esengine 86daa61e9d feat(covergate): the declared-sensitive paths hold their coverage
REASONIX.md calls these paths the ones where a wrong change is "a hole in a
boundary". Measured, they were the least-tested part of the kernel rather
than the best: shellsafe 59.3%, shellparse 63.6%, sandbox 69.4%, netclient
47.2%, against a tree median of 76.5%. The code deciding what the agent may
execute and reach had the weakest evidence that it works.

The gate reads the same `sensitive:` bullets the runtime reads for its
review requirement, so a path is declared once and gets both effects — there
is no second list to fall out of sync. Floors are per declared path and
measured from the coverage profile per file, not per package, because
`internal/control/approval.go` is sensitive while the package around it is
not; measured on its own it is 93.9%, which no package number would show.

Floors ratchet in the opposite direction to repolint's budgets and for the
same reason: raising one is free, lowering one needs -allow-drop and a
reason in the PR. They record what is there today rather than an aspiration,
so the gate is green on arrival and only fails on a regression.

Verified by removing one shellsafe test file: the gate reports 47.3% against
its 59.3% floor. Adding an unrelated `sensitive:` line puts that path under
the gate with no other change.
2026-08-25 18:22:23 -07:00
esengine 0adc688cad chore(ci): the two guards that read the PR body are retired
Cache-impact, Cache-guard, System-prompt-review and Documentation-impact were
enforced by reading text out of the pull request description. Both workflows,
their checkers and the template fields go.

What they were protecting stays: scripts/cache-guard.sh is a test run, not a
body field, and remains the release-level cache-hit check. CONTRIBUTING still
asks that a cache-sensitive change say what it does to the provider-visible
prefix — as prose in review, not as a parsed line.
2026-08-21 07:57:12 -07:00
yhh 931b1385fd feat(repolint): the ratchet refuses to loosen, and says what it stopped using
"Never widen the baseline to land a change" was documented and unenforced, and
-update never told tightening from loosening: it rewrote the file from the tree
and both directions passed silently. Lowering a budget is the ratchet working;
raising one carries debt forward and is the diff a reviewer has to be shown on
purpose. -update now refuses to raise one without -allow-widen, and names the
file, the rule and the numbers rather than reporting that the baseline grew.

The other half is that budgets only came down when someone ran -update, and
people run it when they are adding debt — so the ratchet tightened and loosened
in the same commits and never in between. Today's update dropped file-size by
1447 units and function-size by 855: that was room a file could have grown back
into with CI green. A clean run now reports the surplus so it can be reclaimed.

The first attempt at this ate the report(over) call that prints the offending
lines, leaving a failure that named a budget and no code. Caught by ineffassign
on the now-unused variable.
2026-08-20 23:44:00 -07:00
yhh 3e95cd59fe feat(repolint): both halves of the identity rule become gates
REASONIX.md banned reading wording but never required carrying identity, so the
producer side was left to discipline and the same defect landed seven times in
one week: a build step that swallowed its diagnostics, an elevation failure
readable only as a raw errno, a stream stall classified by the word "stalled",
a launch phase read off a message prefix, a cross-volume move matched against
English Windows text, a plugin migration gated on a phrase, and a client that
discarded what the server explained. The rule now states both halves.

refusal-path fails a new http.Error in internal/serve. error-text fails a new
match against an error's rendered text, following it one hop into a local:
the direct form is already extinct in this tree and the 21 surviving sites all
store the text first, so a rule that only caught the direct form would have
reported clean forever.

Both are pass/fail and weigh one. The first draft of refusal-path did not set
Weight, counted 207 violations at zero and reported clean — a gate that looks
like it runs and stops nothing, which is the defect it was written to catch.

The baseline records the 207 and the 21 as debt so new ones fail. It also
tightens five existing ceilings and drops entries for files that no longer
offend: the tree had improved since the last snapshot and -update recorded
what is actually there.
2026-08-20 23:03:00 -07:00
esengine 5074b9a688 refactor(evidence): a project declares which paths are sensitive, spelling does not
highRiskPathHints substring-matched 35 English words against every mutated
path, and High risk forces a review plus a security_review subagent before a
turn can end. Measured against this repository: 326 of 1,314 non-test Go files
scored High, 72 of them on a spelling accident alone — phasetrace.go matched
"race", conclude_blocked.go matched "lock", meterconfig.go matched "config",
and every file under sandbox/ matched "db" through san-db-ox. A table that
cannot tell internal/auth from session_write_authority.go was deciding when to
spend two subagent runs.

Sensitivity is not readable from a path, so the host stops guessing and the
project declares it, through the mechanism already there for host checks:
`- sensitive: <glob>` bullets in the same "Reasonix host checks" section,
matched with the shared doublestar semantics in fileutil. Undeclared paths are
ordinary production code, which still demands a structured review or
host-proven verification plus diff inspection. The structural triggers are
untouched: an opaque write the host proved but cannot name a path for, ten or
more paths at once, and privileged tool surfaces all still score High.

This repository now declares its own nine boundaries, so its gate holds where
it matters and stops firing on trace files: 78 files score High instead of 326.
2026-08-18 17:45:01 -07:00
esengine b52db1aa25 fix(repolint): the diff-scoped check gets an entry the host can read
REASONIX.md told the model to run `go run ./tools/repolint -only ...`, which the
host classifies as an unknown program that might write — so a delivery run that
followed the instruction could not cite the result. One observed run was refused
for `gofmt -l`, reached for the repolint command the file recommends, and was
refused again for the same reason.

A `make` target is the entry the host already recognizes, and Makefile is where
this project declares its checks. Nothing in the classifier changes.

The alternative — letting a project name its own verification commands in config
— was rejected. Delivery deliberately refuses to escalate (see
mixedShapeClearedByEscalation) because it only accepts what the host can confirm
by itself; a config channel would let the audited party define the audit.
2026-08-16 09:57:24 -07:00
esengine 640c8d984e fix(repolint): a test file's length counts cases, not concerns
Splitting a long test file scatters one subject's table across files that then
have to be read together, so the ceiling only ever bought a worse layout. The
rule retires entirely rather than gaining an exemption flag; complexity and
struct-state already stand down for tests, and size was the last one that did
not.

The baseline drops the 74 test-file-size entries by hand rather than through
-update, which would have absorbed unrelated debt the tree carries today.
2026-08-16 09:38:36 -07:00
esengine 6e0b886446 feat(agent): a way to ask when the static tables run out of answers
shellsafe enumerates 145 commands and will never enumerate them all: a new
formatter, linter, or query tool arrives every week, and each one that falls
through reads as "might write". A read-only command classified that way blocks
work — a real run spent seventeen consecutive rounds trying to see one failing
test, rewriting the filter each time a pipe segment tripped the mixed-command
contract.

So the tables get an escalation path rather than more entries. They stay the
fast path and answer everything they know; when they come up short and the host
is about to refuse, one classification call decides, and the verdict is cached
so a segment is asked about once. triage_model points those calls at a cheap
tier — empty falls back to subagent_model, then the main model, so it works
before anyone configures anything.

The escalation cannot widen what actually runs. The OS sandbox still bounds
every write, the verdict is refused unless the reply is exactly READONLY, and
anything unestablished — no provider, a timeout, a hedged answer — lands on the
refusing side. Delivery never escalates: there a mixed command is refused for
what it does to the receipt, not for what it might write.

Two things this cost to learn, both from running it rather than reading it:

- A one-word answer needs thinking turned off. Under a tight token cap a
  thinking model spends the whole budget reasoning and returns nothing, so four
  classifications in a row silently failed.
- The classifier gets its own provider instance from boot. Borrowing the turn's
  own would put extra calls into a stream the run is mid-conversation on — which
  is exactly what hung the agent tests against a scripted provider, for the full
  ten-minute default timeout.

repolint grows -only, which reports the budgets of named files instead of the
whole tree's recorded debt. A change could not tell its own violations from
inherited ones; the model in one run tried to build a git worktree to compare
against, and the sandbox refused it. Repo-wide ceilings still report, because a
file can push the tree past one without exceeding its own budget.

Carries the rest of the in-progress work in the same snapshot.
2026-08-16 03:15:01 -07:00
YHH 3385952bca chore(repolint): ratchet scalar state on mutex- and atomic-guarded structs (#8436)
Agent's field count went 16 -> 105 between 2026-05-29 and 2026-08-12, and its
bare scalars 8 -> 52 before easing to 48. Each independent bool, counter, or
atomic multiplies the states the type can be in while nothing records which
combinations are legal, so fixing one boundary case by adding one more flag is
cheap at the call site and paid for by everyone downstream.

The counter-pressure already exists and works: perTurnState absorbed five
delivery flags along with an explicit lifetime ("before the next turn resets
this state"), which is why the scalar count fell for the first time. What is
missing is anything that stops the next flag from being added faster than the
last one is grouped. That is a ratchet, not a refactor: a refactor moves the
current pile once, while the growth rate stays the same.

struct-state counts scalars, not fields, because the field total cannot tell a
new flag from the container that just absorbed five — grouping would otherwise
score as no progress. Only types owning a mutex or atomic are measured: a
struct without one is not concurrently mutated state. That distinction is what
makes the rule usable. Counting every struct produced 131 findings led by a
505-field translation table and a metrics DTO; counting guarded ones produces
12, and every one is a type that genuinely coordinates concurrent state
(Agent, Controller, desktop App, the provider clients).

The baseline records those 12 with -update, which is what a new rule requires;
the diff was reduced to struct-state entries only, so no unrelated drift rides
along. Verified both ways: the tree is clean today, and adding one more scalar
to Agent trips both its per-file budget and the repo total.
2026-08-12 08:56:45 +08:00
esengine 510e740bc5 fix: REASONIX.md named a type that no longer exists
The delegation boundary rule went into REASONIX.md while the spec member was
still called ContextCapsule. Renaming it to ContextRequest, so the manifest
could take that name, left the standing instruction pointing at a type nobody
can find — and REASONIX.md is loaded into the system prompt, so the stale name
is in the prefix rather than only in a doc.

Also worth recording against the question this came from: does merging make the
agent delegate less? Measured rather than argued. The same five neutral tasks,
same fixtures, only the binary swapped: main-v2 delegated zero times and this
branch delegated zero times, both 4/4 passing. The 15% delegation rate observed
throughout was already the main-v2 rate.

Nothing here adds a brake. The admission gate runs in applyBatchGuards, after
the calls have executed, and only emits an audit — there is no path by which a
verdict denies a call. The ablation change is inert unless --ablate subagent is
passed. What the branch adds to a delegated run is complete_subtask in writer
child registries and a closing contract in the child prompt, which is a cost
paid by a child that already ran, not a deterrent to starting one.
2026-08-09 18:14:15 -07:00