Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a rep
codeoid workstream ls | show <id> | match [dir] [--workstream <id>] | import [--root <dir>] # named lines of work, what a folder resolves to, a proposal from disk (docs/workstreams-design.md)
codeoid new <n> <d> --workstream <id> [--override] # a session's home workstream; default: the folder's, else workstreams.default
codeoid mcp signin <server> --credential <id> # a named OAuth slot: a second sign-in to one server (a workstream's own)
codeoid new <n> <d> --ref <workstream>[:<dir>] # another workstream it may read (its folders read-only, its memory), by that one's share policy
codeoid workstream ref add|rm <session> <workstream>[:<dir>] | move <session> <workstream> # references and moves (§5.3, §7)
```

`/mcp/control` (off by default, `mcpControl.*`) lets external agents drive
Expand Down Expand Up @@ -551,6 +553,25 @@ server's named credential is a REFERENCE on its transport, resolved by
call against the live grant. Overrides: `OverrideTokens` (G3); the folder
record is `workstream_folders`. OAuth slots: `mcp_oauth_credentials.credential`
in the key, `withSlot`/`slotOf` in mcp/oauth.ts.
W3 (under isolation): the read fence `WorkstreamService.fenceFor` (home's and
references' folders, every other workstream's as `foreign`; folders from
`workstreams/folders.ts` and recorded homes), read live by Session through
`workstreamFence(binding)`: the sandbox's project roots and `extraHidden`
(`SessionSandbox` `workstreamDirs`), the gate's `workstreams/gate.ts`
`fenceRefusal` in every mode (and Claude's PreToolUse hook), and recall
(`MemoryEngine.scopeSession`; `episodes.workstream_id`). References
(`WorkstreamBinding.references`, `readFrom`) by the read workstream's `share`
(`referenceVerdict`), moves by `moveVerdict`; `session.add_reference` /
`remove_reference` / `set_workstream` in `#sessionWorkstreamEdit`,
`Session.setWorkstream`; the env, grant and fence closures take the binding,
so a change applies at the next turn.
W4: the conductor's fleet tools name each result's workstream and take a
`workstream` filter; `FleetDeps.textWithheld` keeps a `closed` workstream's
episode text out of fleet_find / fleet_summary / fleet_recall and the dispatch
digest. `fleet_spawn.workstream` → `checkWorkstream` (`#fleetSpawnHome`) before
queueing, `dispatch_tasks.workstream` judged again at claim; send-class cards
carry `targetWorkstream` (`#fleetCardWorkstream`). `workstreams/egress.ts` is
the egress stub.

Routines (`src/daemon/routines/`, docs/routines.md) run sessions on a schedule
or an API call. `RoutineService` owns the scheduler and runs; it reaches
Expand Down
43 changes: 43 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,49 @@ A workstream with `allowOverride: false` can't be overridden at all.

A folder's workstream is remembered: if its repository later matches a different one (its `origin` was changed), the next session there must choose.

### Reading another workstream, and moving a session

With `isolateGlobalServers: true` a session is fenced off from every other workstream's folders and memory:
- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides other workstreams' folders;
- a file tool (Read, Grep, Glob, Write, Edit, on every backend, in every mode) in another workstream's folder is refused, naming that workstream;
- `recall`, `timeline` and `get_episode` leave out episodes another workstream wrote (episodes from before workstreams stay visible in their repository).

