The code workspace: containment before capability
The eighth in a short series on how mindfork is put together. Earlier: the overview, the engine, the self-model, vector search, local speed, the Python sandbox and the trust boundaries.
Attaching is the permission
/project attach <directory> gives one chat one directory. That sentence
carries most of the design. The path is canonicalized and stored with the
chat; while it is there the assistant has a family of code_* tools, and
when it is not, the tools do not exist — a chat without a project sends a
request byte-identical to what the app sent before the feature was written.
There is no second switch to find, because the act of pointing at a
directory is the consent, and making you attach a directory and then
tick five toggles would contradict that. The profile's own per-tool
toggles still apply on top.
This is deliberately not the same thing as the general file tools. Those
are a global capability behind a switch, reaching the whole file system
unless you narrow them to a root. The workspace family is the opposite
shape: scoped to one chat and one directory you pointed at, and every path
it is handed is resolved against that root — .., an absolute path
outside, a symlink pointing out, all settled by canonicalization before
anything is read.
What it can do inside
Five tools read and change the project, and one habit ties them together.
code_listwalks the tree the way.gitignoresays to, skipping.git/and hidden entries, two levels deep unless asked for more.code_readreturns a file with line numbers in the form12→text, a header stating the total, and a window over a long file. The file is read in its own encoding, and when that is not UTF-8 the header says which — because that is the encoding an edit will be written in.code_grepis a regular-expression search, smart-case, narrowed by a glob, answeringpath:line: textand naming its cap when it truncates. It is the search this feature has; the next section but one says why.code_editreplaces an exact fragment that must occur exactly once. A fragment that is missing, or occurs twice withoutreplace_all, changes nothing, and the answer says which of the two it was. The result echoes the changed lines, numbered, so a second read is not needed to verify.code_writecreates a file or replaces one whole; its own description sends the model tocode_editfor a change inside an existing file, since a whole-file write can silently drop what it did not mention.
The habit is read before you edit, and it is taught rather than enforced:
the tool text says so, and enforcing it would need per-turn read tracking
that no live run has yet shown to be necessary. What is enforced is
fidelity on the way back. Models emit \n; Windows repositories are
CRLF; a file is matched on newline-normalized text and written back in
its own shape — line endings, byte-order mark and encoding — and only when
the unedited file comes back byte for byte through that encoding. A
windows-1251 source once came back from an ASCII edit with every Cyrillic
letter replaced by three bytes of replacement character while the diff
showed one line; now a character the encoding cannot store, or a file read
with loss, is refused with nothing written.
One more rule matters for a real fix: reading and editing the project do
not spend the turn's tool-round budget. A turn that is forty rounds of
read, edit and build still has its full allowance of other tools. The
safety net is the per-call timeout, one command at a time, and Esc.
The commands are yours, verbatim
The assistant can build, run and test the project — through three slots
you fill with command lines: /project build-cmd cargo build, and the same
for run-cmd and test-cmd. The three tools take no arguments at all,
by schema. The model sees the text of each line, so it can tell you how to
fix a broken one, but the only thing it can do itself is run the slot
exactly as written; a slot you have not filled is a tool that is not
offered. There is no shell tool and there are no arguments on purpose: a
test filter is the first thing anyone wants and the first injection vector,
and the whole point of three fixed slots is that the model cannot compose a
command.
There is no shell underneath either. The line is split into arguments the
way a shell would split it, quotes honoured, and the program is spawned
directly, completed from PATHEXT on Windows so one line works on both
platforms. A pipeline or a redirect therefore cannot run — and the refusal
comes when the line is set, naming the character it found and the route
that works (put the steps in a script, name the script), not three turns
later as a program that could not be found. The check runs again at
execution time, because a chat file is JSON on disk and can be edited by
hand.
A command that outruns its limit is killed with its whole process tree — a
Job Object on Windows, a process group on Unix — because killing only the
process the app spawned would leave cargo's rustc children compiling.
Whatever the command printed before that is kept, and the answer says
it timed out: the deliberate inverse of the Python sandbox, which discards
partial output. A build's first errors arrive in its first second, and
throwing them away because the build was slow wastes the whole wait. Output
is capped per stream and cut from the middle, keeping head and tail, since
a compiler puts its first errors at the top and its summary at the bottom.
One command runs at a time across the application — two builds of one
project would fight over the same output directory.
Every change is a diff, and a diff is a way back
Before the assistant first touches a file in a chat, the file's original
is journaled: a pre-image on disk under workspace/<chat-id>/, with a
manifest naming the root it belongs to. Not in the chat file, which must
not grow by megabytes of source; not in the disposable cache, because a
pre-image is the one thing that cannot be recomputed. A file the assistant
created is journaled as one that did not exist.
F4, or /changes, is the screen over that journal: every touched file,
each shown as a diff between its pre-image and its current content, and
r puts one file back after asking — a created file is removed instead.
Deletion does not otherwise exist: no tool can delete, and the revert of a
created file is the one deletion there is, user-initiated and confirmed.
Attaching a different directory starts a fresh journal, and that rule
was the one defect the track's own audit found: it had been implemented
inside the write path, so the reset fired on the assistant's next edit
rather than on the attach. Fixed, and the audit written down. Backups pack
workspace/ with the rest, so a restored chat can still undo.
There is no git integration in this, by choice. The snapshot journal
answers "what did the assistant do" more precisely than a diff against
HEAD, works in a directory that is not a repository, and is what powers
revert. A git mode is possible later; it is not the foundation.
Measured before it shipped
The track opened with a probe against the two families the project gates
on, gemma-4 and qwen-3.6, and the ground truth was not a string comparison:
the fixture is compiled with rustc and run, so a "fix" that deletes
the arithmetic cannot pass, and every run asserts that code_edit was
actually called, because a model that explains the fix in prose would
otherwise pass a smoke about editing.
Arm A is the compile error a user pastes — the prompt is the cargo build
output. Arm B keeps the fragment out of the prompt: a median that builds
and prints 6 instead of 5, with no code quoted, so the fragment to replace
can only come from what code_read returned — and the obvious one-line
fragment occurs twice in the file by construction, so a naive edit is
refused as ambiguous. Both arms: 5/5 on both families, exactly one edit per
run across all twenty runs, no refusal ever fired. The strongest single
datum is arm B's argument: a five-line fragment reproduced byte for byte,
indentation included, from a read that had line numbers prefixed to every
line — the prefixes stripped, the leading whitespace kept, and the fragment
widened past the duplicate by the model's own choice. The families differed
in the route, not the outcome: qwen wandered more before committing, two
to eight calls against gemma's steady two, one run at 74 s against 20.
The index that did not ship
The plan's last stage was a semantic index over the project, with its own
go/no-go: ship only if it measurably improves answers or reduces rounds
against code_grep alone. It was built as a probe over this repository —
about 21 000 line windows — and measured on eight questions asked in the
user's vocabulary rather than the code's ("why does the app sometimes
shorten the conversation by itself", where the code says compaction), on
gemma-4-31b, two arms, three passes of the set per instrument version.
Of the turns that looked at the project, grep alone answered 16 of 22 correctly and grep with search 19 of 29; of all turns, 33% against 40%. Read those two rows together: by one denominator the index is ahead, by the other behind, and the choice is a judgement about what an unusable turn means. An effect that changes sign with a definition is smaller than the instrument measuring it, and rounds were slightly worse. No-go.
The instrument turned out harder than the thing measured, and that is the finding worth keeping. The first table was 5/5 against 5/5 with the search tool called zero times — the workspace block tells the model in words which tools it has, the probe's tool was not in that list, and the model believed the block. Correctness was a rate, not an outcome: the control arm scored 5/5 and then 3/5 on the same questions. A turn that spent itself on calls and thinking and emitted no text is a token-budget failure, not a retrieval one, and counting it against retrieval is a claim made from the wrong evidence. A keyword grader produced a false positive on a general-knowledge answer and false negatives on correct ones. The one effect that survived every version of the instrument — with a search tool available the model answered without looking far less often, eight turns against fifteen — is not what the criterion asked, and shipping on it would be shipping on an unmeasured basis, which is what the go/no-go exists to prevent.
What the no-go bought: no new cache schema and its migration, no background indexing task, no re-index on every edit, no settings toggle, and no hard dependency on an embedding server for a feature that otherwise does not need one. What would change the answer is a judge-graded measurement at around eighty turns per arm, or a corpus with sparse comments, where grep has less to match on. Either is a new probe, not a resumption of this one.
What it is deliberately not
A shell. Arguments on the commands. A tree-sitter or language-server
dependency for marginal gain at this model class. Fuzzy edit matching,
which silently corrupts files where an exact match with a good error does
not. A file watcher that re-indexes on external edits. A tool that deletes.
Each of those is a line in the plan's "deliberately not doing" list, with
the reason beside it — and the plan, its stages, the probe results and the
audit of where the shipped code differs from it are in the repository, as
docs/history/code-workspace.md,
with the contract itself in spec §9.12.