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

60 KiB
Raw Blame History

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.tsGlobalSidecarStore (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.tsSidecarRouter (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.tsstore: CoauthorStorestore: SidecarStore.
  • src/threadController.ts, src/attributionController.ts, src/proposalController.ts — constructor store: CoauthorStorestore: SidecarRouter, rootDir: stringrootDir: 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.tsno 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 + add isAuthorable in src/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: SidecarRouterkeyOf + 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) 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:
    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 a TextDocument is 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: 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):

    private isTracked(document: vscode.TextDocument): boolean {
      return isAuthorable(document.uri.scheme);
    }
    
  • Replace docPathOf with keyOf (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: 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:
      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 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:

      const reason = selectionRejection({
        hasEditor: !!editor,
        selectionEmpty: editor?.selection.isEmpty ?? true,
        scheme: editor?.document.uri.scheme ?? "",
      });
  • Step 5: Widen renderIfOpen to 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 (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
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 (F2F7) 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 (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).