A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders where its sessions were started by their folder's own match.
The more specific rule decides: a catch-all workstream over `~/Workspace` is fenced off from `~/Workspace/acme-api` when that is another's, and inside a folder of another workstream's, only what the session may read (its own workdir, its home's folders, what a reference grants) stays readable.
The home itself is never a workstream's folder.

Limits: a repository matched only by a remote rule is fenced once a session has been started there (its folder is recorded then); one never opened is not. A session's commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced.

A **reference** lets a session read another workstream: its folders read-only (or only some), and its memory. Never its servers, credentials or accounts, and never a write.
The workstream being read decides, by its `share`:

| `share` | A reference to it is… |
| --- | --- |
| `open` | granted. |
| `ask` | granted when its owner asks with `session:approve` on an undelegated token; asked of the owner on the session otherwise. |
| `closed` | refused. A reference granted before it was closed gives nothing. |

```bash
codeoid new <name> <dir> --ref pub --ref acme:~/Workspace/acme-api # at create
codeoid workstream ref add <session> pub # later (the session header's chip too)
codeoid workstream ref rm <session> pub
codeoid workstream move <session> <workstream> # another home
```

Moving a session takes its whole conversation with it, so it follows the `share` of its old home and of every workstream it has read from (for its lifetime, even after the reference is gone; each home it leaves counts too). Moving from the default workstream, or from no home, is always allowed; past another workstream's exclusive folder it is an override (needs `session:approve`).
A session homed in another workstream's folder (chosen at create, or moved) reads that folder, so the folder's workstream's `share` decides there too: `closed` refuses it, `ask` needs `session:approve`.
Each change is saved at once and applies at the session's next turn (a turn running keeps what it started with): its backend is rebuilt with the new servers, credentials and sandbox.

A search with no folder (Grep or Glob over the workdir) is refused when another workstream's folder is inside the workdir: search a narrower folder.


### The conductor and workstreams

The conductor is homed in a reserved workstream of its own: no workstream's servers, env or folders. It reads across all of them through its fleet tools, which take a `workstream` filter and name each result's workstream.
With `isolateGlobalServers: true`, a `closed` workstream gives the conductor its sessions' status and metadata only (in `fleet_list`), never their text: `fleet_find` and `fleet_recall` never return them (not even that something matched), `fleet_summary` gives status only, `machine_map` leaves out their branches, and a worker of a closed workstream reports that it finished, not what it found. Episodes from before workstreams are judged by their session's home (as recorded; for one that no longer runs, its stored binding, else its folder's record, else its folder now). A workstream deleted from the config has no policy left: its sessions' text is no longer withheld. Closing a workstream doesn't reach back: what the conductor was told while it was open (its own transcript, a task's digest on `fleet_tasks`) stays.

The conductor acts only through sessions homed where the work belongs. `fleet_spawn` takes `workstream` (else the folder's; a folder with none, or one whose repository's remote now matches another workstream than before, must be given one) and follows the same rules as a create: past a workstream's exclusive folder, or into an `ask` workstream's folder, the spawn's approval card says so (naming the workstream it passes), and only the session owner's own approval of that card confirms it: the approval adds a one-time token to that very call, which its handler accepts only if the spawn would still be judged exactly as the card showed, at the config revision it was shown at. A closed workstream's folder never takes another's worker, and a closed workstream's worker works only in its own folders. Every send-class card (web, Telegram, the TUI, `codeoid approve`, the terminal) names the workstream the action runs in.

### Moving an existing setup

`codeoid workstream import [--root <dir>]` proposes workstreams from your repositories' remotes and lists Claude's global MCP servers to assign. It writes nothing; isolation stays off until you turn it on.
Expand Down
29 changes: 28 additions & 1 deletion docs/workstreams-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ The conductor is the one session whose job crosses workstreams, and this design
- That worker has only the target workstream's servers, credentials and memory.
- A Highflame Slack reply is drafted by a worker that has never seen a Sentisum episode.
- The approval card shows the workstream as well as recipient, repo and content.
- **`fleet_spawn`** takes `workstream` and follows §5. A conflicting spawn returns `workstream_conflict` to the conductor, and it has to choose instead of guessing.
- **`fleet_spawn`** takes `workstream` and follows §5. A conflict the owner can override is shown on the spawn's approval card, and the owner's own approval of that card confirms it (G3); one that can't be overridden, or wasn't confirmed, comes back to the conductor, which has to choose instead of guessing.
- **Intake.** When the conductor's Slack and GitHub intake arrives (Slack front end, a later phase), each inbox source is bound to one workstream: Highflame Slack to `highflame`, Sentisum Slack to `sentisum`.
A signal is born with its workstream, and its reply worker inherits it.

Expand Down Expand Up @@ -494,6 +494,33 @@ codeoid new <name> <dir> --workstream <id> [--ref <workstream>[:<path>]]...
codeoid workstream move <session> <id>
```

### 12.1 W3 as built (2026-11-11)

- **Enforcement switch.** Everything W3 enforces (the fence, recall scoping) is on only with `isolateGlobalServers`, like W1's servers. Without it, episodes are still tagged with `workstream_id` and references are recorded, but nothing is fenced.
- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops (never the home, nor a top at or above it). Every one is fenced, nested in the home's or holding it: what the session may read inside one (`WorkstreamFence.readable`: its workdir and repository, its home's folders, exactly what its references grant) is carved out of it (the gate: a path in a foreign folder and in none of those; the sandbox: every other entry on the way to them, `carveOut`, else the folder whole).
- **Sandbox.** The fence's readable folders replace `@parent` as the project roots unless the session's own setting names roots; other workstreams' folders go into the spec's hidden paths (`SpecInputs.extraHidden`), and a root inside one is dropped. A change of fence is a change of directories, applied between turns (`SessionSandbox.dirsPending`).
- **Gate.** `workstreams/gate.ts` judges every tool call's read, search and write paths (secret-paths.ts and held-writes.ts extractors), in every mode; Claude's PreToolUse hook refuses the same calls, since the CLI skips `canUseTool` for some.
- **Memory.** `episodes.workstream_id`, tagged with the home at write time. Recall stays the repository's (`workspace_id`), filtered to the home and references; untagged episodes stay visible. Cross-repository recall over a whole workstream is deferred.
- **`ask` grants.** The owner asking with `session:approve`, undelegated, is the approval. Anyone else (a delegated or send-only token) is asked of the owner on the session, and the verdict is judged again when they answer.
- **Read-from record.** `readFrom` is recorded when a reference is granted (it may be read from then).
- **Monorepos.** A session's repository top is its own only where no other workstream's folder holds it; a recorded home is widened to its repository's top only where no other workstream has a folder inside that top. A granted reference path counts only where it really is (written and real) inside the granting workstream's folders, at grant (resolved then) and at every fence.
- **Turn boundary.** `Session.setWorkstream` saves the new binding at once and applies it at the next turn's start (`#applyPendingWorkstream`); a running turn keeps its gate, servers, env and memory scope together.
- **Homed in another's folder.** `folderVerdict`: a home chosen in, or moved into, a folder another workstream holds (any claim) reads it, judged by that workstream's `share`, and it is added to `readFrom`. A move adds the old home (but the default) to `readFrom`.
- **Carve limit.** A carve past `CARVE_LIMIT` is tried at ten times it, then the folder is hidden whole; one holding the session's workspace can't be hidden whole, and is then left visible to its commands (said in `limits`), the gate still fencing its file tools.
- **Sealed.** A stored binding that can't be read back gives no home and `sealed: true`: what it read from is unknown, so it never moves.
- **Known limits:** a reference with `paths` gives the workstream's whole memory (recall isn't filtered by folder yet); an episode is tagged with the home that wrote it, so what a session read through a reference stays in its home's recall after that workstream closes; the conductor's fleet reads (`fleet_summary`, the dispatch digest) are W4's.
- **Deferred:** `Bash` commands naming another workstream's path in an unsandboxed session (§14 q4) are not yet asked about; fork *into* another workstream; skillDirs.

