/** * Chat scope (crew#502, acceptance finding F-067): WHAT a chat's seats can see, decided up front * and stated — to the seats and to the caller. Project/everything scopes are refreshed before * each message as membership changes (crew#1020). * * `POST /chats` used to set the seats' cwd only when `repoRef` was sent, and the engine fell back * to `current_dir()` — the DAEMON's working directory. Studio's GroupChat never sent `repoRef`, so * every chat explored wherever `wicked-crew serve` had been started from (a customer's `$HOME`), * with no estate MCP, no repo list and no statement of the scope; "chat with all 9 repos" worked * only because the acceptance rig started the daemon from the parent of `repos/`, and the seats * browsed the rig's other clones and the INSTALLED package's `.d.ts` files as if they were sources. * * The scope is a first-class input now: a PROJECT (default: every registered `crew.repo` member) * or an explicit repo list. From it this module derives the three things the engine is handed * (the engine pool's `chat_open(chat_id, clis, cwd, scope)` before ASK-C1; a path's launch now): * * - `cwd` — a PRIVATE SCRATCH ROOT of the chat's own, under THIS daemon's per-process * namespace (`/wicked-crew-chats/-/`, see * {@link chatScratchBase}), never a repo and never the daemon's cwd. Relative * writes land there and the * OS sandbox, where the seat's config arms it, contains writes to it — the * repos are READ. Deliberately NOT under the state home: the engine's worker * fence denies `Read/Edit/Write(/**)` to every seat (wicked-core * `execute_wrapped::deny_rules`), so a seat whose cwd sat there could not read * its own working directory — and a new state-home store needs core's registry * (`tests/fixtures/state-home-subtrees.json`) released first. * - `readRoots` — the scoped repos' REGISTERED root paths (the run worktree read-roots idea, * applied to live repos): advertised to a claude seat as the SDK's * `additionalDirectories`, recorded on every seat (`GET /chats`). * - `codeGraphDb` — the graph the seats' READ-ONLY estate MCP is bound to: the project's * co-located graph (`resolveProjectGraphBinding`, DES-GROUNDING-001 — the SAME * seam governed runs get) when the chat is filed into a project; a single * repo's own graph otherwise; none for several repos with no project. * * and STATES it: an `AGENTS.md` + `CLAUDE.md` in the scratch root (every seat CLI reads its cwd's * instructions file — claude `CLAUDE.md`; codex, pi, opencode and copilot `AGENTS.md`) names the * repos, their paths, the read-only rule and the grounding, so a seat that starts in an empty * directory knows exactly what it was pointed at; the same {@link ChatScope} rides the * `POST /chats` response and `GET /chats/:id` for the UI (studio's New Chat). */ import type { ConformanceRule } from '../core/types.js'; import type { CoreAdapter } from '../core/adapter.js'; import type { ChatScope, ChatScopeRequestKind, RepoEntry } from '../core/types.js'; import { type FreshenCheckout } from './chat-freshness.js'; import { type ProjectGraphBindingDecision } from '../projects/graph.js'; import type { ChatRefusalSource } from './seat-standing.js'; /** The engine's per-seat answer to the pool's `chat_open` (`ChatSeatOutcome` on the wire). */ export interface ChatSeatOutcomeLike { cliKey: string; ok: boolean; error?: string; } /** One seat a `POST /chats` did NOT seat, and why (F-2R2-007 / F-A45-011) — published as * `ChatSeatRefusal` in wicked-crew-api-types (`source` since 0.36.0). */ export interface ChatSeatRefusal { cliKey: string; reason: string; source: ChatRefusalSource; } /** The scope a `POST /chats` asked for, after body validation. `repoRefs` is `repoRef` merged in. */ export interface ChatScopeRequest { chatId: string; projectId?: string; /** Registry ids or names; empty when the caller named none. */ repoRefs: string[]; /** The kind the caller NAMED (`scopeKind`, studio#323 R4); absent ⇒ the legacy inference. */ kind?: ChatScopeRequestKind; } /** What the engine is handed for the chat — the wire shape of the pool's `chat_open` scope plus `cwd`. */ export interface EngineChatScope { cwd: string; codeGraphDb: string | null; readRoots: string[]; } export type ChatScopeResolution = { ok: true; scope: ChatScope; engine: EngineChatScope; } | { ok: false; status: 400 | 404 | 409; error: string; missing?: string[]; }; /** The reads the resolver needs — injectable so the resolver is testable without an engine. */ export interface ChatScopeDeps { listRepos(): Promise; /** The `crew.repo` member refs of a project, in attach order. */ projectRepoRefs(projectId: string): Promise; /** The project-graph decision for a chat — repo-bound when exactly one repo is in scope. */ bindProjectGraph(projectId: string, repoRef: string | undefined): Promise; /** Where chat scratch roots live. Default {@link chatScratchBase}. */ scratchBase?: string; /** Where a registered root the daemon cannot resolve — and that is NOT in this chat's scope nor * lexically near the base — is noted instead of refusing the open (hardening, W7). */ log?: (msg: string) => void; /** * Entity count in a repo graph database — `nodes > 0` means indexed, anything else means * schema-only, never indexed, or unreadable. Omitted = {@link estateEntityCount}, which probes * `wicked-estate stats --db`; injectable so tests never shell out. A probe failure counts as 0 * (ungrounded — the gate fails CLOSED). crew#642. * * The default is applied by `ownGraph` itself, not only by {@link chatScopeDeps}: a constructor * that omits this must not silently skip the liveness gate and report a schema-only database as * grounded, which is the very defect crew#642 fixed (review of #651). */ entityCount?: (dbPath: string) => Promise; /** crew#899: bring each in-scope checkout to its upstream when safe, else report how stale it * is ({@link freshenCheckout}). Omitted = checkouts are read as they are and nothing is said * (tests; the daemon wires it in `server.ts`). */ freshen?: FreshenCheckout; } /** * `/wicked-crew-chats/-` — THIS daemon's own namespace, minted once per * process (Copilot, #518): a shared `/wicked-crew-chats/` could already exist from * another daemon running as the same OS user, which would pass every ownership check, overwrite the * live chat's statement and remove its root. Chats do not survive a daemon restart (the engine's pool * is in memory), so a stale namespace left by a crashed daemon holds nothing live. See the module * doc for why the base is not the state home. */ export declare function chatScratchBase(): string; /** The production deps over a live adapter. */ export declare function chatScopeDeps(adapter: CoreAdapter): ChatScopeDeps; /** * crew#642 — the production entity-count probe: `wicked-estate stats --db ` parsed for its * `nodes` count. `nodes > 0` means indexed; anything else means schema-only, never indexed, or * unreadable. A probe failure (missing binary, timeout, unparseable output) counts as 0, so the * liveness gate fails CLOSED — an unanswerable graph reads ungrounded, never grounded. * * Exported and used as `ownGraph`'s DEFAULT (review of #651): the contract on * `ChatScopeDeps.entityCount` promises this behaviour when the dep is omitted, so the promise is * kept where the value is USED rather than only where the deps happen to be built. */ export declare function estateEntityCount(dbPath: string): Promise; /** The route's own id rule, re-applied here: the id becomes ONE path segment under the base. */ /** The chat-id guard — the ONE sanitizer a chat id passes before it names anything on disk (the * scratch root here, the transcript file in `chat-transcripts.ts`): no separators, no spaces. */ export declare const CHAT_ID: RegExp; /** * Decide a chat's scope. Never throws on a caller error: an unknown repo is a 404 naming EVERY * missing ref (not just the first), a chat id that cannot name a scratch directory is a 400. A * graph that cannot be bound degrades to "no graph" WITH the reason — the same posture as a run * launch (`resolveProjectGraphBinding` never indexes; a refresh is an explicit act). */ export declare function resolveChatScope(req: ChatScopeRequest, deps: ChatScopeDeps): Promise; /** * The shape rules of a NAMED kind (studio#323 R4), checked before anything is read — and by the * route BEFORE its project lookup, so a shape error is the contracted 400, never a 404/409 about a * project the body should not have named (codex on #664). A named kind * resolves to exactly that kind or is refused — an input it cannot use is a 400, never silently * dropped or re-inferred into another kind. `null` = acceptable (or no kind named). */ export declare function namedKindRefusal(kind: ChatScopeRequestKind | undefined, projectId: string | undefined, refs: string[]): { ok: false; status: 400; error: string; } | null; /** * The statement every seat reads on start — written as BOTH `AGENTS.md` (codex, pi, opencode, * copilot) and `CLAUDE.md` (claude) into the scratch root. Plain prose, repo paths verbatim: the * seat is in an empty directory and this is how it learns what it was pointed at. */ /** * The ONE worked grounding command the scope statement hands every seat of a graph-bound chat * (recon-w1-grounding Q4 cause #1; R-L5-1, text-only): the shim's `call` action against the estate * MCP's `SearchEntity`, whose argument is `name` (a `{"query"}` returned 0 matches in P6). Exported * so the statement snapshot test and the byte-budget test pin the exact spelling. */ export declare const CHAT_GROUNDING_COMMAND = "wicked-garden run scripts/_estate_client.py --readonly call '{\"tool\":\"SearchEntity\",\"arguments\":{\"name\":\"\"}}'"; export declare const CHAT_DECISIONS_DIRECTIVE: string; /** * crew#855: what a seat does with a message that asks for work (build, fix, add, change). A * repo-bound chat (exactly one repository: the path's `repoRef`, deliverable) PROPOSES it as one * `PLAN+` line with a creator step and its `touch` — the engine's first-creator change, which opens * the `plan_approval` gate studio renders as the Continue in Build card (§4.7, ASK-K2b; a block * without `touch` is refused `no_scope`). Any other scope has no repository a creator step could * run in, so the seat answers with the plan and points at Continue in Build (the launch-form * prefill) instead. Either way it never attempts the change itself. */ export declare function chatWorkRequestLines(scope: Pick, opts?: ChatStatementOptions): string[]; /** How a chat's statement differs by what the chat runs (crew#1157). */ export interface ChatStatementOptions { /** The chat runs a read-only analysis workflow (`analyze`): no `PLAN+` work proposals. */ analysis?: boolean; } export declare function chatScopeStatement(chatId: string, scope: ChatScope, rules?: ReadonlyArray, opts?: ChatStatementOptions): string; /** * Create the chat's scratch root (private) and write the scope statement into it. Throws on an * unwritable temp — the route reports that rather than opening seats with no working directory. * * The root lives under the shared system temp dir, where another local user can pre-place an * entry under a predictable name (Copilot, #518) — so nothing here follows a link or trusts an * existing entry: the base and the root must be REAL directories (never symlinks), an existing * root must be OWNED by this process's user (POSIX), the mode is ENFORCED with `chmod` (mkdir's * `mode` applies only on creation), and each instruction file is unlinked before it is written * exclusively (`wx`) so a planted link cannot redirect the write. */ export declare function prepareChatScratch(chatId: string, scope: ChatScope, rules?: ReadonlyArray, opts?: ChatStatementOptions): void; /** * Reap the scratch namespaces of DEAD daemons (independent review, W6): every sibling * `-` directory under the shared parent whose pid no longer exists is removed — judged * with the same checks as {@link removeChatScratch} (a real directory, never a link, owned by this * user). This daemon's own namespace, a live pid, a pid this user may not signal (another user's * daemon), and anything not shaped like a namespace are left alone. Never throws; returns the * removed namespaces. Called once at daemon boot. */ export declare function reapStaleChatNamespaces(parent?: string): string[]; /** * Remove a chat's scratch root — fail closed (Copilot, #518): the base must be a REAL directory * (a planted `/wicked-crew-chats` link would make a lexical prefix test pass and the * recursive removal follow it), the target must sit DIRECTLY under the base by realpath, must not * itself be a link, and must be owned by this user. Anything else is left alone: a wrong guess * here deletes someone else's files, so the policy is the same as `prepareChatScratch`'s. */ export declare function removeChatScratch(cwd: string, base?: string): void; /** One chat id's lifecycle slot in the index — see {@link ChatScopeIndex}. */ type ChatSlot = /** An open is in flight: reserved synchronously before the route's first `await`. */ { state: 'reserved'; token: number; } /** A DELETE on this daemon is closing the id and has not settled: no timer frees it (the * holder does, with its ticket), so nothing the close awaits can land on a reuse. */ | { state: 'held'; ticket: number; } /** The chat is open; its scratch root exists and its seats are reading the statement there. * `refused` (F-A45-011): every requested/default seat NOT seated at open, with its reason and * source — served on `GET /chats/:id` so the studio's admission copy survives a reload. */ | { state: 'live'; scope: ChatScope; /** crew#1020: retain the open request so dynamic scopes can follow membership changes. */ request?: ChatScopeRequest; refused: ChatSeatRefusal[]; /** What the ENGINE was handed at open (F-W1-005): a per-seat retry must re-run the pool's `chat_open` with * the IDENTICAL scope — a different one makes the engine evict every warm seat (Copilot, #426). */ engine?: EngineChatScope; } /** `DELETE` ran: the root is already gone, and the id stays taken until the engine's own * `chatClosed` for it is observed (or the grace timer gives up) — so a delayed close can never * land on a chat that reused the id, and a reuse cannot race the close. */ | { state: 'closing'; timer: ReturnType; }; /** * The daemon's live chat scopes, keyed by chat id — in memory, like the engine's own chat pool (a * daemon restart loses both). A small state machine (Copilot, #518) rather than a map, because the * open is asynchronous and the engine's close is an event: * * - `reserve` claims an id SYNCHRONOUSLY (before the route's first `await`) and hands back a * token; `set` publishes the scope only if THAT reservation is still standing — an engine * `chatClosed` or a `DELETE` that cancelled it in the meantime makes `set` return `false`, and * the route tears the just-opened chat down instead of registering a scope for a closed one. * - `beginClose` (DELETE) removes the root at once and parks the id as `closing`; `closed` (the * engine's `chatClosed`) frees it. Until then a re-open of the same id is refused, so the * engine's close — which may be delivered after `DELETE` returns — can never delete a newer * chat's root, and a `chatClosed` for a merely reserved id cancels that open. * - a `closing` slot whose event never arrives (an engine without the event, a lost relay) is * freed after `closeGraceMs`; a late event after THAT is the documented residual (the engine's * `chatClosed` carries no per-open token). */ export declare class ChatScopeIndex { readonly base: string; private readonly closeGraceMs; private readonly slots; private nextToken; /** `base`: where this daemon's chat scratch roots live — the resolver is handed the SAME base. */ constructor(base?: string, closeGraceMs?: number); /** Live, reserved or closing — every state in which the id is not free. */ has(chatId: string): boolean; /** The state the id is in, for the route's refusal wording. */ stateOf(chatId: string): ChatSlot['state'] | 'free'; /** Reserve `chatId` for an open in flight; `null` when it is live, reserved or closing. */ reserve(chatId: string): number | null; /** Whether `token` still holds the reservation for `chatId` (an open in flight that was not * cancelled by a DELETE / `chatClosed` meanwhile). */ owns(chatId: string, token: number): boolean; /** Give a reservation back (the open failed before `set`) — only the holder's own. True when it * WAS the holder's: the caller may then remove what the open prepared (a scratch root shared by * id), and must not when a newer open holds the id (codex on #808 r3, 3). */ release(chatId: string, token: number): boolean; /** * Abort an open that already reached the ENGINE (the route closed the engine chat again): the * engine's `chatClosed` for this id is still on its way, so the id is parked as `closing` — not * released — until that event (or the grace) frees it; a reuse in between would otherwise have * its root removed by the late event (Copilot, #518). Only the holder's own reservation. */ abortToClosing(chatId: string, token: number): void; /** Publish the scope of a finished open. `false` when the reservation is gone (cancelled by a * close in the meantime): the caller must tear the chat down, nothing was recorded. */ set(chatId: string, scope: ChatScope, token: number, refused?: ChatSeatRefusal[], engine?: EngineChatScope, request?: ChatScopeRequest): boolean; /** The scope of a LIVE chat; `undefined` while reserved, closing or unknown. */ get(chatId: string): ChatScope | undefined; /** crew#1020: the original request, copied so a refresh cannot mutate the open's intent. */ requestOf(chatId: string): ChatScopeRequest | undefined; /** * crew#1020: replace a dynamic scope only while the same live chat still holds it; an awaited * resolution must never overwrite a chat closed and reopened under the same id. Admission and * its refusals stay as decided at open: changing a project's members does not change its kind. */ refresh(chatId: string, previous: ChatScope, scope: ChatScope, engine: EngineChatScope): boolean; /** The current engine scope (open, or crew#1020 refresh); `undefined` when not live here. */ engineOf(chatId: string): EngineChatScope | undefined; /** * Fold a per-seat re-open (F-W1-005, `POST /chats/:id/seats`): a seat that WARMED leaves the * refused list; one the engine refused replaces its entry (the engine's reason, source `engine`). * Returns the refused list as it now stands (empty for a chat that is not live here). */ foldSeats(chatId: string, outcomes: readonly ChatSeatOutcomeLike[]): ChatSeatRefusal[]; /** The seats refused at open (F-A45-011) — `undefined` for a chat this daemon did not open. */ refusedOf(chatId: string): ChatSeatRefusal[] | undefined; /** * `DELETE /chats/:id` for an ask path (ASK-C1; codex on #808 r5): remove the root now and HOLD * the id — parked with no grace timer — until the close that owns the ticket settles * ({@link closeHeld}); a reopen meanwhile is refused (`reserve` → null), a second DELETE joins * (`null` here: a close is already in progress). A reserved id is cancelled the same way (its * open's `set` fails). A free id is held too, so nothing can open it while the fold is pending. */ holdClose(chatId: string): number | null; /** Whether `ticket` still holds the close of `chatId`. */ isHeld(chatId: string, ticket: number): boolean; /** The held close settled: free the id — only the holder's own. */ closeHeld(chatId: string, ticket: number): boolean; /** * `DELETE /chats/:id`: remove the root now and park the id until the engine's `chatClosed` (or * the grace) frees it. A reserved id is cancelled instead (the in-flight open cleans up on its * failed `set`). Returns the scope that was live, if any. */ beginClose(chatId: string): ChatScope | undefined; /** * The engine's `chatClosed` for `chatId` (any reason): frees a closing id, removes and frees a * live one (an engine-side reap), cancels a reservation (the open in flight tears down). Idempotent. */ closed(chatId: string): void; /** Forget the chat and remove its scratch root at once — no closing window (tests, teardown). */ delete(chatId: string): ChatScope | undefined; } export {}; //# sourceMappingURL=chat-scope.d.ts.map