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