Files
vscode-cowriting-plugin-con…/plans/2026-06-11-f8-out-of-workspace.md
T
2026-06-11 13:19:40 -07:00

1342 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# F8 Out-of-Workspace Authoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the human "Ask Claude to Edit Selection" (threads / attribution / propose-accept) on **any** document the editor shows — saved-in-folder, saved-out-of-folder, or untitled — matching the already-universal F6 diff / F7 preview, while keeping in-workspace authoring byte-for-byte unchanged.
**Architecture:** Introduce one `SidecarStore` storage interface and a `SidecarRouter` façade that routes per-document by `#24`'s `isUnderRoot`: in-workspace `file:` docs keep the committable repo `.threads/<repo-rel>.json` sidecar (`CoauthorStore`, **unmodified**, INV-2); out-of-workspace `file:` + `untitled:` docs persist to a global-storage sidecar (`GlobalSidecarStore`, mirroring F6's `BaselineStore`, keyed by `sha256(uri)`; untitled in-memory only). The three authoring controllers depend on the router; their workspace-root gate becomes a shared `isAuthorable` predicate (scheme ∈ {file, untitled}), with `isUnderRoot` demoted from a gate to a routing input. Same `Artifact` shape + `SCHEMA_VERSION=1` either home, so `mergeArtifacts` / the F5 cross-rung contract are untouched (INV-25: global artifacts are single-rung).
**Tech Stack:** TypeScript, VS Code extension API, Node `fs`/`crypto`, vitest (unit), `@vscode/test-electron` + mocha (host E2E). Spec: `specs/coauthoring-out-of-workspace.md`.
**Conventions in this codebase (read before starting):**
- New stores are **vscode-free** (Node `fs`/`crypto` only) so they unit-test under vitest — mirror `src/baselineStore.ts` + `test/baselineStore.test.ts`.
- The document key for an in-workspace file is its **repo-relative path** (`docs/x.md`); for everything else it is the **URI string** (`file:///abs/x.md`, `untitled:Untitled-1`). The router's `keyOf` is the single source of that identity.
- `SidecarStore` is the 4-method storage surface (`load`/`save`/`update`/`consumeSelfWrite`) — `CoauthorStore` already conforms structurally (do **not** add `implements` to it; leave it untouched, INV-2). `SidecarRouter implements SidecarStore` and **adds** `keyOf` + `sidecarPath`; the three controllers + their test-facing surfaces are typed to the concrete `SidecarRouter`.
- Run unit tests with `npm test` (vitest). Run host E2E with `npm run test:e2e` (builds + compiles `tsconfig.e2e.json` + launches two EDH passes: one with the fixture workspace, one with no folder).
- After each task: `npm test` green, `npm run typecheck` clean, then commit.
---
## File Structure
**Create:**
- `src/sidecarStore.ts` — the `SidecarStore` interface (4-method storage surface) + `DocKey`/identity types.
- `src/globalSidecarStore.ts``GlobalSidecarStore` (vscode-free): out-of-folder `file:``<dir>/sidecars/<sha256(uri)>.json`; `untitled:` → in-memory map; `consumeSelfWrite` ⇒ false; `update` with INV-16 throw + anchor prune.
- `src/sidecarRouter.ts``SidecarRouter` (vscode-free): `keyOf(identity)`, per-key routing of `load`/`save`/`update`/`consumeSelfWrite`/`sidecarPath`; plus `docIdentity(doc)` extractor.
- `test/globalSidecarStore.test.ts`, `test/sidecarRouter.test.ts` — unit tests.
- `test/e2e/suite/outOfWorkspace.test.ts` — host E2E (out-of-folder file + untitled + in-workspace regression).
- `docs/MANUAL-SMOKE-F8.md` — live smoke runbook.
**Modify:**
- `src/workspacePath.ts` — add `isAuthorable(scheme)`; rewrite `selectionRejection` to reject only `!isAuthorable` (keep no-editor / empty-selection). `isUnderRoot` retained (router input).
- `src/versionGuard.ts``store: CoauthorStore``store: SidecarStore`.
- `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts` — constructor `store: CoauthorStore``store: SidecarRouter`, `rootDir: string``rootDir: string | undefined`; replace `isInRoot`/`isTracked` with `isAuthorable`; replace `docPathOf` with `store.keyOf(docIdentity(document))`; `handleExternalSidecarChange` uses `store.sidecarPath`.
- `src/extension.ts` — construct `GlobalSidecarStore` + `SidecarRouter`; remove the no-root early-return + command stubs (authoring commands live folder-less); `renderIfOpen` gated by `isAuthorable`; widen the seam command lookups; export `sidecarRouter` on `CowritingApi`.
- `test/workspacePath.test.ts` — extend for `isAuthorable` + new `selectionRejection` behavior.
- `test/e2e/suite-no-workspace/noWorkspace.test.ts` — authoring commands are now real (not stubs); `activate` returns an API even with no folder.
- `README.md` — hybrid-persistence + F5 non-shareability note.
- `src/model.ts`**no change** (Artifact + SCHEMA_VERSION untouched, INV-24).
---
## Task 1: `SidecarStore` interface
**Files:**
- Create: `src/sidecarStore.ts`
- [ ] **Step 1: Write the interface**
```typescript
/**
* SidecarStore — the storage surface the three authoring controllers depend on
* (F8 spec §6.2/§6.4). One per-document `Artifact` keyed by a string `key` (the
* document key — repo-relative path in-workspace, URI string otherwise; the
* router's keyOf is the single source). Two implementations:
* - CoauthorStore (src/store.ts) — the committable repo `.threads/` sidecar,
* keyed by repo-relative path (INV-2, byte-for-byte unchanged; conforms
* structurally — not modified for F8).
* - GlobalSidecarStore (src/globalSidecarStore.ts) — out-of-workspace `file:`
* and `untitled:` docs, in VS Code GLOBAL storage keyed by sha256(uri)
* (INV-19/24/25; untitled in-memory only).
* SidecarRouter (src/sidecarRouter.ts) implements this AND adds keyOf/sidecarPath;
* the controllers receive the router.
*/
import type { Artifact } from "./model";
export interface SidecarStore {
/** Load the artifact for `key`, or null if none persisted. */
load(key: string): Artifact | null;
/** Overwrite the artifact for `key` (newest wins, no history). */
save(key: string, artifact: Artifact): void;
/**
* Read-modify-write: load (or empty), apply `mutate`, prune anchors referenced
* by no thread/attribution/proposal, persist, return the result. MUST be
* synchronous (the F3 co-ownership contract). Throws on a newer-major sidecar
* (INV-16 backstop).
*/
update(key: string, mutate: (artifact: Artifact) => void): Artifact;
/**
* Decrement-and-report whether `fsPath` is a sidecar this store just wrote
* (repo sidecars only; the global store is outside the `.threads/` watcher so
* its impl is a no-op returning false).
*/
consumeSelfWrite(fsPath: string): boolean;
}
```
- [ ] **Step 2: Verify it typechecks**
Run: `npm run typecheck`
Expected: clean (no references yet).
- [ ] **Step 3: Commit**
```bash
git add src/sidecarStore.ts
git commit -m "feat(f8): add SidecarStore storage interface (spec §6.4)"
```
---
## Task 2: `GlobalSidecarStore` — round-trip for `file:` URIs
**Files:**
- Create: `src/globalSidecarStore.ts`
- Test: `test/globalSidecarStore.test.ts`
- [ ] **Step 1: Write the failing test (file URI round-trip + path + missing + self-write)**
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { createHash } from "node:crypto";
import { GlobalSidecarStore } from "../src/globalSidecarStore";
import { emptyArtifact, SCHEMA_VERSION, type Artifact } from "../src/model";
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "global-sidecar-")); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const FILE_URI = "file:///abs/notes/chapter-1.md";
const hash = (uri: string) => createHash("sha256").update(uri).digest("hex");
function withThread(uri: string): Artifact {
const a = emptyArtifact(uri);
a.anchors["an_1"] = { fingerprint: { text: "hi", before: "", after: "", lineHint: 0 } };
a.threads.push({ id: "t_1", anchorId: "an_1", status: "open", messages: [] });
return a;
}
describe("GlobalSidecarStore — file: URIs persist to <dir>/sidecars/<sha256(uri)>.json", () => {
it("returns null for a key with no sidecar", () => {
expect(new GlobalSidecarStore(dir).load(FILE_URI)).toBeNull();
});
it("save then load round-trips the artifact at the hashed path", () => {
const store = new GlobalSidecarStore(dir);
const a = withThread(FILE_URI);
store.save(FILE_URI, a);
const onDisk = path.join(dir, "sidecars", `${hash(FILE_URI)}.json`);
expect(fs.existsSync(onDisk)).toBe(true);
expect(store.load(FILE_URI)).toEqual(a);
});
it("sidecarPath returns the hashed disk path for a file: URI", () => {
const store = new GlobalSidecarStore(dir);
expect(store.sidecarPath(FILE_URI)).toBe(path.join(dir, "sidecars", `${hash(FILE_URI)}.json`));
});
it("overwrites in place (newest wins, no history)", () => {
const store = new GlobalSidecarStore(dir);
store.save(FILE_URI, withThread(FILE_URI));
const empty = emptyArtifact(FILE_URI);
store.save(FILE_URI, empty);
expect(store.load(FILE_URI)).toEqual(empty);
});
it("consumeSelfWrite is a no-op returning false (outside the .threads watcher)", () => {
const store = new GlobalSidecarStore(dir);
store.save(FILE_URI, withThread(FILE_URI));
expect(store.consumeSelfWrite(store.sidecarPath(FILE_URI)!)).toBe(false);
});
it("update applies the mutation, prunes unreferenced anchors, and persists", () => {
const store = new GlobalSidecarStore(dir);
const result = store.update(FILE_URI, (a) => {
a.anchors["orphan"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
a.anchors["kept"] = { fingerprint: { text: "y", before: "", after: "", lineHint: 0 } };
a.threads.push({ id: "t", anchorId: "kept", status: "open", messages: [] });
});
expect(Object.keys(result.anchors)).toEqual(["kept"]); // "orphan" pruned
expect(store.load(FILE_URI)!.threads.length).toBe(1);
});
it("update throws on a newer-major sidecar (INV-16 backstop)", () => {
const store = new GlobalSidecarStore(dir);
const future = { ...emptyArtifact(FILE_URI), schemaVersion: SCHEMA_VERSION + 1 };
store.save(FILE_URI, future as Artifact);
expect(() => store.update(FILE_URI, () => {})).toThrow(/INV-16/);
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npm test -- globalSidecarStore`
Expected: FAIL ("Cannot find module '../src/globalSidecarStore'").
- [ ] **Step 3: Implement `GlobalSidecarStore` (file: branch first; untitled stub in-memory map)**
```typescript
/**
* GlobalSidecarStore — the out-of-workspace/untitled authoring sidecar (F8 spec
* §6.2/§6.4), mirroring BaselineStore (src/baselineStore.ts): vscode-free (Node
* fs/crypto only), one `Artifact` JSON per document in VS Code's per-extension
* GLOBAL storage, NEVER the repo (INV-19/24). The key passed in is the DOCUMENT
* KEY (the URI string for the docs this store serves):
* - `file:` URI → disk at `<dir>/sidecars/<sha256(uri)>.json`.
* - `untitled:` → an in-memory Map only (no durable identity — the F6
* degrade); lost on reload, and never read by mergeArtifacts (INV-25).
* `consumeSelfWrite` is a no-op (these artifacts are outside the `**/.threads/**`
* watcher, so there is no self-write storm to suppress).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";
import {
SCHEMA_VERSION,
emptyArtifact,
isNewerMajor,
serializeArtifact,
type Artifact,
} from "./model";
import type { SidecarStore } from "./sidecarStore";
export class GlobalSidecarStore implements SidecarStore {
/** untitled keys live here only — no durable identity to persist against. */
private readonly memory = new Map<string, Artifact>();
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
constructor(private readonly storageDir: string) {}
private isUntitled(key: string): boolean {
return key.startsWith("untitled:");
}
/** Filesystem-safe storage key for a persistable (file:) doc: sha256 of its URI. */
private storageKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
/** Disk path for a `file:` key, or undefined for an in-memory untitled key. */
sidecarPath(key: string): string | undefined {
if (this.isUntitled(key)) return undefined;
return path.join(this.storageDir, "sidecars", `${this.storageKey(key)}.json`);
}
load(key: string): Artifact | null {
if (this.isUntitled(key)) return this.memory.get(key) ?? null;
const p = this.sidecarPath(key)!;
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
}
save(key: string, artifact: Artifact): void {
if (this.isUntitled(key)) {
this.memory.set(key, artifact);
return;
}
const p = this.sidecarPath(key)!;
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
}
/** See CoauthorStore.update — same INV-16 throw + anchor prune, no self-write mark. */
update(key: string, mutate: (artifact: Artifact) => void): Artifact {
const artifact = this.load(key) ?? emptyArtifact(key);
if (isNewerMajor(artifact)) {
throw new Error(
`refusing to write ${key}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`,
);
}
mutate(artifact);
const referenced = new Set<string>([
...artifact.threads.map((t) => t.anchorId),
...artifact.attributions.map((a) => a.anchorId),
...artifact.proposals.map((p) => p.anchorId),
]);
for (const id of Object.keys(artifact.anchors)) {
if (!referenced.has(id)) delete artifact.anchors[id];
}
this.save(key, artifact);
return artifact;
}
/** No-op: global artifacts are outside the `**/.threads/**` watcher. */
consumeSelfWrite(_fsPath: string): boolean {
return false;
}
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npm test -- globalSidecarStore`
Expected: PASS (all 7).
- [ ] **Step 5: Commit**
```bash
git add src/globalSidecarStore.ts test/globalSidecarStore.test.ts
git commit -m "feat(f8): GlobalSidecarStore for file: URIs (sha256 key, INV-19/24)"
```
---
## Task 3: `GlobalSidecarStore` — untitled in-memory branch
**Files:**
- Test: `test/globalSidecarStore.test.ts` (append)
- [ ] **Step 1: Append the failing test**
```typescript
describe("GlobalSidecarStore — untitled: keys are in-memory only (no disk, lost on reload)", () => {
const UNTITLED = "untitled:Untitled-1";
it("save then load round-trips within the same instance (session)", () => {
const store = new GlobalSidecarStore(dir);
const a = emptyArtifact(UNTITLED);
a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] });
a.anchors["an"] = { fingerprint: { text: "z", before: "", after: "", lineHint: 0 } };
store.save(UNTITLED, a);
expect(store.load(UNTITLED)).toEqual(a);
});
it("writes NO disk file for an untitled key", () => {
const store = new GlobalSidecarStore(dir);
store.save(UNTITLED, emptyArtifact(UNTITLED));
expect(store.sidecarPath(UNTITLED)).toBeUndefined();
expect(fs.existsSync(path.join(dir, "sidecars"))).toBe(false);
});
it("a fresh instance (simulated reload) has no untitled state", () => {
const store = new GlobalSidecarStore(dir);
store.save(UNTITLED, emptyArtifact(UNTITLED));
expect(new GlobalSidecarStore(dir).load(UNTITLED)).toBeNull();
});
});
```
- [ ] **Step 2: Run to verify it passes** (the untitled branch was implemented in Task 2)
Run: `npm test -- globalSidecarStore`
Expected: PASS (all 10). If any untitled test fails, fix the `isUntitled` branches in `globalSidecarStore.ts` until green.
- [ ] **Step 3: Commit**
```bash
git add test/globalSidecarStore.test.ts
git commit -m "test(f8): GlobalSidecarStore untitled in-memory branch"
```
---
## Task 4: `isAuthorable` + widened `selectionRejection`
**Files:**
- Modify: `src/workspacePath.ts`
- Test: `test/workspacePath.test.ts`
- [ ] **Step 1: Write the failing tests (append to `test/workspacePath.test.ts`)**
```typescript
import { isAuthorable } from "../src/workspacePath";
describe("isAuthorable", () => {
it("accepts file: and untitled: schemes", () => {
expect(isAuthorable("file")).toBe(true);
expect(isAuthorable("untitled")).toBe(true);
});
it("rejects read-only / virtual schemes", () => {
expect(isAuthorable("git")).toBe(false);
expect(isAuthorable("output")).toBe(false);
expect(isAuthorable("cowriting-baseline")).toBe(false);
expect(isAuthorable("")).toBe(false);
});
});
describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", () => {
const ok = { hasEditor: true, selectionEmpty: false, scheme: "file" };
it("accepts an out-of-workspace file (no longer rejected)", () => {
expect(selectionRejection({ ...ok, scheme: "file" })).toBeNull();
});
it("accepts an untitled buffer (no longer rejected)", () => {
expect(selectionRejection({ ...ok, scheme: "untitled" })).toBeNull();
});
it("still names the missing editor", () => {
expect(selectionRejection({ ...ok, hasEditor: false })).toMatch(/focus a text editor/i);
});
it("still names the empty selection", () => {
expect(selectionRejection({ ...ok, selectionEmpty: true })).toMatch(/select some text/i);
});
it("rejects a non-{file,untitled} scheme with its own message (not 'select some text')", () => {
const msg = selectionRejection({ ...ok, scheme: "git" });
expect(msg).toMatch(/can.?t be edited|read-only|not a file/i);
expect(msg).not.toMatch(/select some text/i);
});
});
```
Note: the existing `selectionRejection` tests that assert "save this document" for `untitled` and "outside your workspace folder" for an out-of-root file are now **obsolete** — delete those two `it(...)` blocks (the ones at `test/workspacePath.test.ts` matching `/save this document/i` and `/outside your workspace folder/i`). Keep the `isUnderRoot` describe block unchanged.
- [ ] **Step 2: Run to verify it fails**
Run: `npm test -- workspacePath`
Expected: FAIL (`isAuthorable` not exported; new selectionRejection expectations unmet).
- [ ] **Step 3: Implement — replace `selectionRejection` + add `isAuthorable` in `src/workspacePath.ts`**
Replace the `SelectionContext` interface and `selectionRejection` function (keep `isUnderRoot` exactly as is) with:
```typescript
/** Documents Cowriting can author on: a saved file OR an unsaved buffer (F8 §6.2). */
export function isAuthorable(scheme: string): boolean {
return scheme === "file" || scheme === "untitled";
}
export interface SelectionContext {
/** Is there an active text editor at all? */
hasEditor: boolean;
/** Is the active editor's selection empty (no highlight)? */
selectionEmpty: boolean;
/** The active document's URI scheme (`file`, `untitled`, …). */
scheme: string;
}
/**
* Why a selection can't be sent to Claude — or `null` if it can. F8 widened the
* membership: any `file:` or `untitled:` document is authorable (in-workspace
* files persist to the repo `.threads/` sidecar, out-of-workspace/untitled to
* global storage — the router decides). Only a non-{file,untitled} scheme (a
* read-only `git:`/`output:` view), a missing editor, or an empty selection is
* refused — each with its own message (#24's per-condition messaging).
*/
export function selectionRejection(ctx: SelectionContext): string | null {
if (!ctx.hasEditor) {
return "Cowriting: focus a text editor with a selection first.";
}
if (ctx.selectionEmpty) {
return "Cowriting: select some text to send to Claude first.";
}
if (!isAuthorable(ctx.scheme)) {
return "Cowriting: this kind of document can't be edited — Cowriting authors on a file or an untitled buffer, not a read-only view.";
}
return null;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npm test -- workspacePath`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/workspacePath.ts test/workspacePath.test.ts
git commit -m "feat(f8): isAuthorable + widened selectionRejection (file|untitled)"
```
---
## Task 5: `SidecarRouter` — `keyOf` + per-key routing
**Files:**
- Create: `src/sidecarRouter.ts`
- Test: `test/sidecarRouter.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { createHash } from "node:crypto";
import { CoauthorStore } from "../src/store";
import { GlobalSidecarStore } from "../src/globalSidecarStore";
import { SidecarRouter, docIdentity } from "../src/sidecarRouter";
import { emptyArtifact } from "../src/model";
let root: string;
let globalDir: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "router-root-"));
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "router-global-"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(globalDir, { recursive: true, force: true });
});
function makeRouter(withRoot = true): SidecarRouter {
return new SidecarRouter(
withRoot ? new CoauthorStore(root) : null,
new GlobalSidecarStore(globalDir),
withRoot ? root : undefined,
);
}
// minimal duck-typed stand-in for a vscode.TextDocument's identity inputs
const docOf = (uriString: string, fsPath: string, scheme: string) => ({
uri: { toString: () => uriString, fsPath, scheme },
});
const hash = (uri: string) => createHash("sha256").update(uri).digest("hex");
describe("SidecarRouter.keyOf — one identity per document", () => {
it("an in-root file: doc → its repo-relative path", () => {
const fsPath = path.join(root, "docs", "x.md");
const key = makeRouter().keyOf(docIdentity(docOf(`file://${fsPath}`, fsPath, "file")));
expect(key).toBe(path.join("docs", "x.md"));
});
it("an out-of-root file: doc → its URI string", () => {
const fsPath = "/elsewhere/y.md";
const uri = `file://${fsPath}`;
expect(makeRouter().keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri);
});
it("an untitled: doc → its URI string", () => {
expect(makeRouter().keyOf(docIdentity(docOf("untitled:Untitled-1", "Untitled-1", "untitled")))).toBe(
"untitled:Untitled-1",
);
});
it("root undefined (no folder) → everything is the URI string", () => {
const fsPath = "/anything/z.md";
const uri = `file://${fsPath}`;
expect(makeRouter(false).keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri);
});
});
describe("SidecarRouter routing — load/save/update dispatch by key home", () => {
it("an in-root key writes to the repo .threads/ sidecar, not global storage", () => {
const router = makeRouter();
const key = path.join("docs", "x.md");
router.save(key, emptyArtifact(key));
expect(fs.existsSync(path.join(root, ".threads", "docs", "x.md.json"))).toBe(true);
expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false);
expect(router.sidecarPath(key)).toBe(path.join(root, ".threads", "docs", "x.md.json"));
});
it("an out-of-root file: key writes to global storage, not the repo", () => {
const router = makeRouter();
const key = "file:///elsewhere/y.md";
router.save(key, emptyArtifact(key));
expect(fs.existsSync(path.join(globalDir, "sidecars", `${hash(key)}.json`))).toBe(true);
expect(fs.existsSync(path.join(root, ".threads"))).toBe(false);
expect(router.sidecarPath(key)).toBe(path.join(globalDir, "sidecars", `${hash(key)}.json`));
});
it("an untitled: key is in-memory (global store), no disk anywhere", () => {
const router = makeRouter();
const key = "untitled:Untitled-1";
router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }));
expect(router.load(key)!.threads.length).toBe(1);
expect(router.sidecarPath(key)).toBeUndefined();
expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false);
});
it("round-trips through load after update on an in-root key", () => {
const router = makeRouter();
const key = path.join("docs", "x.md");
router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }));
expect(router.load(key)!.threads.length).toBe(1);
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npm test -- sidecarRouter`
Expected: FAIL ("Cannot find module '../src/sidecarRouter'").
- [ ] **Step 3: Implement `src/sidecarRouter.ts`**
```typescript
/**
* SidecarRouter — the per-document persistence façade the three authoring
* controllers depend on (F8 spec §6.2/§6.4). Implements SidecarStore and OWNS
* the routing: it computes the single document key (keyOf) and dispatches
* load/save/update/consumeSelfWrite/sidecarPath to the right implementation:
* - an in-workspace `file:` doc → CoauthorStore, keyed by its repo-relative
* path → `.threads/<path>.json` (committable, INV-2; the only home F5's
* cross-rung ever sees).
* - an out-of-workspace `file:` or `untitled:` doc → GlobalSidecarStore, keyed
* by its URI string → global storage (INV-19/24/25; untitled in-memory).
* The membership predicate is `#24`'s isUnderRoot — here a ROUTING input, never
* an authoring gate (that became isAuthorable). vscode-free (takes the extracted
* identity, not a vscode.TextDocument) so it unit-tests.
*/
import * as path from "node:path";
import { isUnderRoot } from "./workspacePath";
import type { Artifact } from "./model";
import type { SidecarStore } from "./sidecarStore";
/** The minimal document identity the router routes on (extracted from a TextDocument). */
export interface DocIdentity {
/** `document.uri.toString()` — the URI string. */
uri: string;
/** `document.uri.fsPath`. */
fsPath: string;
/** `document.uri.scheme`. */
scheme: string;
}
/** Extract the routing identity from anything URI-shaped (a real vscode.TextDocument fits). */
export function docIdentity(doc: { uri: { toString(): string; fsPath: string; scheme: string } }): DocIdentity {
return { uri: doc.uri.toString(), fsPath: doc.uri.fsPath, scheme: doc.uri.scheme };
}
export class SidecarRouter implements SidecarStore {
constructor(
private readonly repo: SidecarStore | null,
private readonly global: SidecarStore & { sidecarPath(key: string): string | undefined },
private readonly root: string | undefined,
) {}
/**
* The single document key: an in-workspace `file:` doc → its repo-relative
* path (cross-rung-meaningful, the existing sidecar key, INV-2); everything
* else → its URI string (machine-local, self-consistent — INV-25).
*/
keyOf(id: DocIdentity): string {
if (id.scheme === "file" && this.root !== undefined && isUnderRoot(id.fsPath, this.root)) {
return path.relative(this.root, id.fsPath);
}
return id.uri;
}
/** A key is a global (URI-string) key iff it carries a URI scheme prefix. */
private isGlobalKey(key: string): boolean {
return key.startsWith("file://") || key.startsWith("untitled:");
}
private storeFor(key: string): SidecarStore {
if (this.isGlobalKey(key) || this.repo === null) return this.global;
return this.repo;
}
load(key: string): Artifact | null {
return this.storeFor(key).load(key);
}
save(key: string, artifact: Artifact): void {
this.storeFor(key).save(key, artifact);
}
update(key: string, mutate: (artifact: Artifact) => void): Artifact {
return this.storeFor(key).update(key, mutate);
}
/** Only the repo store self-writes (the `.threads/` watcher); global is a no-op. */
consumeSelfWrite(fsPath: string): boolean {
return this.repo?.consumeSelfWrite(fsPath) ?? false;
}
/**
* The sidecar's on-disk path: `.threads/<key>.json` for a repo key, the hashed
* global path for an out-of-folder `file:` key, undefined for untitled. Used by
* the controllers' external-change handler (only repo sidecars are watched) and
* by the E2E to assert the storage home.
*/
sidecarPath(key: string): string | undefined {
if (this.isGlobalKey(key)) return this.global.sidecarPath(key);
if (this.repo === null) return undefined;
// CoauthorStore exposes sidecarPath(docPath): string (structural).
return (this.repo as unknown as { sidecarPath(k: string): string }).sidecarPath(key);
}
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npm test -- sidecarRouter`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/sidecarRouter.ts test/sidecarRouter.test.ts
git commit -m "feat(f8): SidecarRouter — keyOf + per-document routing (spec §6.2)"
```
---
## Task 6: `VersionGuard` onto `SidecarStore`
**Files:**
- Modify: `src/versionGuard.ts`
- [ ] **Step 1: Re-point the store type**
In `src/versionGuard.ts`, change the import and field type from `CoauthorStore` to `SidecarStore`:
```typescript
import type { SidecarStore } from "./sidecarStore";
// ...
export class VersionGuard {
private readonly warned = new Set<string>();
constructor(private readonly store: SidecarStore) {}
// isReadOnly / wasWarned unchanged — they only call store.load(key).
}
```
(Replace `import type { CoauthorStore } from "./store";` and `constructor(private readonly store: CoauthorStore)`. The `docPath` params are now document keys — no logic change.)
- [ ] **Step 2: Verify typecheck**
Run: `npm run typecheck`
Expected: clean (controllers still pass `CoauthorStore`, which conforms to `SidecarStore` structurally — Task 7 switches them to the router).
- [ ] **Step 3: Commit**
```bash
git add src/versionGuard.ts
git commit -m "refactor(f8): VersionGuard depends on SidecarStore not CoauthorStore"
```
---
## Task 7: Re-point the three controllers onto the router
**Files:**
- Modify: `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts`
This task changes three controllers the same way. Each currently: imports `CoauthorStore`, takes `store: CoauthorStore` + `rootDir: string`, gates on `isInRoot`/`isTracked` (`scheme==="file" && isUnderRoot(fsPath, rootDir)`), and computes its per-doc key via `docPathOf(uri) = vscode.workspace.asRelativePath(uri, false)`. After F8 each: takes `store: SidecarRouter` + `rootDir: string | undefined`, gates on `isAuthorable(document.uri.scheme)`, and computes its key via `this.store.keyOf(docIdentity(document))`.
- [ ] **Step 1: ThreadController — imports + constructor + gate + key**
In `src/threadController.ts`:
Replace the imports:
```typescript
import { CoauthorStore } from "./store";
// ...
import { isUnderRoot } from "./workspacePath";
```
with:
```typescript
import { SidecarRouter, docIdentity } from "./sidecarRouter";
// ...
import { isAuthorable } from "./workspacePath";
```
Change the constructor signature:
```typescript
constructor(
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
) {
```
Replace `isInRoot` and `docPathOf`:
```typescript
private isInRoot(uri: vscode.Uri): boolean {
return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir);
}
private docPathOf(uri: vscode.Uri): string {
return vscode.workspace.asRelativePath(uri, false);
}
```
with a single key helper (the controller keys on the document key now):
```typescript
private keyOf(document: vscode.TextDocument): string {
return this.store.keyOf(docIdentity(document));
}
```
Now update every caller in this file:
- `commentingRangeProvider`: `if (!this.isInRoot(document.uri)) return [];``if (!isAuthorable(document.uri.scheme)) return [];`
- `currentAuthor()`: `const email = gitUserEmail(this.rootDir);``const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;`
- `persist(state)` calls `this.store.update(state.docPath, …)` — leave as is; `state.docPath` now holds the key (see DocState note below).
- `ensureState(document)`: replace `const docPath = this.docPathOf(document.uri);` with `const docPath = this.keyOf(document);` (everywhere `docPathOf(...)` appears in this file, use `this.keyOf(doc)` when a `TextDocument` is available; `state.docPath` already holds the key).
- `createThreadOnSelection`: `!this.isInRoot(editor.document.uri)``!isAuthorable(editor.document.uri.scheme)`; and `this.guard.isReadOnly(this.docPathOf(editor.document.uri))``this.guard.isReadOnly(this.keyOf(editor.document))`.
- `renderAll(document)`: `const docPath = this.docPathOf(document.uri);``const docPath = this.keyOf(document);`
- `handleExternalSidecarChange`: the body compares `this.store.sidecarPath(state.docPath) === p` and finds the doc by `this.docPathOf(d.uri) === state.docPath`. Replace with:
```typescript
handleExternalSidecarChange(uri: vscode.Uri): void {
const p = uri.fsPath;
for (const state of this.docs.values()) {
if (this.store.sidecarPath(state.docPath) === p) {
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
if (doc) this.renderAll(doc);
}
}
}
```
- `onDidChange`: `this.docs.get(this.docPathOf(e.document.uri))` → `this.docs.get(this.keyOf(e.document))`.
- `onDidSave`: `this.docs.get(this.docPathOf(document.uri))` → `this.docs.get(this.keyOf(document))`.
Leave `DocState.docPath`'s field name as `docPath` (it now holds the document key — renaming it ripples needlessly; the comment on the field already says "keyed by docPath"). The `getRendered(docPath)` test surface keeps its name and still accepts the key (the repo-relative path for in-workspace docs, exactly as today).
- [ ] **Step 2: AttributionController — same transform**
In `src/attributionController.ts`:
- Imports: `import { CoauthorStore } from "./store";` → `import { SidecarRouter, docIdentity } from "./sidecarRouter";`; `import { isUnderRoot } from "./workspacePath";` → `import { isAuthorable } from "./workspacePath";`
- Constructor: `private readonly store: CoauthorStore,` → `private readonly store: SidecarRouter,`; `private readonly rootDir: string,` → `private readonly rootDir: string | undefined,`
- `isTracked(document)`:
```typescript
private isTracked(document: vscode.TextDocument): boolean {
return isAuthorable(document.uri.scheme);
}
```
- Replace `docPathOf`:
```typescript
private docPathOf(uri: vscode.Uri): string {
return vscode.workspace.asRelativePath(uri, false);
}
```
with:
```typescript
private keyOf(document: vscode.TextDocument): string {
return this.store.keyOf(docIdentity(document));
}
```
- `currentAuthor()`: `const email = gitUserEmail(this.rootDir);` → `const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;`
- Every `this.docPathOf(<doc>.uri)` where a `TextDocument` is in scope → `this.keyOf(<doc>)`. Specifically:
- `loadAll`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);`
- `handleExternalSidecarChange`:
```typescript
handleExternalSidecarChange(uri: vscode.Uri): void {
for (const s of this.docs.values()) {
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === s.docPath);
if (doc) this.loadAll(doc);
}
}
}
```
- `onDidChange`: `const docPath = this.docPathOf(e.document.uri);` → `const docPath = this.keyOf(e.document);`
- `applyAgentEdit`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);`
- `onDidSave`: both `this.docPathOf(document.uri)` occurrences → `this.keyOf(document)`.
- `render`: `this.docs.get(this.docPathOf(document.uri))` → `this.docs.get(this.keyOf(document))`.
- `matchesDisk(document)` reads `document.uri.fsPath` via `fs.statSync` — for an untitled buffer that path is not a real file, `statSync` throws, the `catch` returns `false` (treated as a real edit). Leave unchanged.
- [ ] **Step 3: ProposalController — same transform**
In `src/proposalController.ts`:
- Imports: `import { CoauthorStore } from "./store";` → `import { SidecarRouter, docIdentity } from "./sidecarRouter";`; `import { isUnderRoot } from "./workspacePath";` → `import { isAuthorable } from "./workspacePath";`
- Constructor: `private readonly store: CoauthorStore,` → `private readonly store: SidecarRouter,`; `private readonly rootDir: string,` → `private readonly rootDir: string | undefined,`
- `isTracked(document)`:
```typescript
private isTracked(document: vscode.TextDocument): boolean {
return isAuthorable(document.uri.scheme);
}
```
- Replace `docPathOf` with `keyOf` (same as the other two):
```typescript
private keyOf(document: vscode.TextDocument): string {
return this.store.keyOf(docIdentity(document));
}
```
- Update callers:
- `ensureState`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);`
- `propose`: both `this.docPathOf(document.uri)` (the `guard.isReadOnly` guard and `const docPath`) → `this.keyOf(document)`.
- `renderAll`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);`
- `handleExternalSidecarChange`:
```typescript
handleExternalSidecarChange(uri: vscode.Uri): void {
for (const state of this.docs.values()) {
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
if (doc) this.renderAll(doc);
}
}
}
```
- `onDidChange`: `this.docs.get(this.docPathOf(e.document.uri))` → `this.docs.get(this.keyOf(e.document))`.
- `openDoc(state)`: `vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath)` → `vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath)`.
- [ ] **Step 4: Verify typecheck (extension.ts still passes `CoauthorStore` — expected to fail here)**
Run: `npm run typecheck`
Expected: errors ONLY in `src/extension.ts` (it still constructs controllers with a `CoauthorStore` + non-optional root). Those are fixed in Task 8. The three controller files themselves must typecheck clean — if any controller file reports an error, fix it before proceeding.
- [ ] **Step 5: Commit**
```bash
git add src/threadController.ts src/attributionController.ts src/proposalController.ts
git commit -m "refactor(f8): controllers depend on SidecarRouter + isAuthorable (key via keyOf)"
```
---
## Task 8: Wire the router in `extension.ts`; authoring works folder-less
**Files:**
- Modify: `src/extension.ts`
- [ ] **Step 1: Imports + API surface**
Add imports near the top:
```typescript
import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
```
Change `import { isUnderRoot, selectionRejection } from "./workspacePath";` to:
```typescript
import { isAuthorable, selectionRejection } from "./workspacePath";
```
Add `sidecarRouter` to the API interface:
```typescript
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
}
```
- [ ] **Step 2: Construct the global store next to the baseline store**
Right after the `baselineStore` construction (the F6 block), add:
```typescript
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
// workspace-independently; the router falls back to it for any non-in-folder doc.
const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? "");
```
(When `baselineStorageDir` is undefined — global storage genuinely unavailable — file writes will throw and authoring degrades to errors-on-write, the F6-equivalent edge; untitled in-memory still works. This matches PUC-5.)
- [ ] **Step 3: Replace the no-root early-return block with folder-less wiring**
Replace the entire block from `const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;` through the `return undefined;` and its closing brace (the `if (!root) { … stubs … return undefined; }`) — i.e. lines that early-return on no folder — with:
```typescript
// --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) ---
// The router routes per-document: in-workspace file: → the committable repo
// `.threads/` sidecar (CoauthorStore); out-of-folder file: + untitled: → the
// global-storage sidecar. Constructed even with NO folder open (everything
// then routes global) — the F6 #19 precedent, now extended to authoring.
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const coauthorStore = root ? new CoauthorStore(root) : null;
const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root);
// F5 (INV-16): one shared guard — newer-major sidecars are read-only.
const versionGuard = new VersionGuard(sidecarRouter);
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// --- F6 machine-landing wiring — now for ANY authorable doc ---
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
context.subscriptions.push(
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
);
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
// centrally in the repo store (only repo `.threads/` sidecars are watched —
// global artifacts live outside the workspace). Harmless when no folder is open.
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
const onSidecar = (uri: vscode.Uri) => {
if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return;
threadController.handleExternalSidecarChange(uri);
attributionController.handleExternalSidecarChange(uri);
proposalController.handleExternalSidecarChange(uri);
};
watcher.onDidChange(onSidecar);
watcher.onDidCreate(onSidecar);
context.subscriptions.push(watcher);
```
(Note: the F6/F7 controllers are constructed ABOVE this block, unchanged. Delete the old `const store = new CoauthorStore(root);`/`new VersionGuard(store)`/`new ThreadController(store, root, …)` etc. lines that this block replaces — they are now superseded.)
- [ ] **Step 4: Fix the `editSelection` rejection call (it passed `fsPath`/`root`, now gone)**
The `selectionRejection({...})` call inside `cowriting.editSelection` passes `scheme`, `fsPath`, `root`. Update it to the slimmed context:
```typescript
const reason = selectionRejection({
hasEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
scheme: editor?.document.uri.scheme ?? "",
});
```
- [ ] **Step 5: Widen `renderIfOpen` to any authorable doc**
Replace:
```typescript
const renderIfOpen = (doc: vscode.TextDocument) => {
if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) {
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
}
};
```
with:
```typescript
const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) {
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
}
};
```
- [ ] **Step 6: Return the full API (including the router)**
At the end of `activate`, the `return { … }` now always runs (no early `return undefined`). Add `sidecarRouter`:
```typescript
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
sidecarRouter,
};
```
- [ ] **Step 7: Verify typecheck + unit tests**
Run: `npm run typecheck && npm test`
Expected: typecheck clean; all unit tests PASS (130 prior + the new global/router/workspacePath tests).
- [ ] **Step 8: Commit**
```bash
git add src/extension.ts
git commit -m "feat(f8): wire SidecarRouter; authoring commands live folder-less (#19 precedent)"
```
---
## Task 9: Update the no-workspace E2E suite
**Files:**
- Modify: `test/e2e/suite-no-workspace/noWorkspace.test.ts`
After F8, with no folder open, `activate()` returns a real API and the authoring commands are real (they route to global storage), not warning stubs.
- [ ] **Step 1: Rewrite the suite's expectations**
Replace the body of the three authoring-related tests so they assert the new behavior. Keep the "EDH really has no workspace folder" test and the F6 test unchanged. Replace the two stub tests with:
```typescript
test("activate returns a real API even with no workspace folder (F8)", async () => {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = await ext.activate();
assert.ok(api?.proposalController, "no-folder activation returns the authoring API (F8)");
assert.ok(api?.sidecarRouter, "router exposed");
});
test("all contributed coauthoring commands are registered (real, not stubs)", async () => {
const all = await vscode.commands.getCommands(true);
for (const command of [
"cowriting.createThread",
"cowriting.reply",
"cowriting.resolveThread",
"cowriting.reopenThread",
"cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
assert.ok(all.includes(command), `${command} is registered`);
}
});
test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = await ext.activate();
const untitled = await vscode.workspace.openTextDocument({ content: "Edit this scratch sentence please.\n", language: "markdown" });
await vscode.window.showTextDocument(untitled);
await new Promise((r) => setTimeout(r, 300));
const key = untitled.uri.toString();
const target = "Edit this scratch sentence please.";
const start = untitled.getText().indexOf(target);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key, start, end: start + target.length, newText: "REPLACED scratch sentence.", model: "sonnet", sessionId: "e2e-nf", turnId: "turn-nf",
});
assert.ok(id, "propose returns an id for an untitled buffer with no folder");
assert.ok(await api.proposalController.acceptById(key, id!), "accept applies");
await new Promise((r) => setTimeout(r, 300));
assert.ok(untitled.getText().includes("REPLACED scratch sentence."), "replacement landed in the untitled buffer");
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
});
```
Also remove the now-obsolete top-of-file comment claim that the commands are "warning stubs" (replace with a one-line note that F8 made them real).
- [ ] **Step 2: Verify (built by the E2E run in Task 11; typecheck via the e2e tsconfig)**
Run: `npm run pretest:e2e`
Expected: builds + `tsc -p tsconfig.e2e.json` compiles clean.
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite-no-workspace/noWorkspace.test.ts
git commit -m "test(f8): no-folder suite — authoring commands are real, route to global"
```
---
## Task 10: Host E2E — out-of-workspace file + untitled + in-workspace regression
**Files:**
- Create: `test/e2e/suite/outOfWorkspace.test.ts`
- [ ] **Step 1: Write the E2E suite**
```typescript
import * as assert from "assert";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 300));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.proposalController && api?.sidecarRouter, "extension exports the authoring API + router");
return api;
}
async function proposeViaCommand(uri: string, text: string, target: string, newText: string, turnId: string): Promise<string> {
const start = text.indexOf(target);
assert.ok(start >= 0, `text contains "${target}"`);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-oow", turnId,
});
assert.ok(id, "propose returns the proposal id");
return id!;
}
// F8 host E2E (no LLM): authoring on a file OUTSIDE the workspace folder and on
// an untitled buffer, plus an in-workspace byte-for-byte regression. Runs in the
// WITH-workspace EDH pass (a folder is open; the out-of-folder file lives in a
// temp dir outside it — exactly like diffView's out-of-folder test).
suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", () => {
test("out-of-folder file: propose→accept lands Claude-attributed; persisted in GLOBAL storage, no .threads (PUC-1, INV-9/24/25)", async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-oow-"));
const outsidePath = path.join(outsideDir, "sibling.md");
const TARGET = "The sibling-repo sentence Claude edits.";
fs.writeFileSync(outsidePath, `# Sibling\n\n${TARGET}\n`, "utf8");
const uri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
const key = uri.toString();
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
await settle();
// routed to global storage, never the repo
const sidecar = api.sidecarRouter.sidecarPath(key)!;
assert.ok(sidecar && fs.existsSync(sidecar), `global sidecar exists at ${sidecar}`);
assert.ok(sidecar.includes(`${path.sep}sidecars${path.sep}`), "lives under <globalStorage>/sidecars/");
assert.ok(!sidecar.includes(`${path.sep}.threads${path.sep}`), "NOT in a .threads/ tree (INV-25)");
assert.ok(!fs.existsSync(path.join(outsideDir, ".threads")), "no .threads/ written beside the out-of-folder file");
assert.ok(await api.proposalController.acceptById(key, id), "accept applies via the seam");
await settle();
assert.ok(doc.getText().includes("The sibling sentence CLAUDE REWROTE."), "replacement landed");
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow1");
assert.ok(agent && agent.authorKind === "agent", "accepted text is Claude-attributed");
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "proposal gone (INV-13)");
// a thread can be opened on the out-of-folder doc
await vscode.window.showTextDocument(doc);
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(0), doc.positionAt(8));
const threadId = await api.threadController.createThreadOnSelection("thread on a sibling file");
assert.ok(threadId, "a thread opens on the out-of-folder doc");
// reload-restore from the GLOBAL sidecar (re-anchor content-based)
fs.writeFileSync(outsidePath, "PREPENDED\n\n" + doc.getText(), "utf8");
const reloaded = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(reloaded);
await vscode.commands.executeCommand("workbench.action.files.revert");
await settle();
api.threadController.renderAll(reloaded);
assert.strictEqual(api.threadController.getRendered(key).length, 1, "thread restored from the global sidecar after reload");
fs.rmSync(outsideDir, { recursive: true, force: true });
});
test("untitled buffer: propose→accept works in-session, state in-memory only (PUC-2)", async () => {
const TARGET = "The untitled draft sentence.";
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
await vscode.window.showTextDocument(untitled);
await settle();
const api = await getApi();
const key = untitled.uri.toString();
assert.strictEqual(untitled.uri.scheme, "untitled", "really untitled");
const id = await proposeViaCommand(key, untitled.getText(), TARGET, "The untitled draft, CLAUDE-EDITED.", "turn-oow2");
await settle();
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
assert.ok(await api.proposalController.acceptById(key, id), "accept applies");
await settle();
assert.ok(untitled.getText().includes("The untitled draft, CLAUDE-EDITED."), "replacement landed in the untitled buffer");
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow2");
assert.ok(agent && agent.authorKind === "agent", "untitled accepted text is Claude-attributed");
});
test("in-workspace regression: artifact still lands at <root>/.threads/<repo-rel>.json with the repo-relative document.path (INV-2)", async () => {
const DOC_REL = "docs/f8regression.md";
const TARGET = "An in-folder sentence for the F8 regression.";
const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F8 regression\n\n${TARGET}\n`, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
// the in-workspace key is the repo-relative path, not the URI string
assert.strictEqual(api.sidecarRouter.keyOf({ uri: uri.toString(), fsPath: abs, scheme: "file" }), DOC_REL, "in-folder key is the repo-relative path");
const id = await proposeViaCommand(uri.toString(), doc.getText(), TARGET, "An in-folder sentence, EDITED.", "turn-oow3");
await settle();
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept by the repo-relative key");
await settle();
const sidecar = api.sidecarRouter.sidecarPath(DOC_REL)!;
assert.strictEqual(sidecar, path.join(WS, ".threads", "docs", "f8regression.md.json"), "committable .threads/ path unchanged (INV-2)");
assert.ok(fs.existsSync(sidecar), "sidecar written in the repo");
const onDisk = JSON.parse(fs.readFileSync(sidecar, "utf8"));
assert.strictEqual(onDisk.document.path, DOC_REL, "document.path is the repo-relative path (byte-for-byte)");
});
});
```
- [ ] **Step 2: Commit**
```bash
git add test/e2e/suite/outOfWorkspace.test.ts
git commit -m "test(f8): host E2E — out-of-folder file + untitled + in-workspace regression"
```
---
## Task 11: Run the full host E2E suite
**Files:** none (verification).
- [ ] **Step 1: Run the E2E**
Run: `npm run test:e2e`
Expected: BOTH EDH passes green — the with-workspace suite (F2F7 + the new F8 out-of-workspace suite) and the no-workspace suite (updated for real folder-less authoring). If a test fails, debug per `superpowers:systematic-debugging` (do not weaken assertions to pass).
- [ ] **Step 2: Commit any fixes**
```bash
git add -A
git commit -m "fix(f8): make host E2E green"
```
(Skip if no fixes were needed.)
---
## Task 12: Docs — manual smoke + README hybrid-model note
**Files:**
- Create: `docs/MANUAL-SMOKE-F8.md`
- Modify: `README.md`
- [ ] **Step 1: Write `docs/MANUAL-SMOKE-F8.md`** (mirror the F6/F7 smoke docs' structure)
```markdown
# Manual smoke — F8 out-of-workspace authoring
Confirms "Ask Claude to Edit Selection" (+ threads/attribution) work on a file
**outside** the workspace folder and on an **untitled** buffer, while in-workspace
authoring is unchanged. One live turn hits the SDK (the only LLM step).
## Prereqs
- Build: `npm run build`.
- Launch the Extension Development Host (F5 in VS Code) with this repo's `sandbox/`
opened as the workspace folder.
## A. Out-of-folder file (PUC-1)
1. Open a markdown file from a DIFFERENT directory (outside `sandbox/`) — e.g. a
sibling repo, or `File > Open` a scratch file under `/tmp`.
2. Select a sentence → Command Palette → **Cowriting: Ask Claude to Edit Selection**
→ type an instruction → wait for the proposal (amber range + ✓/✗).
3. Accept (✓). Expect: the text is replaced and shows the Claude attribution tint.
4. Add a coauthoring thread on a selection (**Add Coauthoring Thread on Selection**).
5. Close and reopen the file (or revert). Expect: the thread + attribution are
restored. There is **no** `.threads/` folder beside the file — its state lives
in VS Code global storage (`<globalStorage>/sidecars/<hash>.json`).
## B. Untitled buffer (PUC-2)
1. `File > New File` (don't save) → type a few sentences.
2. Select → **Ask Claude to Edit Selection** → instruct → accept. Expect: it works
exactly like A, in-session.
3. Reload the window (Developer: Reload Window). Expect: the untitled buffer's
coauthoring state is **gone** (in-memory only — documented limitation, §6.7).
## C. In-workspace unchanged (PUC-3)
1. Open a file UNDER `sandbox/`. Repeat the propose→accept→thread loop.
2. Expect: a committable `sandbox/.threads/<path>.json` sidecar is written, exactly
as before F8 (byte-for-byte, INV-2).
## D. Read-only view declines (PUC-5)
1. Open a Git diff / Output view, select text, run **Ask Claude to Edit Selection**.
2. Expect: a warning that this kind of document can't be edited (not "select some
text").
```
- [ ] **Step 2: Add a README note**
Find the section of `README.md` that lists the coauthoring features (F2F7) and add an F8 entry. Append this paragraph in the appropriate features area (match the surrounding heading style):
```markdown
### F8 — Out-of-workspace authoring
"Ask Claude to Edit Selection" (and threads / attribution / propose-accept) work on
**any** document the editor shows — saved in the workspace folder, saved outside it,
or untitled — matching the universal F6 diff / F7 preview. Persistence is **hybrid**:
an in-workspace file keeps its committable `.threads/<repo-rel>.json` sidecar
(byte-for-byte unchanged, INV-2); an out-of-workspace file or untitled buffer stores
its coauthoring artifact in VS Code **global storage** keyed by `sha256(uri)` (untitled
is in-memory only). A global-storage artifact is **not a committed file**, so it is
**never cross-rung-shareable** (INV-25) and renaming the file orphans its artifact —
both stated as design contract (spec `specs/coauthoring-out-of-workspace.md`).
```
- [ ] **Step 3: Verify build still clean + commit**
Run: `npm run typecheck && npm test`
Expected: clean + green.
```bash
git add docs/MANUAL-SMOKE-F8.md README.md
git commit -m "docs(f8): manual smoke runbook + README hybrid-model/non-shareability note"
```
---
## Self-Review checklist (run after execution, before PR)
- **Spec coverage:** §6.2 (SidecarStore+router+isAuthorable) → Tasks 1/4/5; §6.3 (GlobalSidecarStore, document key table) → Tasks 2/3/5; §6.4 (interfaces, selectionRejection, controllers, activation) → Tasks 4/6/7/8/9; §6.8 (unit + host E2E) → Tasks 2/3/4/5/10/11; §6.7 untitled in-memory + non-migration → Tasks 2/3 + docs; INV-24 (hybrid routing) → Tasks 5/7/8 + E2E Task 10; INV-25 (single-rung) → E2E Task 10 (no `.threads/`, no merge); §9 docs → Task 12.
- **`mergeArtifacts` / model.ts:** untouched (verify `git diff --stat` shows no change to `src/model.ts`, `src/mergeArtifacts.ts`, `src/store.ts`).
- **Acceptance (spec §7.3):** out-of-folder + untitled authoring succeed; in-workspace byte-for-byte (Task 10 regression); global artifacts by URI hash (Tasks 2/10); re-anchor + reload restore (Task 10); unit + host E2E green (Task 11).
```