### 12.2 W4 as built (2026-11-11)

- **Fleet reads.** `FleetSessionView.workstream`; `fleet_list`, `fleet_find` and `fleet_recall` take a `workstream` filter and name each result's. `FleetDeps.textWithheld` (the manager: a `closed` workstream, under isolation) drops a closed workstream's episode text from `fleet_find` snippets, `fleet_summary` and `fleet_recall` (counted, not shown) and from the dispatch digest (`buildWorkerDigest`: status only), which is also what `fleet_tasks` shows.
- **No rolling summaries yet.** §10 promises a closed workstream's session cards and summaries; nothing writes session cards today (`memory/cards.ts` has no caller), so a closed workstream gives status and metadata only until cards exist (backlog).
- **Spawns.** `fleet_spawn.workstream`; `FleetDispatchDeps.checkWorkstream` before queueing (`#fleetSpawnHome`: `forCreate` with the folder required under isolation, `folderVerdict`), stored on the task (`dispatch_tasks.workstream`, `workstream_override`) and judged again at claim. The approval card (`ToolWaitingConfirmationState.targetWorkstream`, from `Session` `fleetTargetWorkstream`, the manager's `#fleetCardWorkstream`) names the workstream and warns of the override approving it confirms (G3), or of a refusal.
- **Egress.** `workstreams/egress.ts` `planEgress`: a tested stub (G6) — a message names its workstream and is sent by a worker homed there, never by the conductor; wired when P6's Slack and email egress lands.
- **The conductor's own memory.** Its recall includes its reserved workstream's episodes (it couldn't reach them under isolation before).
- **Phones.** The fleet rail docks beside the chat from `md` up; below, the chat (and its approval card) has the width. The rail's board has no phone view yet (backlog); `fleet_list` and `fleet_tasks` answer in chat.
- **Audit round 1.** Untagged episodes are judged by their session (live, else its stored folder; unknown: withheld while any workstream is closed). A closed workstream's hits are dropped from queries before anything is counted. A worker's digest is withheld from the conductor only (an orchestrator's children share its workstream); the conductor's tenant-wide board withholds others'. A confirmation is a one-time token (round 2): issued when the session owner's own approval (`isOwnApprover`) settles a card that needed one, added to that call's input by the gate (`#withFleetConfirmation`, `confirmation`), and accepted by its handler only for the tenant, workdir, workstream, conflicting workstream, `ask` folders and config revision the card was judged on (`#spendFleetToken`); the revision is stored on the task and checked at claim. A backend that drops the gate's input change gets no token: the spawn is refused. An unnamed spawn is stored without a workstream and judged again at claim by `#fleetSpawnHome` (remote-changed guard included).

## 13. Phases

| Phase | Ships | Enforced by the end |
Expand Down
Loading