Skip to content

Projects become workdirs with multiple chat threads - #193

Open
kazzand wants to merge 110 commits into
front/synthesisfrom
feat/project-threads-synth
Open

Projects become workdirs with multiple chat threads#193
kazzand wants to merge 110 commits into
front/synthesisfrom
feat/project-threads-synth

Conversation

@kazzand

@kazzand kazzand commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Projects become workdirs with multiple chat threads

A project stops being a single side-panel room and becomes a working folder with
several chat threads
, on the same contract as every other chat: a thread opens as the
centre chat, can be forked, renamed, reordered, archived and deleted, and each thread
chooses whether it works in the project folder or in its own git worktree. Main
Chat is untouched.

98 commits, 80 files, +22,863 / −912.

Base is front/synthesis, not ouroboros — a deliberate deviation from
CONTRIBUTING's "base the PR on lowercase ouroboros", made because this work is stacked
on the web-UI redesign (PR #98) and front/synthesis is its ancestor. Basing on
ouroboros would present the entire redesign as this PR's diff. Land #98 first, or
review this against the redesign head.

What the owner decided (the plan is the authority)

  • Thread #0 is the project's own chat; the project row and thread #0 are one row, and
    the project's top-level chat_id stays its compatibility alias. No migration rewrite.
  • A fork is a cursor, not a copy{fork_of_chat_id, fork_before_ts} — so no journal
    rows are duplicated and deleting the source does not empty the fork. One shared
    thread_ancestry_lens(chat_id) feeds both the history surface and the agent's context,
    so the two cannot describe the same thread differently.
  • A thread may be archived and restored, or deleted, and deleting one takes its
    worktree with it when the checkout is clean.
  • Per-thread choice of place: work in the project folder (serialised with the
    project's other work) or branch off into a worktree (runs concurrently).
  • Attaching a project needs a place; a non-git folder is admitted through a typed
    git_init_required decision rather than a silent refusal.

Verification

Lane Result
python -m pytest (junit XML, run alone) 8658 tests, 0 failures, 0 errors, 1 skipped
node --test web/tests/*.js 424 pass, 0 fail
Playwright (OUROBOROS_RUN_UI_SMOKE=1) 35 passed, 1 skipped
VERSION untouched (maintainer assigns at landing)

Counts are read from the JUnit XML, not from stdout: a full run in this repository exits
without printing its summary line — on the base branch too.

Vision inspection (DEVELOPMENT.md's rule that a visible change is looked at in a real
consumer flow, not merely screenshotted): a live instance was driven by hand — created a
project on a real git folder, created threads, opened one as the centre chat, branched one
into its own worktree, archived and restored another, and read every thread-menu row's
enabled state and reason. It found one defect no reviewer could: the archived row's
tooltip read "Restore this thread — restore it to act on it", a tautology beside the word
"Restore". Fixed, and the test that pinned the old phrasing now asserts the substance.

Review record

Four phases, each through two adversarial rounds, then the merge, then two integration
reviewers, then the paid contributor triad. Every finding was re-verified against the code
before being accepted, and several reviewer claims were refuted with reproductions
rather than implemented:

  • 27 findings closed in the worktree/lifecycle phase, including a snapshot that committed
    conflict markers into a folder stopped mid-merge, a renamed tracked credential deleted
    from the owner's branch, and a merge onto a detached project HEAD that reported success
    while both safety judges were fooled by the same wrong reference.
  • 19 integration findings, including two blockers found one level up: deleting a project
    orphaned every branched thread's checkout and branch with no unmerged-work refusal at
    all, and the merge's own central cross-stream fix had zero regression protection —
    five mutations dropping it survived the entire suite. It now has five call-site tests
    plus a source guard, proven by re-applying each mutation.
  • 8 of the paid gate's 11 criticals were live and are fixed; 2 were stale (already closed
    by later commits) and were deliberately left alone; 1 was the vision-inspection gate,
    answered by doing it. Two reviewer prescriptions were declined for reverting owner
    decisions, and the alternatives close the same hazards.

Notable class, found three times: the server grows a typed escape that no client can
produce. The root was that every thread route answers 4xx and the client layer rejects any
non-2xx, so refusals never reached the code that renders them. DEVELOPMENT.md now carries
the rule — a refusal carrying acknowledgeable or a typed decision must be rendered
with its answer in the same gesture.

Disclosed, not silently carried

  • The ancestor origin-fallback synthesis is history-only. context.py shares the lens
    but not that synthesis, so an agent in a fork of a converted project can still miss the
    parent's referenced Main origin message. Stated in ARCHITECTURE rather than implied away.
  • Origin dedupe runs before the cutoff filter, so binding-file order decides which
    owner's cutoff wins and a stricter winner can drop a row a looser ancestor would admit.
  • Thread checkouts live outside the data directory (~/Ouroboros/thread_worktrees), so
    two instances on one machine share that root. Verified consequence: provisioning refuses
    to reuse an existing path or branch, and removal acts only on its own registry row — a
    second instance gets a typed refusal naming the path, not a stolen checkout.
  • Two projectless tasks naming the same folder now serialise where they previously ran
    concurrently. That is the cost of closing the two-writers-in-one-folder hole.
  • supervisor/queue.py sat at exactly 1600 lines and crossed the gate by one behavioural
    line; it is recorded in the grandfathered-modules SSOT by repo-relative path with its
    reason and the tracked follow-up, the same mechanism the repository already uses.
  • Pre-existing, not from this branch: tests/test_telegram_miniapp_companion.py has two
    tests that fail or hang in a full-suite run depending on timing (fixed ports 9012 / 45678
    / 8765, no socket guard anywhere in the suite). They fail on the base branch too and pass
    when the file runs alone.

Conflicts against current ouroboros

The redesign was rebased onto ouroboros on 2026-08-09; upstream has advanced 109 commits
since. Measured by trial merge: the redesign alone conflicts in 5 files
(docs/DEVELOPMENT.md, tests/test_gateway_parity.py, web/onboarding.css,
web/settings.css, web/style.css). This branch adds exactly one more —
docs/ARCHITECTURE.md, a single 9-line hunk. Everything else belongs to the redesign and
resolves when it lands.

Andrei Kaznacheev and others added 30 commits August 10, 2026 00:22
…nonical projection, registry-wide chat-id reservation

`thread_chat_id(project_id, thread_id)` joins `project_chat_id` in the
allocator-free chat-id SSOT, and `thread_chat_id(pid, 0)` IS
`project_chat_id(pid)` — the identity that makes threads a zero-migration
addition (every existing history row, task binding, unread counter and test
keeps its numbers). The 28-bit digest slice is deliberately NOT widened: the
history endpoint clamps `chat_id` to 2**31-1, so a wider space would be
unreachable over the wire, and re-slicing would change every existing
project's id.

The registry row gains an ADDITIVE `threads: []` list of EXTRA threads plus a
read-time canonical projection (`project_threads`) that synthesizes thread #0
from the project's own chat_id/name/created_at/visible_revision. Nothing is
rewritten on disk; the top-level `chat_id` stays a compatibility alias; a
legacy row reads as a healthy one-thread project.

Uniqueness is a registry-WIDE reservation invariant (not a widened hash): every
project creation, thread creation and the boot reconcile check the minted id
against every id every project AND thread already owns in any lifecycle state.
Threads retry the next opaque id; a project collision is refused loudly; a
reconcile skips loudly rather than merging two histories; pre-existing
duplicates are reported once per process on load.

Also: `resolve_chat_binding(chat_id)` — the one chat→owner seam, memoized on
the registry file's stat; create/rename/fork thread operations (a fork stores
only a `{fork_of_chat_id, fork_before_ts}` cursor, never copied rows; auto-name
`Copy of …` with no model call); `reserved_project_chat_ids` widened to every
thread; and thread-aware unread that advances both the thread counter and the
project aggregate today's flat cursor reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nership

`server.py::_project_id_for_registered_chat` and `_reserved_project_for_chat`
compared an inbound chat id DIRECTLY against `project["chat_id"]`, which sees
thread #0 only — a message to any other thread would have been classified as
Main and scoped to no project. Both now resolve through the one canonical
`projects_registry.resolve_chat_binding(chat_id)`, which answers for every
thread in every lifecycle state and carries the lifecycle so the active-only
and reserved callers stay distinct.

The "room" logic (`_addressable_root_tasks` / `_task_belongs_to_chat`) is
deliberately untouched: VERIFIED already exact per chat id (the task's own
chat_id, else its binding's project chat id), so two thread ids can never
share a room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the agent

A fork carries a `{fork_of_chat_id, fork_before_ts}` CURSOR, never copied rows,
so reading a fork means reading its own chat plus a bounded slice of every
ancestor. Two readers need that answer — `gateway/history.py` (what the owner
sees) and `context.py` (what the agent sees) — and they read their tails
separately, so a cursor honoured by only one would hand them DIFFERENT
histories of the same thread.

`ouroboros/thread_history.py::thread_ancestry_lens` is now the only definition.
It returns every ancestor chat with its effective cutoff plus the in-scope
binding source refs, and both consumers use it.

Pinned semantics:
- INCLUSIVE boundary — `row_ts <= fork_before_ts` (the fork instant is stamped
  after the rows that preceded it); lexicographic ISO-8601, the same convention
  the history window already uses; an unstamped row sorts oldest and is
  admitted rather than silently dropped.
- INTERSECTED cutoffs for a fork of a fork — a grandchild can never see more of
  a grandparent than its parent could.
- LIFECYCLE-BLIND ancestry — a fork of a deleting/tombstoned thread keeps
  reading its shared past instead of being orphaned.
- Ancestor source refs and origin fallbacks travel with the lens, so a fork of
  a CONVERTED project still shows the Main-chat message that started it.
- A chain past the depth cap or a cycle sets a disclosed `truncated` flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`project_lease` keyed occupancy on `project_id` ALONE, so a thread branched off
into its own git worktree would STILL queue behind a task running in the
project's main folder — branching bought no concurrency at all, contradicting
the whole point of offering it.

The lane is now `(project_id, normalized workspace_root)`: two threads of one
project working in the SAME folder still serialize (identical key), while a
worktree-scoped task gets its own lane. `workspace_root` is read from the task
record and its metadata mirror (both carriers exist in the queue) and
normalized with pure normpath/normcase — this runs under the queue lock on
every assignment pass, so it must never touch the filesystem.

`running_project_ids` survives as the SEPARATE project-wide activity query
("is anything running anywhere in this project?"), which the later merge-back
and worktree-removal preconditions need. It is documented as NOT the lease key
so it cannot be reintroduced as one; `candidate_is_leasable` now names the
lane-set requirement explicitly, because passing it bare project ids would
silently disable the lease rather than fail.

tests/test_project_lease.py is updated deliberately: the existing matrix moves
to lane tuples and two new tests pin the same-folder/other-folder split and the
metadata-mirror + path-normalization reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gent one

The subagent worktree machinery cannot back a thread's checkout: provisioning
force-removes a stale checkout AND branch, removal is unconditional `--force`,
and the startup sweep deletes on retention age alone. Any one of those would
silently destroy work the owner branched off deliberately.

`ouroboros/thread_worktrees.py` inverts all three, in its own durable registry
(`state/thread_worktrees.json`, keyed by `(project_id, thread_id)`):
- provisioning REFUSES an existing registration, path or branch — a
  re-provision is an error, not a reset;
- removal first INSPECTS (dirty files, commits the base never received) and
  refuses unless the caller acknowledges the loss, so no timer and no silent
  path reaches a delete; a malformed row can never delete a path outside the
  configured root;
- there is no age GC at all. The subagent sweep iterates ITS registry, so a
  separate file is what makes thread worktrees structurally unreachable by it
  (pinned by test rather than asserted in prose).

Only the git-op lock and the path-containment guards are reused, exported from
subagent_worktrees as public names so this module does not reach into privates.
T3 consumes this; T0 provides the storage and the guarantees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vel parity

Contracts: `ThreadEntry`, additive `ProjectEntry.threads` (the canonical
projection, thread #0 first — a client that ignores it keeps working off
`chat_id`), and the `ThreadCreateRequest`/`ThreadUpdateRequest`/`ThreadResponse`
envelopes.

Routes (owner surfaces through the gateway, deliberately NOT LLM-callable tools
— no prompt or SYSTEM.md surface is touched):
  POST /api/projects/{project_id}/threads
  POST /api/projects/{project_id}/threads/{thread_id}/update
  POST /api/projects/{project_id}/threads/{thread_id}/fork
Each broadcasts `projects_changed` carrying the affected thread's chat id, so an
open client adds it to its known-chat set before any live frame for that thread
can arrive. No `thread_named` event: registry mutations already broadcast
`projects_changed` and the client refreshes state on it, exactly as project
rename does.

Full new-endpoint procedure: router + contracts + api_types JSDoc + api_client
(the UI phase consumes these) + parity and endpoint tests.

X6 verified and fixed: `UiPreferencesResponse` and `ProjectEntry` were BOTH
absent from the FIELD-level parity loop, and ProjectEntry's JSDoc had already
drifted — `origin` and `created_at` existed only on the Python side with nothing
to catch it. Both contracts now join the loop and the JSDoc is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/test_project_lease_ui_conversion.py still passed bare project ids to
candidate_is_leasable, and the result was the dangerous kind of wrong: every
candidate read as leasable, so two writers could enter one folder with nothing
saying so. The function now rejects a non-lane key instead of quietly
permitting, and both call sites in that file move to running_project_lanes
(keeping running_project_ids where the assertion really is about project-wide
activity).

Also key the duplicate-chat-id load report on the registry file's (mtime, size)
rather than once per process, so a collision hand-edited in after the first
load is still reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A3 forbids forking the Main chat. That holds structurally rather than by a
guard: the fork surface is reachable only as (project id, thread id), and the
Main chat belongs to no project and is no project's thread. Pinned by test so a
future widening of the route cannot quietly make it reachable, alongside the
positive case that thread #0 of a PROJECT is a legitimate fork source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memo is keyed on the registry file's version so a later hand-edited
collision is still reported; that key set is now bounded so a long-lived writer
process cannot accumulate one entry per write. Comment corrected to match the
version-keyed semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eused

Deriving the next thread id from `max(live ids) + 1` is correct only while
nothing is ever removed. The moment thread removal lands, a reused id would
mint a chat id the removed thread's history rows still carry — silently merging
two conversations, which is exactly the 28-bit-id hazard the project tombstone
pattern already guards against.

The high-water mark is now persisted on the project row. Legacy rows without it
fall back to the live maximum, which is correct because nothing has been
removed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan is memoized per registry VERSION, not once per process — the prose
still claimed the weaker guarantee it originally had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… demanded

Attaching a folder that nobody had put under git used to be refused outright,
on both the UI/API create path and the agent-side promote attach. That made
"give this project a place" and "put that place under git" one decision, and
took it on the owner's behalf by declining their folder. A11 says every project
must have a place; A12 says git is offered, never forced. So they become two
decisions.

An untracked folder now attaches and is PERSISTED as the project's working_dir,
untouched. The git question moves to where it actually matters — the first FILE
task — and arrives as a question rather than a wall: validate_workspace_root
raises GitInitRequiredError carrying the typed git_init_required decision, with
its own plain-language account of what saying yes buys (diff, rollback,
branching). Admission still stops. Nothing runs `git init` in a folder
Ouroboros does not own.

Two things keep the removed requirement from becoming a hole. The new error is
a WorkspaceRootError subclass, so any caller that never learns about the offer
refuses exactly as it did before; and the offer is deliberately not made where
saying yes would be wrong — inside someone else's worktree (it would nest a
second repository) or for a missing/overlapping path (there is nothing to say
yes to). Every other attach guard is untouched.

resolve_room_workspace returns a third value rather than folding the decision
into its error: an open choice presented as somebody's mistake is a lie, and
the promote path halts such a task under GIT_INIT_REQUIRED instead of
WORKSPACE_UNUSABLE for the same reason.

The pinned refusal tests are replaced, not relaxed. Both now assert the folder
is persisted AND that no `.git` appears — a regression where attach silently
initialised git would have passed the old assertions and fails these. New
coverage pins the guards that stayed (missing / home root / repo / data
overlap), the subdir-of-a-worktree case that is deliberately not an offer, and
both admission surfaces returning the decision without queueing.

is_git_worktree_root goes with its last caller; validate_workspace_root was
already the authority it duplicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The offer raised at admission had no answer behind it: init_git existed only as
a flag on project creation, so an owner who attached a folder first and hit the
git question later had no way to say yes without recreating the project.

POST /api/projects/{project_id}/init-git is that answer, and it runs the SAME
attach_snapshot_init the create dialog runs — one snapshot commit of what is
already in the folder, credential-shaped files left untracked and named back to
the owner. Admission never calls this route. It raises the offer and stops; only
the owner reaches here.

Writing into someone's own folder is the entire job of this route, so it
re-establishes the attach guards against the CURRENT working_dir rather than
trusting the value the registry happens to hold: a file on disk that went stale,
was hand-edited, or points at a folder that has since moved is not authority to
run `git init` somewhere. Tests pin every status it answers, including the
system-repo overlap it refuses while the registry claims otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A project created with no folder gets one auto-provisioned under the durable
projects root at first task. The path was already on the registry row and in
the sidebar projection, but provenance was left blank — so the row said only
"this project has some folder", which reads exactly like an unstamped attach.
The owner had no way to learn that Ouroboros made the place, and no surface
could offer to show them where.

ensure_project_workspace now stamps provenance="genesis" in the same write that
binds the path. An existing provenance is never overwritten: how a folder came
to be is a historical fact, so a project whose attached folder vanished and got
a provisioned replacement keeps saying "attached" rather than having its history
quietly rewritten.

Backend only — rendering it is T1's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turning a card into a project, or self-scoping mid-run with
ensure_project_scope, registered the project and dropped the task's
workspace_root on the floor. The project came out with no working folder at
all — and the next task in it then auto-provisioned a fresh empty tree under
the durable projects root, so work that had been happening in the owner's
folder quietly continued somewhere else. Probed before changing anything:
converting a task with a workspace_root yields working_dir: ''.

A11 says a project must have a designated place, and the task's own folder is
the obvious one; nothing else has a better claim. adopt_task_workspace is the
single seam both paths call, and it adopts as an ATTACH — the same
resolved-realpath guards, no git requirement (A12: a plain folder is a place),
provenance "attached" carrying the grant the owner already made by starting
work there.

Two things it deliberately will not do. It never overwrites an existing
working_dir, so converting into an existing project cannot reassign that
project's place. And a folder that has moved, or that overlaps the Ouroboros
repo or data roots, is disclosed rather than either recorded or fatal:
conversion's job is to create the project, so it succeeds and reports
working_dir_error instead of leaving the owner to discover a placeless project
later.

Note on the plan: it listed task-card conversion and ensure_project_scope as
entry points that REFUSE a non-git folder. Neither refuses anything — they
never looked at the folder at all. The outcome the plan asked for was the right
one; the diagnosis was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEVELOPMENT described project-room provisioning but never said whether a
project's folder has to be under git — the one question this phase changes the
answer to. Adds the rule beside the admission bullets: a plain folder is a
legitimate place, the git question lives at task admission as a typed offer, and
the three things that must not happen (init without the owner's answer, an offer
where saying yes would be wrong, or the offer leaking into the loud-fail error
channel as if the folder were broken).

The right-panel Projects model is deliberately untouched; that rule belongs to
the phase that changes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitInitRequiredError carried an unused project_id kwarg. The validator only
ever knows the path; the room-level caller is the one that knows whose folder
it is and re-stamps the decision through git_init_decision. Says so instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phase claimed one definition of "what does this thread read", shared by
gateway/history.py and ouroboros/context.py. It was not: context.py resolved a
post-hoc task binding with `_bound.get(task_id) == thread_chat_id` — this
thread's OWN chat, own task id only — while history.py routed the same binding
through `lens.admits` by task LINEAGE. A task bound post-hoc to a PARENT thread
therefore appeared in a FORK's UI history and was invisible to the agent working
in that fork, and a subagent's rows (bound only through their root) were
invisible to the agent everywhere. The two also built the lens differently
(`with_source_refs=False` vs the default), so a fork of a CONVERTED project
showed the owner the Main-chat message that started it and hid it from the agent.

thread_history.py now owns the ONE row test both readers call —
`bound_chat_for_row` (own -> parent -> root) and `admits_row` — and both build
the lens with source refs.

Four more holes in the same walk, all reproduced first:

* An ancestor was added to the cutoffs BEFORE its binding was resolved, so a
  hand-written `fork_of_chat_id: 1` admitted the whole Main conversation into a
  project thread's history AND the agent's focused context, silently. An
  ancestor with no project binding is now refused before it enters the lens.
* A cycle closing on the REQUESTING chat tightened that chat's own cutoff, i.e.
  a thread began rejecting the messages it had just sent. Its own present is
  never bounded now; the cycle is disclosed instead.
* `lens.truncated` was set and consumed by nobody. ARCHITECTURE already promised
  it was disclosed, and the response still called itself complete while part of
  the shared past had not been read. It now threads into the window projection
  as the `ancestry_depth` cause, with its own honest "Load older" copy — those
  rows are not in an archive, the chain was never followed to them (A3b).
* The origin fallback asked `project_origin_rows` once per ancestor, re-reading
  state/project_task_bindings.json per link and able to synthesize one owner
  message twice. `origin_rows_by_chat` buckets the whole chain in ONE read and
  dedupes on the same identity tuple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…able bits stop lying

The lane key `(project_id, workspace_root)` was read straight off the task
record — but only the promote/room path stamps `workspace_root`. A task scoped
POST-HOC through `mark_task_project` (the SSOT behind both `ensure_project_scope`
and `api_project_from_task`) carries the project id and nothing else, so ONE
project folder split into two lanes and two top-level writers entered it
concurrently: strictly worse than the project-wide lease this key replaced, and
a breach of the invariant pinned at supervisor/workers.py.

An empty `workspace_root` now resolves to the project's REGISTERED `working_dir`,
handed in by the caller (`projects_registry.project_working_dirs`, read once per
assignment pass) so the lease stays filesystem-free under the queue lock. When
even that is unknown the lane is a WILDCARD conflicting with every lane of its
project — an unknown folder queues, it never runs parallel by accident. An
explicit `workspace="none"` task therefore still serializes against its
project's folder; "I write nowhere" is not a claim this module can verify.
Symlink resolution moves to RECORD-WRITE time (`create_project`/`update_project`
canonicalize `working_dir`, case preserved because the owner reads that path),
leaving the lease with pure normpath/normcase — and `normcase` being a POSIX
no-op is now written down where it matters. The lane-key shape check also runs
BEFORE the unscoped short-circuit, so a caller passing bare project ids is told
immediately instead of once a project task happens to slip through.

Also in this pass:

* Thread #0 gets its own stored revision counter. It was projected from
  `visible_revision`, the project-wide aggregate, so a message in ANY sibling
  thread marked the project's main thread unread. A legacy row seeds from the
  aggregate — while a project had one thread the two were the same fact.
* `_normalize_thread_rows` builds from `dict(raw)` and overwrites only the
  normalized keys, mirroring `_normalize_project_row`. Rebuilding a fresh dict
  of known keys silently deleted every additive field on every read; T3's
  `worktree` binding would have looked like data nobody ever wrote. A HALF
  fork cursor is still dropped whole.
* The worktree ops lockfile keys on the REPO (`<repo>/.git/.worktree_ops.lock`),
  not on each registry's own root. `git worktree add|remove|prune` all rewrite
  the same `.git/worktrees` metadata, so the subagent owner and the thread owner
  were holding two different locks over one `.git` while the comment claimed
  they serialized. The startup sweep now takes each affected repo's lock in turn
  under the registry root lock (root -> repo, never the reverse).
* A permitted thread-worktree removal deletes its `thread/<name>` branch.
  Provisioning refuses to reuse an existing branch, so leaving it behind turned
  every removal into a permanent block on branching that thread off again.
* `ensure_project_scope` logs a registry REFUSAL (the chat-id reservation
  raising ValueError) at error level. It was falling into the generic
  `except Exception: log.debug`, so "a project collision is refused loudly" had
  no loud half and the task just kept running unscoped.
* The chat-binding index memo is honest about being a heuristic — a same-size
  rewrite inside one mtime tick is indistinguishable from no write — and adds a
  monotonic write counter that closes that window for this process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the git REQUIREMENT from attach also removed the only CONTAINMENT check
the attach surface had. They were never the same rule. "This folder is not a git
repo" is fine and is the whole point of A11/A12; "this folder is a subdirectory
of somebody's git repo" is not, and nothing was left to say so.

The consequence was not a refusal, it was a shadow repository. A repo subdir
attached cleanly, and the create dialog's `init_git` — or the owner's later yes
through POST /api/projects/{id}/init-git — ran `git init` INSIDE the owner's
repository. From then on the nested folder passed task admission as a worktree
root of its own, so every diff, rollback and commit Ouroboros made landed in a
repo the owner's VCS reports as one untracked directory. Their real history
never saw any of it.

Even without saying yes the folder was a trap: attach, resolve_promote_source
and adopt all PERSISTED the subdir as the project's place, and admission then
refused it forever with "workspace_root must be the git worktree root" — no
offer attached, no repair route, a project permanently unable to do file work.

So `validate_attach_path` gains one question, `enclosing_git_worktree`: is there
a git worktree root that CONTAINS this folder without BEING it? Only a
subdirectory answers yes. A plain folder answers no and still attaches; a
worktree ROOT answers no and still attaches. The refusal names the enclosing
repository, because the useful thing to tell someone who pointed at
`site/packages/web` is which folder to point at instead. Every caller — UI
create, init-git, promote attach, task-folder adoption — inherits it from the
one validator, and a git probe that fails or times out answers "" rather than
turning a probably-fine folder into a refusal.

Adoption needs a second rule the attach surfaces do not, because an adopted path
does not come from the owner's fingers: it comes from a task record, and a
task's workspace is exactly where Ouroboros's OWN ephemeral checkouts live. A
subagent's self_worktree and a thread's branch-off worktree are linked
worktrees; the subagent root is swept by the age GC. Either would have become a
project's durable place and then disappeared underneath it — and it would have
been a checkout of the system body, not the owner's work.
`ephemeral_checkout_reason` refuses both through the disclosure channel adoption
already has.

Last, the promise "an existing working_dir is never overwritten" was true of the
code and false of the timeline. Both places that made it read with get_project
and wrote with update_project — two separately-locked operations with a folder
validation, or an entire genesis provisioning, in between. Two writers
interleaving there both saw "no working_dir" and both wrote; when the loser was
ensure_project_workspace, its abandoned genesis tree sits under the durable
projects root, which is deliberately never GC-pruned. `set_working_dir_if_absent`
puts the test and the write inside one registry lock and returns who won, so the
loser reports the truth instead of assuming its own value landed, and an
orphaned tree is logged rather than silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…could read

Three things this phase left half-said.

The offer's message told the agent to do the owner's job. It read "Ouroboros can
start tracking it", and the agent reads that same sentence — it is the body of
the GIT_INIT_REQUIRED halt message on the promote path. Shell policy would not
have stopped it either: the git guard protects the Ouroboros runtime, not the
owner's tree, so `git init` in an attached project folder is permitted work as
far as the policy is concerned. Only doctrine stands between the offer and the
agent executing the yes on the owner's behalf, so the doctrine has to be where
it is read. The message now names whose act this is, and the same clause goes
into the task text's existing account of legitimate git work
(compose_workspace_block) and its DEVELOPMENT counterpart — one clause in the
places that already carry the rule, not a new rule accreted somewhere else.

`working_dir_error` on POST /api/projects/from-task was free text: no contract
described it, the JS mirror had never heard of it, and the client reads
`payload.project` and nothing else. It is the disclosure that a conversion
produced a project with NO PLACE — the conversion still succeeds, which is
precisely why the disclosure matters — and it reached nobody. Typed rather than
dropped: `ProjectFromTaskResponse` declares `working_dir` and
`working_dir_error`, api_types.js mirrors it under the field-level parity test,
the gateway also logs the failure so a browser that ignores the field is not the
only witness, and the convert flow raises a warning toast.

And ARCHITECTURE claimed the decision object is "carried unchanged by every
surface" including the promote outcome and the halted task's chat message. It is
not. The object reaches a client on exactly one surface, the /api/tasks 400
body; the promote path's outcome dict stops at the supervisor, and what the
owner actually receives is a reason code plus the decision's own prose. The
sentence now says that, in ARCHITECTURE, the contract docstring and the JS
mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The containment guard asked git where it was standing and read every
non-zero exit as "nothing encloses this folder". Git exits non-zero for
reasons that have nothing to do with containment: `safe.directory`
refuses a repository owned by another user, an older git refuses one
carrying unknown `extensions.*` (sha256, reftable), and a repository whose
config says `[include] path=<fifo>` never answers at all. In each case the
subdirectory of somebody's repository was ADMITTED — the exact shape the
guard exists to refuse, waved through by the failure of the thing asked to
refuse it. Unknown was being spent as yes.

So stop asking. Containment is a filesystem question and the filesystem
always answers: walk `parents` for a `.git`, directory or file, or a
bare-repo layout, and the first hit is the enclosing repository. A plain
folder and a worktree ROOT still answer "nothing" — A11/A12 intact — and
two shapes that used to slip through are now caught for free, because the
walk sees what git's own `--show-toplevel` will not admit to: a submodule
working directory (git calls it its own toplevel) and the interior of a
bare repository. Git's storage is refused outright besides; it holds a
repository, it is not a folder to work in.

Where a probe still runs it may only WIDEN a refusal, never produce an
admission, and it now runs with `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_COMMON_DIR`, `GIT_CEILING_DIRECTORIES`, `GIT_INDEX_FILE` and
`GIT_OBJECT_DIRECTORY` stripped — the clone path already scrubbed its
environment and the probes should have followed. Inherited, those decide
which repository git believes it is standing in, which is a fact about
whatever launched Ouroboros: `GIT_DIR` made a PLAIN folder report a
stranger's toplevel and get refused, and `GIT_CEILING_DIRECTORIES` stopped
the upward search so a real repository subdirectory reported nothing and
was admitted. Neither is a fact about the folder.

The durable-place rule had the same hole and gets the same treatment. A
linked worktree is now recognised by its `.git` FILE and its
`gitdir: …/worktrees/<name>` pointer, read off the disk, so a repository
that will not talk cannot hand a project a home that one `git worktree
remove` deletes. And `resolve_promote_source` — where the path is typed by
an AGENT, and the paths an agent has in hand are exactly the checkouts
Ouroboros makes for itself — now applies that rule, which until now only
`adopt_task_workspace` did.

Last, the refusal stops giving bad advice. "Attach that root instead" is a
recommendation, and when the enclosing repository is itself one of those
removable checkouts it recommended a home that gets swept. It still names
what it found; it just no longer calls it a destination.

The create route also runs the guard off the event loop now. It forks with
a five-second timeout, and one attach against a stalled repository froze
every other request for those five seconds; its sibling on the init-git
route already knew this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…an empty one

Making the folder bind atomic was right, and applying it to the
REPLACEMENT case was not. `ensure_project_workspace` also runs when the
registry row already names a folder that has since gone missing — that is
most of why it runs — and there "bind only if absent" is precisely the
wrong rule. It declined, so the function logged that the project had been
"given a folder concurrently" when nothing of the kind had happened,
returned the path that does not exist, and left the genesis tree it had
just dug orphaned under the durable projects root, which is deliberately
never GC-pruned. ARCHITECTURE has said all along that a project whose
attached folder vanished gets a provisioned replacement and keeps saying
`attached`; the code had stopped doing the first half.

The empty case still claims atomically. The stale case overwrites, on
purpose, and provenance keeps following the historical-fact rule: stamped
only where the row carries nothing. The test that covered this asserted
the return value was truthy, which a refusal satisfies just as well as a
replacement — it now asserts a directory exists there and that the row
names it.

The create route drops the last read-then-write bind: `get_project` and
`update_project` lock separately, and testing one then overwriting in the
other is the race `set_working_dir_if_absent` exists to close.

And `ProjectFromTaskResponse` takes its row in the frozen-contract table
beside every sibling minted this phase. Its `working_dir_error` is typed
exactly because the conversion succeeds either way — a contract nobody
documented is how the disclosure got lost the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… removal, lock re-entry

The ancestry walk refused an ancestor with NO project binding but accepted one
bound to a DIFFERENT project, with truncated left False — a hand-written
fork_of_chat_id pointing at project beta poured beta's whole conversation into
an alpha thread, silently, on the owner's history AND in the agent's focused
context. The parent's project_id must now equal the requesting chat's, or the
ancestor is refused on the same branch as the unbound case and disclosed.

remove_thread_worktree ran `git worktree remove` with check=False and a
swallowing rmtree, then reported removed:True and dropped the registry row even
when the checkout survived — an orphan holding the thread branch, invisible to
the registry, impossible to re-provision or remove again. A surviving path now
returns reason="removal_failed", keeps the row, and leaves the branch alone.

prune_orphans took the repo lock INSIDE the root lock, so a registry row with an
empty repo_dir re-acquired the SAME O_EXCL lockfile in the same thread: the
in-process RLock hid it, the file lock did not, and startup stalled for the full
120 s timeout before throwing the whole sweep away. A row with no repo_dir
touches no git metadata, so it now takes no inner lock at all.

loadOlderControlState early-returned on the first matching cause, so
['archive_floor','ancestry_depth'] named the fork chain and silently swallowed
the archive boundary that was also cutting the window. Causes accumulate: one
sentence per present cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
projects_registry.py sat at 1556 lines against the 1600-line HARD gate
(review.py::MAX_MODULE_LINES, enforced by test_smoke and the hermetic
preflight) and is not grandfathered, so T3's thread archive/tombstone
lifecycle would have blocked its own commit. Mechanical extraction, no
behaviour change: the thread block moves verbatim to
ouroboros/project_threads_registry.py and every public name stays
importable from ouroboros.projects_registry, so no call site moved.

The seam is the one the reviewer named and it holds: thread #0 is
PROJECTED, never stored, so nothing in the project lifecycle reads thread
state. The registry keeps its locking/IO primitives; the thread module
borrows them INSIDE the functions that need them, which keeps the import
graph one-way (registry -> threads) and keeps the registry the single
place those primitives can be patched.

Two members stayed, both because moving them would drag registry
internals across a seam they never cross: THREAD_NAME_MAX (an import-time
alias of PROJECT_NAME_MAX; reading it from the new module would need a
module-level borrow in the wrong direction and re-open the cycle) and
_backfill_thread_activity (named for threads, but it touches no thread
row — it reads the project's own chat_id, is driven by
reconcile_projects, and writes through update_project).

One test line moved with the code: the mint-retry test bends
thread_chat_id on the module where _mint_thread resolves it, and the
duplicate-scan test clears the memo where it now lives.

1556 -> 1234 lines; the new module is 418.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_force_rmtree`'s error hook called `os.chmod(p, stat.S_IWRITE)`, which
REPLACES the mode rather than adding to it. On a directory that leaves
`0o200` — write-only, no read, no execute — so the retry inside it, every
later retry, and the owner's own `rm -rf` all fail with EACCES. The hook that
exists to make teardown recoverable was making it permanent, and directories
are the common case because a worktree is torn down directory-first.

The repair is now additive (`current | wanted`), grants a directory its `+x`
back, and also relaxes the failing entry's PARENT — `unlink`/`rmdir` are
checked against the containing directory's write bit, not the child's.

Shared with the subagent registry, genesis projects and the durable thread
worktrees, so it is fixed where it lives. The regression test pins the exact
shape: a locked-down tree must come out removed, not bricked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emove it

`remove_thread_worktree` containment-checked the stored path against the root
resolved at REMOVAL time. Relocating `OUROBOROS_THREAD_WORKTREE_ROOT` — or
simply passing a different `worktree_root` — therefore turned every previously
provisioned row into `path_outside_root`: the owner's own branched-off checkout
became permanently unremovable through the API, recoverable only by hand-editing
the registry. The mirror hazard is worse: a moved root would ADMIT a path the
provisioning root never would have.

Provisioning now records `worktree_root` on the row and removal validates
against THAT. The guard is narrowed, never dropped — a hand-edited row pointing
outside its own provisioning root is still refused, so removal can never become
`rm -rf` on an arbitrary path. A pre-T3 row has no stored root and falls back to
the resolved one, which is the behaviour it was written under.

Carry-forward T0R2-9. ARCHITECTURE §1/§4 updated in the same commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s in it

Three carry-forward defects in the lease, all of which let two writers into one
folder while the prose claimed they could not.

T0R2-5: the key was `(project_id, workspace_root)`, so folder exclusivity held
only WITHIN a project — two projects attached to the same folder each got a lane
and ran concurrently. A task that names a folder now keys on that folder alone.
A task that names none keys on `(project_id, "")`, and both the docstring and
ARCHITECTURE now say so: the lane is a pure function read under the queue lock
and may not ask the registry which folder a placeless task will write in. This
is a deliberate reversal of T0's key; its pinned tests are updated with it.

T0R2-4: `normcase` lowercases on win32 only, so on macOS `/Users/x/Repo` and
`/Users/x/repo` — the same folder — produced two lanes. Case-insensitive
platforms now casefold, and the test asserts the opposite on a case-sensitive
one, where folding really would serialize two unrelated writers.

T0R2-7: the lane was recomputed from the task record on every assignment pass,
so a mid-run mutation moved a live task into a different lane — releasing the
folder it was still writing in. The lane is pinned at the RUNNING transition and
read back from there. The pin is write-once, with the one honest exception that
a post-hoc project conversion pins the lane it ACQUIRES rather than drifting out
of one it holds.

ARCHITECTURE §1/§3 updated in the same commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A7 says a thread's location is not a toggle, so nothing here stores one:
`thread_location` answers "where does this thread work" by asking whether a
durable worktree exists, and that single question is the entire state machine.

BRANCH OFF offers the current branch, every other branch and every tag, and
accepts any commit-ish typed instead — the list is an offer, not a restriction
(A8). "Exactly as it is now" is the one base that is not yet a commit, so it is
made into one, in the shape the repo already uses for the attach snapshot and
the coop checkpoint: local identity, credential-shaped files unstaged through
the one `_sensitive_untracked_reason` authority, the sha and the skipped paths
returned rather than swallowed. A clean tree makes no commit at all, because "as
it is now" is already a commit in that case.

MERGE BACK enforces A9: nothing running anywhere in the project — read from the
project-WIDE activity query, not the writer lane, and fail-CLOSED when the queue
cannot be read — plus a clean local tree. A conflict is SHOWN with its paths and
ABORTS the merge, so the owner's folder comes out byte-for-byte as it went in
and the thread keeps its branch and every commit in it. A successful merge does
not touch the checkout: removal stays the separate inspected act (A10).

A folder-less project is refused with its own reason; a non-git folder gets T2's
typed `git_init_required` offer built by the same `git_init_decision`, so
branching and task admission ask the owner one question rather than two.

Tests run real `git init`, real `git worktree add` and a real conflicting merge
— mocking git here would pin our beliefs about git rather than git's behaviour,
in the one place where the difference costs the owner their work.

ARCHITECTURE §1 updated in the same commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…recall from its neighbours

Two fixes `chat.js` needed before a thread could live in the centre (X8).

`asPanel` answered two unrelated questions at once — "which DOM shape am I?"
and "do I render the global agent controls?" — so mounting a project thread in
the CENTRE meant choosing between duplicating the one agent's
Evolve/Review/Restart/Panic chrome and pretending to be a right panel. They are
now `layout` ('page' | 'panel' | 'centre') and `chrome` ('global' | 'thread'),
independently. `asPanel` remains the back-compat shorthand for the pair the
right panel used, so no existing call site changes meaning.

Composer recall (up/down through what you last sent) read the bare global
`ouro_chat_input_history` key while the transcript cache and the draft were
already scoped by `storeKey`. Every project room therefore recalled — and
appended to — the main chat's sent messages. It was always wrong; threads only
make it easy to see, since one project can now hold many rooms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Andrei Kaznacheev and others added 30 commits August 10, 2026 14:07
…-dropping mutation cannot survive

The merge's own commit message names the defect neither stream could see: the
lane must be computed with the SAME `project_workspaces` map the admission check
used, or a task admitted as `("", registered_folder)` freezes `(pid, "")` and the
folder reads as unheld to the very next candidate — the pin, whose purpose is to
stop a live writer drifting out of its lane, becomes the thing that drifts it.

All five sites pass the map and NONE of them had a test. Five mutations dropping
it survived the entire relevant suite. The two tests that looked like guards
structurally could not see it: `test_project_lease.py` calls `pin_task_lane`
itself, so it pins the FUNCTION's contract and never a caller's obligation, and
`test_project_lease_ui_conversion.py` makes its lane assertion with the map on
NEITHER side, so both sides reduce to `(pid, "")` and it returns the same answer
with the fix and without it.

So each site is asserted through its PRODUCTION entry point, on the value the
site is obliged to produce:

* `assign_tasks` — one pass over a placeless project-scoped candidate, then
  `RUNNING[tid]["task"][LANE_PIN_FIELD] == ["", normalize_workspace_root(folder)]`;
* `ensure_project_scope` — the same field after a mid-flight self-scope;
* `api_project_from_task` — a placeless RUNNING task converted into a project
  that HAS a folder (`create_project` is idempotent, so naming an existing
  project adopts its folder — the one pairing the map exists for);
* `queue_notice` — a placeless holder in the project folder and a BRANCHED thread
  asking, where the truthful answer is "nothing waits": read without the map the
  holder's folder is unresolvable and the notice warns about a wait that does not
  exist, one line above a Branch off… offer that already happened;
* `_project_lane_wait_suffix` — the same fact on the scheduled-task sentence.

Plus a SOURCE guard for the sixth site, because `project_workspaces` has a
default: a caller that omits it compiles and passes its own behavioural tests.

Mutation proof (each mutation applied to this tree, then reverted): 0/5 survived.
M1/M2/M3 fail with `KeyError: '_lane_key'`, M4/M5 with the false-warning
assertion, and the source guard names the offending line for all five.
…shot commit finally asks it

I5. The merge unioned `reserved_folder_lane` into `running_project_lanes` so the
SCHEDULER sees a merge-back holding the folder it is rewriting. `project_is_busy`
— the SSOT every owner gesture's precondition reads — kept reading only
`running_project_ids` + `running_workspace_roots`. Reproduced with an empty queue:
during a merge-back `reserved_folder_lanes()` and `running_project_lanes([])` both
report the folder held while `project_is_busy` answers False, so a second
merge-back, a `remove_thread_worktree` and a thread delete were all told IDLE —
and a second holder was in fact admitted. All three run under `asyncio.to_thread`,
so this is genuinely concurrent, not theoretical.

`project_is_busy` now consults the reservation set, keyed on the same
normalization it already applied two lines above. `include_own=False` drops the
reservations THIS thread holds, because merge-back asks the question from inside
its own reservation and an operation refused by its own claim would never run at
all; two genuine concurrent holders each see the other's and both stop, which is
the fail-closed direction. And `remove_thread_worktree`'s judge asks about the
PROJECT folder as well as the checkout: a merge-back holding the project folder
rewrites the same repository's `.git/worktrees` and reads the very
`thread/<name>` branch the removal deletes, and the project-wide half of the query
counts tasks, which a non-task holder is not.

I6. `merge_back_thread` guards its write twice — it takes the folder's lane and
then asks `project_is_busy`. `branch_off_thread` with `base_ref="@snapshot"` runs
`git add -A` and a commit in the SAME folder and did neither. Reproduced with a
task holding the lane: `project_is_busy` answers True for that exact folder one
line earlier, the gesture never asks, and a live task's half-written
`half_written.tmp` became a commit on the owner's branch.

The snapshot arm is now wrapped the way merge-back wraps its merge, and ONLY that
arm: every other `base_ref` reads a commit-ish and writes nothing to the project
folder, so branching off a branch, a tag or a commit keeps working while a task
runs — which the refusal copy says out loud, because "wait" without "here is what
does work" is a dead end.
… or refuses and says which one

BLOCKER. `api_project_delete` -> `run_project_deletion` never called
`remove_thread_worktree`; verified, its only callers were the two THREAD routes.
`api_thread_delete`'s own docstring states the rule and the reason — a tombstoned
thread is invisible on every surface, `list_thread_worktrees` has no route,
branch/merge refuse `thread_not_live`, so leaving the checkout behind orphans a
folder and a branch that A10's explicit removal can no longer reach, on durable
state exempt from every GC. Every clause is equally true of a tombstoned PROJECT
and nothing applied it.

Reproduced: after the project delete the worktree registry still holds the row,
the checkout is on disk, and `POST …/worktree/remove` still works over HTTP — but
no UI entry point survives, because every one hangs off a project row that no
longer exists. A file committed only inside that checkout was
destroyed-by-orphaning with no word, so D4's "a thread's worktree is NEVER
removed silently" had a hole exactly one gesture wide.

BEFORE the fence, the route reads this project's rows and asks
`checkout_work_at_risk` — the NARROWER judge thread deletion uses, so a stray
`node_modules/` never blocks a delete and unmerged commits, tracked-file edits or
an unreadable checkout always do. Work at risk refuses with a typed
`threads_hold_checkouts` naming each thread, built from the SAME
`_delete_refusal_message` a single thread's deletion uses so the two gestures
cannot explain one fact two ways. Asked before anything is fenced, because a
refusal must leave the project exactly as it was.

Otherwise the checkouts go WITH the project, inside the fence, and the answer
says so: `worktrees_removed`, `branches_removed`. A checkout a task is still
writing in is refused by the removal's own busy judge — correctly, it deletes a
folder under a live worker — so it is reported as `worktrees_pending` and swept
by the cancellation worker once the project quiesces, which is the one moment the
tasks are provably gone. No path leaves an orphan silently.

`web/modules/project_create.js:337` promised "its id, chat history, task
bindings, memory, and working folder are preserved" and said nothing about N
checkouts and N `thread/*` branches. It does now, including that the delete stops
and names the thread when one holds work the project folder never received.

`ProjectDeleteResponse` gains the four fields and JOINS the field-level parity
loop — it was only in the name-level one, so the new fields could have landed on
one side alone, which is the exact failure mode a mirror cannot catch by itself.
`room_chat_lens_dir(drive_root, project_id)` took no room chat id and answered
`project.working_dir` unconditionally, while both callers had the chat id in
hand. Reproduced: for a branched thread the TASK workspace is the checkout and
the CHAT lens is the project folder — the agent looks at one tree and writes in
another. `thread_checkout_for_room`'s own docstring names this class: "the
robot-room incident: `.` resolved to the system repo and the agent narrated the
wrong tree".

Worse, `context.py` stated it to the MODEL as fact: "the promoted task inherits
this folder as its workspace", naming `working_dir` — false for a branched
thread, whose promoted tasks land in its checkout instead.

The lens now takes `room_chat_id` and applies the SAME precedence
`resolve_room_workspace` already does — `thread_checkout_for_room` first, the
project's `working_dir` otherwise — including its loud "the registry is
unreadable" arm, because answering "the project folder" for a registry we could
not read is how the writer gets put back in the folder branching exists to keep
it out of. `agent.py` passes `self._current_chat_id`, `context.py` passes
`task["chat_id"]`, and the room fact carries the RESOLVED folder as `room_dir`
with a rule sentence that names it and says plainly that for a branched thread it
is not the project's `working_dir`.

The argument has a default — the resolver is also asked project-wide questions —
so a source guard reads the call sites and refuses one that forgets the room,
in the shape `test_every_resolve_room_workspace_call_site_names_the_room`
already established for exactly this hazard.
…, and the rows that could not say what they do

I9. `unmerged_work` has an owner-answerable flag — `acknowledge_unmerged`, in
this route's own body — and the payload never declared `acknowledgeable`, while
the shared envelope documented that field for `checkout_dirty` alone. The
client's only producer was a PRE-FLIGHT read: `removeCheckout` trusted the
inspection captured when the MENU was opened. If the checkout went dirty in that
window — an agent writing a log is the normal case — the call went out with
`acknowledged: false`, the server refused, and `answerRefusal` bailed on
`!described.acknowledgeable`. The sentence said "or confirm you want it gone" and
the gesture provided no way to confirm; recovery was closing and reopening the
menu, which nothing disclosed. The payload declares it, the envelope's wording
covers both refusals, and `removeCheckout` rides `withAcknowledgement` like
`mergeBack` and `deleteThread`.

I10. `withAcknowledgement` laundered one consent into a different one: ANY
answered refusal was followed by `run(true)`, so answering T2's
`git_init_required` OFFER ("yes, start tracking this folder") was sent as
`acknowledge_checkout_dirty: true` — the owner never saw the dirty-checkout
sentence and never said "Merge anyway". Reachability is narrow; the defect is
structural, because the function could not tell WHICH refusal was answered.
`answerRefusal` now reports it (`decision` | `acknowledged` | `declined` |
false) and the retry passes the flag only for `acknowledged`.

I14. Declining a refusal replayed the identical sentence as an alert.
`'declined'` is deliberately distinct from `false` — the owner SAW a question and
said no, versus this refusal had nothing to ask — and only the first suppresses
the announcement. A refusal nothing could ask about is still shown.

I8. Branching thread #0 is coherent: siblings are NOT moved, they keep the
project folder, and `thread/racer__0` merges back into the folder they write in.
What lied was the row. The four checkout rows sit on the PROJECT row menu beside
Rename…/Fork thread/Delete project…, and for thread #0 `thread.name` IS the
project name, so the owner read "Base for Alpha's own checkout" under a menu
titled "Actions for Alpha" and could not tell they were moving the project's CHAT
rather than the project. One sentence of preface, in the existing vocabulary; no
new rows, no new screens.

I13. The archived-threads menu emits only `restore:`, so an archived thread could
not be deleted, merged back, inspected or have its checkout removed — even though
`begin_thread_deletion` accepts archived by design and `_live_thread_refusal`
permits branch/merge on it, and a branched-then-archived thread's checkout was
two undisclosed steps from any A10 surface. The row now says why: "restore it to
act on it".

I17. `removal_failed` was the one refusal with no sentence — `_removal_message`
fell through to "The checkout could not be removed." while the actual cause sat
in the log and `branch_kept_reason` carried a usable sentence `describeOutcome`
never reads on a refusal. That is the "something went wrong" shape this module's
docstring forbids. It now names the inspection error, what usually holds a
checkout open, that the removal is safe to retry, and why the branch stayed.

`project_threads_menu.test.js`'s "leaves the refusal on screen" assertion is
updated deliberately: it pinned the replay I14 removes.
…d three holes with nothing behind them

I11. The sidebar fingerprint omitted `lifecycle` and `delete_error` from the
per-thread tuple — exactly the two fields the merge added consumers for.
Reproduced over a real `projects_summary`: rewriting a thread's `delete_error`
and flipping a thread to `deleting` both left the fingerprint byte-identical. So
`Retry delete` showed the generic fallback forever, and an `active -> deleting`
transition from another tab or a resumed worker left this tab painting an
ordinary full-menu row.

I12. `threadRowPresentation` states the rule — "a thread on its way out is not a
thread to open" — and implemented it for `draggable`/`showsUnread` only. The row
kept its click handler and `openThread` guards only the PROJECT lifecycle (the
project row does it correctly with `btn.disabled`). The admission fence then
worked and `chat.js` rendered it as "Project is unavailable", while `server.py`'s
own comment says a thread can be fenced inside a perfectly healthy project. The
row disables off the same derived flag, and the annotation is now true of both
cases. Nothing closed the centre stage when the thread tombstoned either — the
row lookup found nothing and did nothing, leaving the stage kebab inert — so the
stage closes when the open thread leaves the projection, which is what the
project row already does one branch up.

I15. `tests/test_web_dialogs_static.py` scanned `web/modules/**/*.js` only, so
`web/app.js` — the largest client file — sat outside the native-dialog ban's only
automated enforcement. No violation there today; the hole is the finding.
Demonstrated: a `confirm(` injected into `web/app.js` passes the old scan and
fails the new one.

I16. `fetchJson` set `data = {error: …}` for an unparseable body and did NOT
throw on a 2xx, so `threadOps.bases` returned `{error: …}`, `listed.ok` was
undefined rather than false, the refusal branch never fired, and `branchOff`
rendered an EMPTY numbered base offer and asked the owner to type one. A body we
could not read is a transport failure whatever the status line says. (No
`fetchJson` caller depends on an empty 2xx body — the one 204 route is read
through `apiFetch` directly.)

I19. `_force_rmtree`'s `_relax` never reverts the chmod: an observed `0o555`
worktree root came back `0o755`. Bounded by the containment check and correct for
a tree being deleted, but the docstring described the repair as enabling a retry
rather than as a persistent permission change on the owner's filesystem. Stated.
…assing here

Two new files, one per side, holding the guard for every finding whose fix landed
in the commits above. They are kept together deliberately: these are CROSS-STREAM
defects, each living in the seam between two phases built on different
foundations, and reading them as one set is how the next round sees the class
rather than nineteen unrelated bugs.

`tests/test_threads_integration_review.py` (19 tests, all 19 fail on 58fc485's
tree): the project delete refusing and then taking the checkouts, and the sweep
that takes the one a task was still writing in; `project_working_dirs` telling
unreadable apart from empty and the lane refusing a second writer in BOTH
directions; the pin that is not written when the lane cannot be resolved, so the
outage does not outlive itself; a requeued task carrying no stale pin;
`project_is_busy` seeing a non-task holder while its own holder is not refused by
its own claim; the snapshot base refusing while a task writes, with every other
base still working; the chat lens following the room into its checkout and the
room fact naming the folder it resolved; the removal refusal declaring that it can
be answered; and `removal_failed` saying something.

`web/tests/threads_integration_review.test.js` (11 tests, 8 fail on that tree —
the other three are deliberate controls): the thread-#0 preface and its absence
for an ordinary thread, the checkout that went dirty since the menu opened being
confirmable in the same gesture, the `git_init` yes NOT travelling as
`acknowledge_checkout_dirty` while a real acknowledgement still does, the archived
row disclosing why Restore is its only action, an unparseable 200 throwing rather
than becoming an empty base offer, and a declined question staying the last word.

Every git-touching test runs REAL git and the REAL supervisor queue: mocking
either would pin our beliefs about them rather than their behaviour, and these
gestures destroy an owner's folder when they are wrong.
The writer lane decided a PLATFORM question itself — `sys.platform` against a
module-local `_CASE_INSENSITIVE_PLATFORMS` tuple — while `platform_layer.py`
already exported `IS_WINDOWS`/`IS_MACOS` next door. Zero behaviour change: the
fact becomes `platform_layer.PATH_CASE_INSENSITIVE` and the folding becomes
`platform_layer.casefold_path`, which `normalize_workspace_root` consumes.

The two lease tests that imported the tuple now import the platform flag, so
there is still exactly ONE copy of the fact and neither assertion can pass
vacuously (I18). ARCHITECTURE's lane paragraph names the new seam in the same
commit.

Six other modules read `sys.platform` directly; they are PRE-EXISTING and
deliberately out of scope for this fix.
`thread_worktrees` reused `subagent_worktrees.run_git`, which passes no timeout
to `subprocess.run`, at 12 call sites (provision x5, inspect x4, remove x3).
Reproduced: with a hanging `git` first on PATH, `inspect_thread_worktree` never
returns. The "pre-existing, disclosed" defence does not hold — the helper is
old, but every HTTP path to it is new with T3: `GET /worktree`,
`POST /worktree/remove`, `branch-off`, `merge-back`, thread `delete` and
`DELETE /api/projects/{id}`. A wedged git held an owner's request and a
thread-pool thread forever, with the repo's git-op lock still taken, so every
other worktree gesture on that repo queued behind it.

- `subagent_worktrees._git` gains `timeout: Optional[float] = None`, a plain
  pass-through. The default stays UNBOUNDED so its own callers — background
  provisioning and the startup orphan sweep, which nothing waits on — are
  untouched.
- `thread_worktrees` gains `_git_timeout_sec()` (the shipped
  `OUROBOROS_THREAD_GIT_TIMEOUT_SEC` SSOT knob, the same one branch-off and
  merge-back read) and a module-local bounded `_git`; all 12 sites go through it.
- An expiry is a TYPED outcome, not a traceback: `check=False` returns rc=124
  with the ceiling named, so the inspection reports `error` (already "cannot
  tell", i.e. unsafe) and removal answers `removal_failed` keeping its row;
  `check=True` raises into the channel `branch_off_thread` already converts into
  a typed `branch_failed`.

ARCHITECTURE's thread_worktrees entry and DEVELOPMENT's branch-off bullet say so
in this commit. An AST guard fails if a future git call skips the seam.
…older is enough to occupy it

Reproduced 3/3 on the merged tree. Two independent halves, and the reviewer's
own prescription — "fence routing before destructive work" — is DECLINED: it
reverts 86aaf2b, whose purpose is that a REFUSED delete never removes the
checkout first. Refuse-first ordering is untouched here.

(i) `remove_thread_worktree` held NOTHING. `_project_is_busy` is a bare read, so
between "nothing is running in this checkout" and the `rmtree` the scheduler
could admit a task straight into the folder being deleted, and a message arriving
after the busy check could queue work into it. The inspection and both deletions
now run inside `project_lease.reserved_folder_lane` on the CHECKOUT — the same
reservation merge-back already holds over the project folder, and
`reserved_folder_lanes(include_own=False)` is why the holder is not refused by
its own claim. A reservation closes the check-then-act gap without reordering
anything.

(ii) `_is_lane_occupant` short-circuited on `bool(project_id)`, so (i) would not
have bound: with the lane held, a project-scoped candidate for the folder was
correctly refused while a PROJECTLESS candidate naming the SAME folder was
admitted into it. Occupancy now follows what a task NAMES —
`project_id or workspace_root` — which is what `running_workspace_roots`' own
docstring already said the gap was. Probed for over-serialisation: the subagent
exemption still runs FIRST (a swarm carries its parent's root and must not
deadlock against the parent), a task naming a DIFFERENT folder is untouched, a
task naming neither a project nor a folder still holds no lane, and nothing
becomes permanently refused — the reservation releases in a `finally`.

`test_post_hoc_conversion_pins_the_lane_it_acquires` had a premise that (ii)
makes false (a folder-naming task is an occupant before any conversion), so it
now exercises the PLACELESS task, which is the one that acquires a lane, and
pins the folder-naming case separately.

ARCHITECTURE's project_lease and thread_worktrees entries carry both facts.
…t leaves behind is disclosed

Reproduced end to end. A clean checkout passes `api_project_delete`'s pre-fence
`project_checkouts_at_risk`; the route's own removal correctly refuses
`project_busy` because a task is still writing there; the still-running task then
commits `only_copy.txt` and edits tracked `app.txt`; the post-quiescence sweep
called `remove_project_thread_worktrees`, whose `acknowledge_unmerged=True` was
HARDCODED, and destroyed both with no re-inspection and no fresh consent —
acknowledging on the owner's behalf, which A10/D4 forbid.

- `remove_project_thread_worktrees` now re-inspects PER ROW with the same
  `checkout_work_at_risk` judge the route used and passes
  `acknowledge_unmerged=not risk["at_risk"]`. Rebuildable dirt the owner already
  confirmed still goes without a second prompt (H-ter's easy delete is intact);
  a newly at-risk checkout comes back `unmerged_work` and lands in `kept`.
- Each `kept` entry now carries `path` and `branch`, so the disclosure can say
  WHERE. `ProjectDeleteResponse` and its JSDoc mirror say so (the inner shape is
  `Dict[str, Any]`, so field-level parity is unaffected).

Second half: a `removal_failed` used to end with the project tombstoned, the
checkout on disk, the registry row present and `delete_error == ""` — the sweep's
`log.warning` reaching no owner surface. The reviewer's prescription (keep the
project untombstoned) is DECLINED: it collides with §I M2 and with H-ter's
"deleting a thread with its worktree must be easy". Done by DISCLOSURE instead:
`_sweep_project_checkouts` returns a sentence naming every survivor's folder,
branch and reason; `complete_project_deletion` takes it as `delete_error` and
writes it onto the tombstoned row; and because a tombstoned row is filtered out
of `list_sidebar_projects`, the owner is also told in chat through the same
`send_with_budget` surface `evolution_lifecycle` already uses for background
lifecycle outcomes. Fully guarded — a disclosure that cannot be sent must not
take the deletion down, and the note is on the row either way.

ARCHITECTURE's projects-registry paragraph and DEVELOPMENT's thread-lifecycle
bullet carry both halves.
…it is disclosed

The paid reviewer blamed the outer `except` in history.py:180-184 and
context.py:899-901. Those are near-unreachable; the true root is one level
deeper and worse. `resolve_chat_binding` already fails closed to `{}` on an
unreadable registry (correct for ROUTING — an unplaceable message belongs in
Main), `thread_history._chat_binding` passed that `{}` through, and an empty
binding for the REQUESTED chat took the degenerate own-thread early return with
`truncated=False`. Reproduced: healthy lens `{51910152, 168469081}`; registry
made unreadable -> lens `{168469081}`, `truncated=False`, and `_window_metadata`
answers `{'complete': True, 'truncated_by': []}`. NO exception is raised at all,
so there was nothing for either outer handler to catch. Both surfaces affected;
BIBLE P1, no-silent-truncation.

- `resolve_chat_binding` gains `strict=False`. Every existing caller is
  byte-identical; `strict=True` re-raises the read failure, so the ONE seam gives
  two honest answers instead of a second lookup that could drift from it.
- `_chat_binding` now returns `None` for "could not read" as against `{}` for
  "the registry answered and this chat owns no thread".
- `ThreadLens` gains `lens_unavailable`, set (with `truncated`, so every existing
  consumer already reacts) only in that third state — for the requested chat AND
  mid-chain for an ancestor whose binding could not be read.
- `_window_metadata` reports a `lens_unavailable` cause; the client's load-older
  notice gives it its own clause and excludes it from the archive/lineage wording
  that would misname where the conversation is.
- `context.py` appends a `## Conversation gaps in this view` section BEFORE the
  conversation it qualifies. Degrading to own-thread rows stays the right
  narrowing; doing it silently was the defect.
- Both fallbacks (`history.py`'s substitute lens, `context.py`'s `_lens = None`)
  now disclose too, so the reviewer's original path is covered as well, and both
  log at warning rather than debug.

A genuine non-project chat still answers `{complete: True, truncated_by: []}` —
the distinction has to cut both ways or the disclosure cries wolf on every read.
ARCHITECTURE:577's ancestry sentence names the third state in this commit.
…sclosed

Reproduced: fork + parent, the ancestor row at position 0, then 4050 unrelated
Main rows, then the fork's own turn. `memory.read_jsonl_tail("chat.jsonl", 4000)`
gives the agent ONLY its own message while `lens.admits(ancestor_row)` is True.
`Memory._read_jsonl_entries` opens the live file alone (the string `archive` does
not appear in it), where `gateway/history.py` has archive backfill. NOT
carry-forward T0R2-8 (that is the origin-fallback synthesis, disclosed verbatim
at ARCHITECTURE:577/:88) and not §C+'s archive cap.

Owner scope decision: the DISCLOSURE half only. Per-ancestor bounded reads across
archives overlap the §C+ deferral that was deliberately left out of scope, so
`context.py` now says what it could not cover instead of going to get it, and the
loss itself is unchanged on purpose.

`_shared_past_beyond_scan` fires on either of two one-directional facts — the
scan hit its cap, so older rows exist that it never looked at (the case a real
install reaches: fork, then 4000 rows after it), or the oldest scanned row is
newer than an admitted ancestor's cutoff, so none of that ancestor's window is in
view at all. Both establish that rows were not READ, never that rows existed, so
the wording says the shared past may not be complete rather than asserting a
loss. It rides the same `## Conversation gaps in this view` section P6 added,
placed BEFORE the conversation it qualifies. A non-fork thread never gets it, and
neither does a fork whose scan reaches back past the fork point.

ARCHITECTURE's "the owner and the agent can never see different histories of the
same thread" was false in two places and is narrowed in this commit: the lens
gives one DEFINITION of the shared past, the two READERS are not the same, and
both divergences are now named — the history-only origin-fallback synthesis, and
this scan horizon. The `thread_history.py` module-map entry says the same.
`ensure_project_workspace`'s REPLACEMENT branch was a plain `update_project` with
no `_file_write_lock` and no comparison against the value that was observed
vanished — the exact read-then-write gap `set_working_dir_if_absent` exists to
close, on the one branch that opted out of it, with an entire genesis
provisioning inside the gap.

Reproduced 2/2. Two callers both observe the same vanished `working_dir`, both
provision (`genesis_1`, `genesis_2`), both write: the registry ends on one and the
other is orphaned under the never-pruned durable projects root, while BOTH callers
are handed their own path back, so one reports a binding that does not exist.
Second reproduction: an owner attach landing between the read and the write is
silently overwritten.

`replace_working_dir_if_unchanged(drive_root, pid, expected, new, provenance=…)`
sits beside `set_working_dir_if_absent` with the same `_file_write_lock`, writes
only while the row still holds `expected`, and returns `(effective, claimed)` so
the loser logs its abandoned tree and reports the winner's path. Provenance keeps
the historical-fact rule and is now judged UNDER the lock rather than from the
caller's stale read.

DEVELOPMENT.md's atomic-binding bullet documented the deliberate choice only
against `if_absent`'s semantics and told the next reader that a replacement "stays
`update_project`"; it is amended in this commit to name the new primitive and the
race the old advice reintroduced.
`project_threads.js:1035` was the only unguarded `await ops.*` in the module.
Verified NARROWER than filed, and the narrower half is real: a TYPED refusal was
already handled (`typedAnswer` unwraps a 409 envelope to a VALUE, so
`describeOutcome` + `announce` fired), so the finding's "propagates fetch/API
failures" is wrong for the majority case. But a 500, an HTML error page or a
transport error re-throws — the rejection escaped `onSelect`, and
`project_create.js:246`'s `menu.addEventListener('click', async …)` has no
try/catch, so it became an unhandled rejection with no owner-facing error, no
refresh, and a stale archived row left clickable.

Restore now goes through `runThreadAction('restore', …)`, which already owns
exactly that shape: the catch, the announce, `onChanged({authoritative:true})` on
both paths, and the refocus. The id sent to the route is unchanged (the string the
menu carried), so the existing menu test still pins `['alpha', '4']`.

ARCHITECTURE's thread-actions paragraph now states that `runThreadAction` is the
ONE place a thread gesture calls `threadOps`.
Two precision fixes on top of the round, no behaviour change beyond the wording.

P8: the capped-scan clause said "older rows exist that it never looked at". A live
journal of exactly the cap length with no archive behind it has none, and a
disclosure must not assert what it cannot see — so it now says "anything older
than it was not read", which is true either way and still carries the same
warning. The ancestor cutoffs are also read from the dict's own keys rather than
re-derived, so a non-int key cannot silently drop an ancestor from the comparison.

P3: the comment block left where `_CASE_INSENSITIVE_PLATFORMS` used to be was
still spelled as a Sphinx attribute doc (`#:`) with no attribute under it.
Docstring only. The risk judge runs outside `remove_thread_worktree`'s git-op
lock, so work appearing between it and the inspection taken inside would still
ride the acknowledgement. That window is now the same one `api_thread_delete` and
`api_project_delete` already have — an ops-lock acquisition and one `git status` —
rather than the whole fence-and-quiesce span the hardcoded acknowledgement reasoned
across. Closing it entirely means moving the judge inside the lock and changing the
removal's signature, which is deliberately not done here; it is stated instead.
…epeating itself

Found by actually opening the sidebar — the gate DEVELOPMENT.md asks for and the
one thing no reviewer could do by reading. I13's disclosure rendered as "Restore
this thread — restore it to act on it": a tautology beside the word "Restore"
that told the owner nothing about WHY the row is the only one offered. It now
names what comes back with the thread — merging back, its changes, its checkout.

The test that pinned the old phrasing now asserts the substance instead, with a
note saying why: pinning one wording is how a sentence that reads badly survives
a green suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The P4 reproduction wrote a fake `git` of `#!/bin/sh` + `sleep 3600`. The shell
forks the sleep as a CHILD, so when the timeout kills the shell the sleep is
re-parented to init and outlives the whole session. The preflight container
reports precisely that — PREFLIGHT_CONTAINMENT_FAILED, "still alive after a
best-effort kill: <pid>" — and refuses the pass verdict whatever it was, because
a leaked tree survives into the next pass and past teardown. It is right to.

`exec sleep 3600` makes the process the timeout kills BE the sleep. Verified: the
file's 34 tests pass and no `sleep 3600` remains afterwards; three orphans from
earlier runs of the old form were reaped by hand.

Found by chasing the gate's own block rather than explaining it away, after two
wrong guesses of my own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…andidate

I3's fix left the hole inside the branch that exists to cover it. With the
folder map unreadable, `candidate_is_leasable` compared a folder-bearing
candidate against `(its own project_id, "")` alone — so a RUNNING placeless
task in project *alpha* held `("alpha","")` while a candidate naming
`/w/shared` under project *beta*, and a projectless one naming it, both
matched nothing and were admitted. Reproduced: two writers in one folder, the
one thing the lane exists to prevent, while the same-project candidate beside
them correctly queued.

A narrow lane says "this running writer's folder could not be read". Whether
it is the folder a candidate names is exactly as unreadable — and projects may
share a folder, which is why the lane is folder-keyed at all. So under a
missing map every unresolved project lane conflicts with every folder-bearing
candidate. It stays narrow in every other direction: a real map (even an empty
one) is untouched, a RESOLVED folder lane still never blocks a different
folder, and an unscoped task still never serializes.

The module docstring and ARCHITECTURE both stated the old, weaker rule as if
it were the invariant; both now state what the code does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_shared_past_beyond_scan` treated `len(scanned) >= 4000` as proof that an
ancestor's window was out of reach. A live journal at 4000 rows is the ordinary
state of any busy install, so the `## Conversation gaps in this view` section
was permanently on for every fork — including forks whose entire shared past
sits inside the window with thousands of older rows read behind it. I flagged
that noise risk when the disclosure landed and shipped it anyway. A warning
that is always on is one the reader learns to skip, which costs exactly the
warning A3b exists to give.

The test is now PER ANCESTOR, and the cap is necessary rather than sufficient:
either the oldest scanned row is newer than that ancestor's cutoff (none of its
window is in view), or the scan is full AND shows no sign of having reached
back past that ancestor's beginning — it holds none of that ancestor's admitted
rows, or the oldest one it holds is the oldest row the scan read at all.
Ownership is resolved the way `admits_row` resolves it, binding first, so a
post-hoc bound task's rows count for the ancestor that owns them. Only the
ancestors it applies to are named.

The residual is named, not silent: an ancestor SILENT across the oldest rows of
the window and active before it reads as covered. Telling that apart needs a
read BELOW the window — the deferred archive-cap work this function discloses
instead of doing. ARCHITECTURE had only ever described the cutoff condition, so
it was already describing a narrower rule than the code ran; it now describes
both, with the residual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tombstoned project is on no surface that could show the owner a checkout that
survived its deletion, so `_orphaned_checkouts_note` names every folder and
branch — and then cut the joined lines with a flat `[:2000]`. Reproduced at 30
survivors: 13 checkouts disappeared from the only record that names them and
the sentence ended mid-word, with nothing saying anything had been dropped.
That is BIBLE P1 and DEVELOPMENT.md's "No silent truncation" broken on the one
text that may least afford it — this whole feature exists to stop the system
being quiet about what it did with the owner's work.

The bound stays; the silence goes. Entries are dropped WHOLE, never mid-path,
and what does not fit is declared: the count, the thread ids (short, and the
key to finding the folder), and where the unabridged list is — the caller's
`log.warning` already writes it, and the note now says so instead of it being
a record nobody knows exists.

Every other hand-rolled `[:N]` in this chain goes through the repo's own SSOT,
`utils.truncate_review_artifact`, which carries the omission marker and the
anti-waste floor: `complete_project_deletion` and `fail_project_deletion`'s
`delete_error` (bounded at a named `DELETE_ERROR_LIMIT`, roomy enough not to
re-cut a note that already declared its own omissions), the sweep's own
exception sentence, a surviving checkout's `reason`, and the git text a failed
inspection reports.

`fail_project_deletion` is pre-existing rather than this branch's, and is fixed
here because it writes the SAME field through the same cap; leaving it would be
patching the instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The inspection WAS done — a live instance driven by hand: a project on a real
git folder, threads created, one opened as the centre chat, one branched into
its own worktree, one archived and restored, every thread-menu row's enabled
state and reason read. It found a real defect no assertion could (the archived
row's tooltip was the tautology "Restore this thread — restore it to act on
it", pinned by its own test), fixed in 1fffc24. What the tree did not carry
was any durable record that it happened.

Recorded in the repo's own form rather than a new artifact type: the two
threads consumer-flow Playwright tests now write their states to
`OUROBOROS_UI_EVIDENCE_DIR`, following the `v679-depth-*` precedent, and their
docstrings name what the pass covered and what it corrected. A saved screenshot
is not verification on its own (DEVELOPMENT.md "Responsive and accessible
behavior"); it is what makes the inspection re-runnable by the next reader —
which is exactly what was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mits

Two reviewers have now argued that `typedAnswer` would render a JSON 500/502
carrying `ok:false` as an owner-answerable refusal. Driven against all eight
routes `threadOps` calls: every failure answers through `json_exception`, whose
payload is `{"error": ...}` — no `ok`, no `reason` — so `typedAnswer` re-throws.
Anything escaping the guard is Starlette's plain-text 500, which `fetchJson`
turns into `{error: "non-json response (HTTP 500)"}`: again neither key.
`_refusal_status` never returns 5xx and the app installs no handler or
middleware that could rewrite a body.

So no client code is added for an unreachable path. What is added is the probe
itself, as a test: if a route ever starts answering a failure with `ok` or
`reason`, the refutation stops being true and this fails loudly — which is the
honest way to close a finding twice raised and twice unconfirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate finding calls the `supervisor/queue.py` entry a weakening of the
deterministic size gate. Re-read against BIBLE and the entries already there,
the precedent holds exactly — `config.py` sat at EXACTLY 1600 and crossed on
one owner setting plus its clamped getter; `workers.py`/`events.py`/
`control.py`/`registry.py` sat at ~1591 and crossed on the acting-subagent
gating; each records a reason and a tracked follow-up — and the exemption set
is the gate's own sanctioned exception SSOT, named as such in CHECKLISTS.md.
So the entry stays.

What did not hold was its reasoning. "It has no reclaimable line that is not
load-bearing prose" is only half true: there is no SMALL one (checked helper by
helper — none is dead), but there IS a ~320-line separable block, the scheduled
tasks half, which is the very follow-up the entry names. Recorded honestly as
what it is: a deliberate decision not to move 320 lines out of the queue SSOT
inside a fix round, where P7 forbids the refactor and this feature's two-stack
synthesis is the standing evidence of what a careless module move costs. The
counter-example in config.py's own entry (`agent_task_pipeline.py` was made to
FIT) is the test that was applied first — fitting this module needs the split,
not a trim.

Reasoning only; the exemption, the module and the gate are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two claims left disagreeing with behaviour by the fixes above.

ARCHITECTURE said the tombstone disclosure names "each folder and branch"; it
names each folder and branch it can fit, and DECLARES the rest. Stated, with
the reproduction that made it matter.

And the gaps notice said "past where that chat's rows begin" while naming a
list of chats — a fork of a fork reaches more than one ancestor, and the
disclosure has to read correctly when it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found probing my own G5 fix. The notice names every ancestor the scan failed to
account for, and then gave them ONE reason — so a fork of a fork whose
grandparent is wholly out of the window while its parent's rows run into the
window edge was told, in a single sentence, something that can only be true of
one of them. The old code had the same shape and the same defect; narrowing the
condition made it easy to see.

Each group now carries its own reason, and a test pins the mixed case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught re-reading my own G3 commit. It said "the screenshots below are the
states a human read on a live instance" and then listed the whole manual pass,
including a thread branched into its own worktree — which this fixture cannot
show, because its project has no git folder to branch from. Evidence that
overclaims is worse than none, and this round exists because of claims that
outran what produced them.

The docstring now separates the four states these captures hold from what the
manual pass covered, and says where the other two captures live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What the vision pass G3 asked for actually found, once the captures existed and
someone looked at them. `.project-row-menu button` had no `:disabled` rule, so
a row the menu cannot offer rendered at full contrast with `cursor: pointer`
and a live hover: `Merge back` and `Remove checkout…` on a thread with no
checkout were indistinguishable from `Fork` beside them, and the reason the
rows carry lived only in a tooltip nobody has cause to open.

Every DOM assertion was green and stays green — `[disabled]` was present the
whole time, which is exactly why no test could see this. That is the argument
for the gate, made by the gate.

Same shape as `.skills-menu-item:disabled` in the same stylesheet, neutralised
hover included: a row that lights up under the pointer reads as clickable
whatever its attributes say. Pinned by a static test, and re-captured — the
three unavailable rows now read as unavailable and the other five do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e count

`inspect_thread_worktree` capped `dirty_files` at 200 and recorded no total, so
`_removal_message` — the sentence an owner reads immediately before agreeing to
destroy a checkout — said "200 uncommitted file changes" when there were 800.
The safety gate itself held (the qualitative claim stayed true and destruction
still needs explicit acknowledgement); only the magnitude lied, by a factor of
four, at the worst possible moment.

`dirty_files_total` carries the real length beside the bounded listing, and every
surface that states a number states that one: the removal refusal, the delete
refusal and confirm copies (through `omitted_files`), the client's own
`removalPrompt` — which held the identical false sentence, so a server-only fix
would have been half a fix — and `successText`, so success and refusal cannot
disagree about the same checkout. Pluralisation fixed with it: "1 uncommitted
file change", not "changes".

Contract, JS mirror and field-level parity in this commit, per the plan's rule
that a frozen-contract change is coordinated and never incidental. Additive and
optional; a legacy inspection without the field reads as "the listing is the set".

Found by the paid gate; the client half and the plural were found while fixing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant