60 KiB
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/cryptoonly) so they unit-test under vitest — mirrorsrc/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'skeyOfis the single source of that identity. SidecarStoreis the 4-method storage surface (load/save/update/consumeSelfWrite) —CoauthorStorealready conforms structurally (do not addimplementsto it; leave it untouched, INV-2).SidecarRouter implements SidecarStoreand addskeyOf+sidecarPath; the three controllers + their test-facing surfaces are typed to the concreteSidecarRouter.- Run unit tests with
npm test(vitest). Run host E2E withnpm run test:e2e(builds + compilestsconfig.e2e.json+ launches two EDH passes: one with the fixture workspace, one with no folder). - After each task:
npm testgreen,npm run typecheckclean, then commit.
File Structure
Create:
src/sidecarStore.ts— theSidecarStoreinterface (4-method storage surface) +DocKey/identity types.src/globalSidecarStore.ts—GlobalSidecarStore(vscode-free): out-of-folderfile:→<dir>/sidecars/<sha256(uri)>.json;untitled:→ in-memory map;consumeSelfWrite⇒ false;updatewith INV-16 throw + anchor prune.src/sidecarRouter.ts—SidecarRouter(vscode-free):keyOf(identity), per-key routing ofload/save/update/consumeSelfWrite/sidecarPath; plusdocIdentity(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— addisAuthorable(scheme); rewriteselectionRejectionto reject only!isAuthorable(keep no-editor / empty-selection).isUnderRootretained (router input).src/versionGuard.ts—store: CoauthorStore→store: SidecarStore.src/threadController.ts,src/attributionController.ts,src/proposalController.ts— constructorstore: CoauthorStore→store: SidecarRouter,rootDir: string→rootDir: string | undefined; replaceisInRoot/isTrackedwithisAuthorable; replacedocPathOfwithstore.keyOf(docIdentity(document));handleExternalSidecarChangeusesstore.sidecarPath.src/extension.ts— constructGlobalSidecarStore+SidecarRouter; remove the no-root early-return + command stubs (authoring commands live folder-less);renderIfOpengated byisAuthorable; widen the seam command lookups; exportsidecarRouteronCowritingApi.test/workspacePath.test.ts— extend forisAuthorable+ newselectionRejectionbehavior.test/e2e/suite-no-workspace/noWorkspace.test.ts— authoring commands are now real (not stubs);activatereturns 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
/**
* 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
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)
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)
/**
* 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
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
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
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)
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+ addisAuthorableinsrc/workspacePath.ts
Replace the SelectionContext interface and selectionRejection function (keep isUnderRoot exactly as is) with:
/** 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
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
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
/**
* 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
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:
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
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:
import { CoauthorStore } from "./store";
// ...
import { isUnderRoot } from "./workspacePath";
with:
import { SidecarRouter, docIdentity } from "./sidecarRouter";
// ...
import { isAuthorable } from "./workspacePath";
Change the constructor signature:
constructor(
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
) {
Replace isInRoot and docPathOf:
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):
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)callsthis.store.update(state.docPath, …)— leave as is;state.docPathnow holds the key (see DocState note below).ensureState(document): replaceconst docPath = this.docPathOf(document.uri);withconst docPath = this.keyOf(document);(everywheredocPathOf(...)appears in this file, usethis.keyOf(doc)when aTextDocumentis available;state.docPathalready holds the key).createThreadOnSelection:!this.isInRoot(editor.document.uri)→!isAuthorable(editor.document.uri.scheme); andthis.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 comparesthis.store.sidecarPath(state.docPath) === pand finds the doc bythis.docPathOf(d.uri) === state.docPath. Replace with: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):private isTracked(document: vscode.TextDocument): boolean { return isAuthorable(document.uri.scheme); } -
Replace
docPathOf:private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); }with:
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 aTextDocumentis in scope →this.keyOf(<doc>). Specifically:loadAll:const docPath = this.docPathOf(document.uri);→const docPath = this.keyOf(document);handleExternalSidecarChange: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: boththis.docPathOf(document.uri)occurrences →this.keyOf(document).render:this.docs.get(this.docPathOf(document.uri))→this.docs.get(this.keyOf(document)).
-
matchesDisk(document)readsdocument.uri.fsPathviafs.statSync— for an untitled buffer that path is not a real file,statSyncthrows, thecatchreturnsfalse(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):private isTracked(document: vscode.TextDocument): boolean { return isAuthorable(document.uri.scheme); } -
Replace
docPathOfwithkeyOf(same as the other two):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: boththis.docPathOf(document.uri)(theguard.isReadOnlyguard andconst docPath) →this.keyOf(document).renderAll:const docPath = this.docPathOf(document.uri);→const docPath = this.keyOf(document);handleExternalSidecarChange: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
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:
import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
Change import { isUnderRoot, selectionRejection } from "./workspacePath"; to:
import { isAuthorable, selectionRejection } from "./workspacePath";
Add sidecarRouter to the API interface:
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:
// 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:
// --- 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
editSelectionrejection call (it passedfsPath/root, now gone)
The selectionRejection({...}) call inside cowriting.editSelection passes scheme, fsPath, root. Update it to the slimmed context:
const reason = selectionRejection({
hasEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
scheme: editor?.document.uri.scheme ?? "",
});
- Step 5: Widen
renderIfOpento any authorable doc
Replace:
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:
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:
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
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:
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
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
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
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 (F2–F7 + 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
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)
# 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 (F2–F7) and add an F8 entry. Append this paragraph in the appropriate features area (match the surrounding heading style):
### 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.
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 (verifygit diff --statshows no change tosrc/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).