Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-10-f2-region-anchored-threads.md
T
Ben Stull 9c9d4928bd F2: just-in-time implementation plan for region-anchored threads (#4)
Plan derived from vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md
§7.2 (SLICE-1..5). UI surface ⇒ @vscode/test-electron host E2E is a first-class
plan task, not a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:47:12 -07:00

61 KiB

F2 — Region-Anchored Threads 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: Deliver F2 (benstull/vscode-cowriting-plugin#4) — durable, region-anchored discussion threads in VS Code, persisted as git-native .threads/ sidecars, rendered through the native Comments API, with hybrid anchoring that survives edits/reload/external change (orphaning rather than guessing) — on the POC skeleton (#2), per spec vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md.

Architecture: Three vscode-free units (unit-tested with vitest, mirroring the POC's cline.ts) plus one thin editor-facing controller and a vscode-free thread-mutation helper:

  • src/model.ts — the versioned artifact schema (shared anchors + provenance primitives, INV-4) and stable serialization.
  • src/store.tsCoauthorStore: load/save .threads/ sidecars via Node fs, stable formatting (INV-2).
  • src/anchorer.tsbuildFingerprint, the resolution ladder (exact-unique → context-disambiguated → lineHint tiebreak → orphaned, INV-1/INV-3), and live-offset shift.
  • src/threadModel.ts — vscode-free Artifact mutations (addThread, appendMessage, setStatus).
  • src/threadController.ts — wires Store + Anchorer + threadModel to vscode.comments (create-on-selection, reply, resolve, render, reload, FileSystemWatcher re-anchor, orphaned surface).
  • src/extension.tsactivate wires the controller (keeps the POC's showClineSdkInfo) and returns a small test-facing API.

The extension stays a CJS esbuild bundle (vscode + @cline/sdk external). No live @cline/sdk turn and no credentials in F2 (INV-5). The Comments surface is native (not a webview) ⇒ E2E uses @vscode/test-electron (not Playwright), and E2E host tests are first-class plan tasks (SLICE-5).

Tech Stack: TypeScript, VS Code Extension API (Comments API), esbuild (extension bundle), vitest (unit), @vscode/test-electron + mocha (host E2E), @cline/sdk@0.0.46 (already linked, untouched by F2).


File Structure

File Responsibility
src/model.ts Artifact schema types (Artifact, Anchor, Fingerprint, Thread, Message, Provenance), SCHEMA_VERSION, emptyArtifact, serializeArtifact (stable key ordering), newId. vscode-free.
src/store.ts CoauthorStore: sidecarPath/load/save over Node fs; one sidecar per document under .threads/. vscode-free.
src/anchorer.ts OffsetRange, buildFingerprint, resolve (ladder + orphan), shift (live offsets). vscode-free.
src/threadModel.ts vscode-free Artifact mutations: addThread, appendMessage, setStatus.
src/threadController.ts vscode layer: CommentController, create/reply/resolve commands, renderAll, live change tracking, FileSystemWatcher re-anchor, orphaned rendering.
src/extension.ts activate/deactivate; wires ThreadController; keeps showClineSdkInfo; returns { threadController } test API.
test/model.test.ts vitest: serialize round-trip + stable formatting.
test/store.test.ts vitest: save→load round-trip in a temp dir; byte-stable re-save.
test/anchorer.test.ts vitest: fingerprint build; resolution ladder (exact/context/lineHint/orphan); shift cases.
test/threadModel.test.ts vitest: addThread / appendMessage / setStatus.
tsconfig.e2e.json Emit test/e2e + src to out/ as CommonJS for the host test run.
test/e2e/runTest.ts @vscode/test-electron launcher (downloads VS Code, opens a temp workspace).
test/e2e/suite/index.ts mocha runner that globs compiled *.test.js.
test/e2e/suite/threads.test.ts host E2E: create→reply→resolve→persist→reload→re-anchor→orphan.
test/e2e/fixtures/workspace/docs/sample.md E2E fixture document.
package.json Add F2 commands/menus contributions, onStartupFinished activation, E2E scripts + dev deps.
vitest.config.ts Exclude test/e2e/** (those require vscode).
.gitignore Ignore E2E-generated .threads/ under the fixture.
README.md Document the F2 feature + how to run unit and host E2E tests.

Task 1 (SLICE-1): Artifact schema + CoauthorStore

Files:

  • Create: src/model.ts

  • Test: test/model.test.ts

  • Create: src/store.ts

  • Test: test/store.test.ts

  • Modify: vitest.config.ts (exclude E2E dir up front so later tasks don't break npm test)

  • Step 1: Exclude the (future) E2E dir from vitest

vitest.config.ts (replace whole file):

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["test/**/*.test.ts"],
    exclude: ["test/e2e/**", "node_modules/**"],
  },
});
  • Step 2: Write the failing model test

test/model.test.ts:

import { describe, it, expect } from "vitest";
import {
  SCHEMA_VERSION,
  emptyArtifact,
  serializeArtifact,
  type Artifact,
} from "../src/model";

function sampleArtifact(): Artifact {
  return {
    schemaVersion: SCHEMA_VERSION,
    document: { path: "docs/spec.md" },
    anchors: {
      a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } },
      a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } },
    },
    threads: [
      {
        id: "t1",
        anchorId: "a1",
        status: "open",
        messages: [
          { id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
        ],
      },
    ],
    attributions: [],
    proposals: [],
  };
}

describe("serializeArtifact", () => {
  it("round-trips through JSON.parse", () => {
    const a = sampleArtifact();
    const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
    expect(parsed).toEqual(a);
  });

  it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => {
    const a = sampleArtifact();
    const out = serializeArtifact(a);
    expect(out.endsWith("\n")).toBe(true);
    // a1 must serialize before a2 even though a2 was inserted first
    expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"'));
    // byte-identical on re-serialize
    expect(serializeArtifact(JSON.parse(out))).toBe(out);
  });

  it("emptyArtifact has the shared F3/F4 extension points present and empty", () => {
    const e = emptyArtifact("docs/x.md");
    expect(e.schemaVersion).toBe(SCHEMA_VERSION);
    expect(e.document.path).toBe("docs/x.md");
    expect(e.anchors).toEqual({});
    expect(e.threads).toEqual([]);
    expect(e.attributions).toEqual([]);
    expect(e.proposals).toEqual([]);
  });
});
  • Step 3: Run the test to verify it fails

Run: npm test -- test/model.test.ts Expected: FAIL — cannot resolve ../src/model.

  • Step 4: Write src/model.ts
/**
 * Versioned coauthoring artifact schema (spec §6.3) — the shared git-native
 * envelope under every coauthoring capability. `anchors` and the `Provenance`
 * author field are SHARED primitives (INV-4): F3 (attributions) and F4
 * (proposals) add arrays that reference the same shapes without a format break.
 *
 * vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts).
 */
import { randomUUID } from "node:crypto";

export const SCHEMA_VERSION = 1 as const;

/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */
export interface Fingerprint {
  text: string;
  /** <= ~3 lines / 120 chars leading context. */
  before: string;
  /** <= ~3 lines / 120 chars trailing context. */
  after: string;
  /** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */
  lineHint: number;
}

export interface Anchor {
  fingerprint: Fingerprint;
}

/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
export type Provenance =
  | { kind: "human"; id: string }
  | { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };

export interface Message {
  id: string;
  author: Provenance;
  body: string;
  /** ISO-8601. */
  createdAt: string;
}

export type ThreadStatus = "open" | "resolved";

export interface Thread {
  id: string;
  anchorId: string;
  status: ThreadStatus;
  messages: Message[];
}

export interface Artifact {
  schemaVersion: number;
  /** repo-relative path; the sidecar key. */
  document: { path: string };
  /** SHARED primitive (INV-4). */
  anchors: Record<string, Anchor>;
  threads: Thread[];
  /** F3 extension point — reuses anchors[]; not implemented in F2. */
  attributions: unknown[];
  /** F4 extension point — reuses anchors[] + provenance; not in F2. */
  proposals: unknown[];
}

export function emptyArtifact(docPath: string): Artifact {
  return {
    schemaVersion: SCHEMA_VERSION,
    document: { path: docPath },
    anchors: {},
    threads: [],
    attributions: [],
    proposals: [],
  };
}

/** Stable, generated id (spec §6.3). */
export function newId(prefix: string): string {
  return `${prefix}_${randomUUID()}`;
}

function serializeProvenance(p: Provenance): Record<string, unknown> {
  return p.kind === "agent"
    ? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } }
    : { kind: p.kind, id: p.id };
}

/**
 * Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so
 * sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always
 * ends with a trailing newline.
 */
export function serializeArtifact(a: Artifact): string {
  const canonical = {
    schemaVersion: a.schemaVersion,
    document: { path: a.document.path },
    anchors: Object.fromEntries(
      Object.keys(a.anchors)
        .sort()
        .map((k) => [
          k,
          {
            fingerprint: {
              text: a.anchors[k].fingerprint.text,
              before: a.anchors[k].fingerprint.before,
              after: a.anchors[k].fingerprint.after,
              lineHint: a.anchors[k].fingerprint.lineHint,
            },
          },
        ]),
    ),
    threads: a.threads.map((t) => ({
      id: t.id,
      anchorId: t.anchorId,
      status: t.status,
      messages: t.messages.map((m) => ({
        id: m.id,
        author: serializeProvenance(m.author),
        body: m.body,
        createdAt: m.createdAt,
      })),
    })),
    attributions: a.attributions,
    proposals: a.proposals,
  };
  return JSON.stringify(canonical, null, 2) + "\n";
}
  • Step 5: Run the model test to verify it passes

Run: npm test -- test/model.test.ts Expected: PASS — 3 tests green.

  • Step 6: Write the failing store test

test/store.test.ts:

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 { CoauthorStore } from "../src/store";
import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model";

let root: string;

beforeEach(() => {
  root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-"));
});
afterEach(() => {
  fs.rmSync(root, { recursive: true, force: true });
});

function withThread(): Artifact {
  const a = emptyArtifact("docs/spec.md");
  a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } };
  a.threads.push({
    id: "t1",
    anchorId: "a1",
    status: "open",
    messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }],
  });
  return a;
}

describe("CoauthorStore", () => {
  it("returns null for a document with no sidecar", () => {
    const store = new CoauthorStore(root);
    expect(store.load("docs/spec.md")).toBeNull();
  });

  it("computes the sidecar path as .threads/<docPath>.json", () => {
    const store = new CoauthorStore(root);
    expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json"));
  });

  it("save then load round-trips the artifact", () => {
    const store = new CoauthorStore(root);
    const a = withThread();
    store.save("docs/spec.md", a);
    expect(store.load("docs/spec.md")).toEqual(a);
  });

  it("writes byte-stable, pretty JSON (re-save is identical)", () => {
    const store = new CoauthorStore(root);
    const a = withThread();
    store.save("docs/spec.md", a);
    const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
    store.save("docs/spec.md", store.load("docs/spec.md")!);
    const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
    expect(second).toBe(first);
    expect(first).toBe(serializeArtifact(a));
  });
});
  • Step 7: Run the store test to verify it fails

Run: npm test -- test/store.test.ts Expected: FAIL — cannot resolve ../src/store.

  • Step 8: Write src/store.ts
/**
 * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
 * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only),
 * so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on
 * external change is wired in the vscode layer (ThreadController, SLICE-4).
 */
import * as fs from "node:fs";
import * as path from "node:path";
import { serializeArtifact, type Artifact } from "./model";

export class CoauthorStore {
  /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
  constructor(private readonly rootDir: string) {}

  /** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
  sidecarPath(docPath: string): string {
    return path.join(this.rootDir, ".threads", `${docPath}.json`);
  }

  load(docPath: string): Artifact | null {
    const p = this.sidecarPath(docPath);
    if (!fs.existsSync(p)) return null;
    return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
  }

  save(docPath: string, artifact: Artifact): void {
    const p = this.sidecarPath(docPath);
    fs.mkdirSync(path.dirname(p), { recursive: true });
    fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
  }
}
  • Step 9: Run the full unit suite to verify it passes

Run: npm test Expected: PASS — model + store tests green (plus the existing cline test).

  • Step 10: Typecheck

Run: npm run typecheck Expected: PASS — no type errors.

  • Step 11: Commit
git add src/model.ts test/model.test.ts src/store.ts test/store.test.ts vitest.config.ts
git commit -m "F2 SLICE-1: artifact schema + CoauthorStore with round-trip tests (#4)"

Task 2 (SLICE-2): Anchorer — fingerprint, resolution ladder, live shift

Files:

  • Create: src/anchorer.ts

  • Test: test/anchorer.test.ts

  • Step 1: Write the failing anchorer test

test/anchorer.test.ts:

import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";

const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";

describe("buildFingerprint", () => {
  it("captures the exact text, bounded context, and 0-based lineHint", () => {
    const start = DOC.indexOf("needle here");
    const range: OffsetRange = { start, end: start + "needle".length };
    const fp = buildFingerprint(DOC, range);
    expect(fp.text).toBe("needle");
    expect(fp.lineHint).toBe(1);
    expect(fp.before.endsWith("line1 ")).toBe(true);
    expect(fp.after.startsWith(" here")).toBe(true);
    expect(fp.before.length).toBeLessThanOrEqual(120);
    expect(fp.after.length).toBeLessThanOrEqual(120);
  });
});

describe("resolve", () => {
  it("exact-unique: returns the single occurrence's range", () => {
    const fp = { text: "line2", before: "", after: "", lineHint: 0 };
    const start = DOC.indexOf("line2");
    expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 });
  });

  it("context-disambiguated: picks the occurrence whose before/after match", () => {
    const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 };
    const start = DOC.indexOf("needle here");
    expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
  });

  it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => {
    // both occurrences of 'needle', no usable context, hint points at line 3
    const fp = { text: "needle", before: "", after: "", lineHint: 3 };
    const start = DOC.indexOf("needle again");
    expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
  });

  it("orphaned: returns 'orphaned' when the text is gone", () => {
    const fp = { text: "absent-text", before: "", after: "", lineHint: 0 };
    expect(resolve(DOC, fp)).toBe("orphaned");
  });

  it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => {
    const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint
    const fp = { text: "needle", before: "", after: "", lineHint: 1 };
    expect(resolve(doc, fp)).toBe("orphaned");
  });
});

describe("shift", () => {
  it("edit entirely before the range shifts both endpoints by the delta", () => {
    const r: OffsetRange = { start: 10, end: 15 };
    // insert 2 chars at offset 0 (replace [0,0) with len 2)
    expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 });
  });

  it("edit entirely after the range leaves it unchanged", () => {
    const r: OffsetRange = { start: 2, end: 5 };
    expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 });
  });

  it("edit overlapping the range clamps the touched endpoints to the edit start", () => {
    const r: OffsetRange = { start: 5, end: 10 };
    // replace [3,7) with 1 char (delta = -3)
    expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- test/anchorer.test.ts Expected: FAIL — cannot resolve ../src/anchorer.

  • Step 3: Write src/anchorer.ts
/**
 * Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source
 * of truth for an anchor's location (INV-3); live OFFSET ranges are a within-
 * session optimization. The resolution ladder is:
 *   exact-unique → context-disambiguated → lineHint tiebreak → orphaned.
 * It NEVER guesses on an unbreakable tie — it orphans instead (INV-1).
 *
 * vscode-free: operates on plain strings and character-offset ranges, so it is
 * unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range.
 */
import type { Fingerprint } from "./model";

export interface OffsetRange {
  start: number;
  end: number;
}

/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */
export interface TextEdit {
  start: number;
  end: number;
  newLength: number;
}

const MAX_CTX_CHARS = 120;
const MAX_CTX_LINES = 3;

function lineNumberAt(doc: string, offset: number): number {
  let line = 0;
  const bound = Math.min(offset, doc.length);
  for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++;
  return line;
}

function leadingContext(doc: string, start: number): string {
  let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start);
  const lines = s.split("\n");
  if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n");
  return s;
}

function trailingContext(doc: string, end: number): string {
  let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS));
  const lines = s.split("\n");
  if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n");
  return s;
}

export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint {
  return {
    text: docText.slice(range.start, range.end),
    before: leadingContext(docText, range.start),
    after: trailingContext(docText, range.end),
    lineHint: lineNumberAt(docText, range.start),
  };
}

function allIndexesOf(hay: string, needle: string): number[] {
  if (needle.length === 0) return [];
  const out: number[] = [];
  let from = 0;
  let idx = hay.indexOf(needle, from);
  while (idx !== -1) {
    out.push(idx);
    from = idx + 1;
    idx = hay.indexOf(needle, from);
  }
  return out;
}

function contextMatches(doc: string, i: number, fp: Fingerprint): boolean {
  const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before;
  const afterStart = i + fp.text.length;
  const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after;
  return beforeOk && afterOk;
}

const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length });

/**
 * Re-resolve a fingerprint against current text. Returns the resolved range or
 * "orphaned" when no confident, unique match exists (INV-1).
 */
export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" {
  const occ = allIndexesOf(docText, fp.text);
  if (occ.length === 0) return "orphaned";
  if (occ.length === 1) return rangeAt(occ[0], fp.text);

  // Multiple exact matches: try context disambiguation.
  const ctx = occ.filter((i) => contextMatches(docText, i, fp));
  if (ctx.length === 1) return rangeAt(ctx[0], fp.text);

  // Still ambiguous: break ties by proximity to lineHint within the best pool.
  const pool = ctx.length > 0 ? ctx : occ;
  const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) }));
  const min = Math.min(...dists.map((x) => x.d));
  const closest = dists.filter((x) => x.d === min);
  if (closest.length === 1) return rangeAt(closest[0].i, fp.text);

  // Unbreakable tie — refuse to guess.
  return "orphaned";
}

/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
  const delta = edit.newLength - (edit.end - edit.start);
  const point = (p: number): number => {
    if (p <= edit.start) return p;
    if (p >= edit.end) return p + delta;
    return edit.start; // inside the replaced span — clamp to its start
  };
  return { start: point(range.start), end: point(range.end) };
}
  • Step 4: Run the test to verify it passes

Run: npm test -- test/anchorer.test.ts Expected: PASS — fingerprint, all four resolution-ladder cases, and the three shift cases green.

  • Step 5: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 6: Commit
git add src/anchorer.ts test/anchorer.test.ts
git commit -m "F2 SLICE-2: Anchorer (fingerprint, resolution ladder, live shift) (#4)"

Task 3 (SLICE-3): ThreadController wiring to the Comments API

This task has two halves: (a) a vscode-free threadModel.ts carrying the Artifact mutations (unit-tested), and (b) the threadController.ts vscode wiring + the package.json contributions (typecheck/build-verified; behavior is exercised by SLICE-5 E2E, since the native Comments UI cannot be driven from vitest).

Files:

  • Create: src/threadModel.ts
  • Test: test/threadModel.test.ts
  • Create: src/threadController.ts
  • Modify: src/extension.ts
  • Modify: package.json (contributions + activation)

3a — vscode-free thread mutations (TDD)

  • Step 1: Write the failing threadModel test

test/threadModel.test.ts:

import { describe, it, expect } from "vitest";
import { emptyArtifact, type Provenance } from "../src/model";
import { addThread, appendMessage, setStatus } from "../src/threadModel";

const BEN: Provenance = { kind: "human", id: "ben" };
const fp = { text: "anchored", before: "a", after: "b", lineHint: 3 };

describe("addThread", () => {
  it("adds an anchor + an open thread with the first message and returns their ids", () => {
    const a = emptyArtifact("docs/x.md");
    const { threadId, anchorId } = addThread(a, fp, { author: BEN, body: "first" });
    expect(Object.keys(a.anchors)).toContain(anchorId);
    expect(a.anchors[anchorId].fingerprint).toEqual(fp);
    const t = a.threads.find((x) => x.id === threadId)!;
    expect(t.anchorId).toBe(anchorId);
    expect(t.status).toBe("open");
    expect(t.messages).toHaveLength(1);
    expect(t.messages[0].body).toBe("first");
    expect(t.messages[0].author).toEqual(BEN);
    expect(t.messages[0].id).toMatch(/^m_/);
    expect(t.messages[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });
});

describe("appendMessage", () => {
  it("appends a message to the named thread", () => {
    const a = emptyArtifact("docs/x.md");
    const { threadId } = addThread(a, fp, { author: BEN, body: "first" });
    appendMessage(a, threadId, { author: BEN, body: "reply" });
    const t = a.threads.find((x) => x.id === threadId)!;
    expect(t.messages.map((m) => m.body)).toEqual(["first", "reply"]);
  });

  it("throws for an unknown thread id", () => {
    const a = emptyArtifact("docs/x.md");
    expect(() => appendMessage(a, "nope", { author: BEN, body: "x" })).toThrow(/nope/);
  });
});

describe("setStatus", () => {
  it("flips a thread between open and resolved", () => {
    const a = emptyArtifact("docs/x.md");
    const { threadId } = addThread(a, fp, { author: BEN, body: "first" });
    setStatus(a, threadId, "resolved");
    expect(a.threads[0].status).toBe("resolved");
    setStatus(a, threadId, "open");
    expect(a.threads[0].status).toBe("open");
  });
});
  • Step 2: Run the test to verify it fails

Run: npm test -- test/threadModel.test.ts Expected: FAIL — cannot resolve ../src/threadModel.

  • Step 3: Write src/threadModel.ts
/**
 * vscode-free mutations on the Artifact (spec §6.5). Keeping thread logic here
 * (not in the vscode controller) keeps it unit-testable; ThreadController is a
 * thin adapter that calls these and persists via CoauthorStore.
 */
import {
  newId,
  type Artifact,
  type Fingerprint,
  type Provenance,
  type ThreadStatus,
} from "./model";

export interface NewMessage {
  author: Provenance;
  body: string;
}

function message(m: NewMessage) {
  return { id: newId("m"), author: m.author, body: m.body, createdAt: new Date().toISOString() };
}

/** Lazily-created anchor + thread for a new region discussion (PUC-1). */
export function addThread(
  artifact: Artifact,
  fingerprint: Fingerprint,
  first: NewMessage,
): { threadId: string; anchorId: string } {
  const anchorId = newId("a");
  const threadId = newId("t");
  artifact.anchors[anchorId] = { fingerprint };
  artifact.threads.push({ id: threadId, anchorId, status: "open", messages: [message(first)] });
  return { threadId, anchorId };
}

/** Append a reply (PUC-2). Throws if the thread is unknown. */
export function appendMessage(artifact: Artifact, threadId: string, m: NewMessage): void {
  const t = artifact.threads.find((x) => x.id === threadId);
  if (!t) throw new Error(`unknown thread id: ${threadId}`);
  t.messages.push(message(m));
}

/** Resolve/reopen a thread (PUC-2). Throws if the thread is unknown. */
export function setStatus(artifact: Artifact, threadId: string, status: ThreadStatus): void {
  const t = artifact.threads.find((x) => x.id === threadId);
  if (!t) throw new Error(`unknown thread id: ${threadId}`);
  t.status = status;
}
  • Step 4: Run the test to verify it passes

Run: npm test -- test/threadModel.test.ts Expected: PASS — addThread / appendMessage / setStatus green.

3b — vscode ThreadController + contributions

  • Step 5: Add F2 contributions + activation to package.json

Replace the activationEvents and contributes blocks (keep everything else, including cowriting.showClineSdkInfo):

  "activationEvents": ["onStartupFinished"],
  "contributes": {
    "commands": [
      {
        "command": "cowriting.showClineSdkInfo",
        "title": "Cowriting: Show Cline SDK Info",
        "category": "Cowriting"
      },
      {
        "command": "cowriting.createThread",
        "title": "Cowriting: Add Coauthoring Thread on Selection",
        "category": "Cowriting"
      },
      { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
      { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
      { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }
    ],
    "menus": {
      "comments/commentThread/context": [
        { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
      ],
      "comments/commentThread/title": [
        {
          "command": "cowriting.resolveThread",
          "group": "inline",
          "when": "commentController == cowriting.threads && commentThread =~ /^open$/"
        },
        {
          "command": "cowriting.reopenThread",
          "group": "inline",
          "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
        }
      ]
    }
  },

Note: we set each vscode.CommentThread.contextValue to its status string ("open"/"resolved"), so the commentThread =~ /…/ clauses show Resolve only on open threads and Reopen only on resolved ones.

  • Step 6: Write src/threadController.ts
/**
 * ThreadController — the thin editor-facing layer (spec §6.4). Wires
 * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
 * selection, reply, resolve, render, live-range tracking, reload, and
 * FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER
 * silently moved — an unresolvable anchor renders as an orphaned thread (INV-1).
 */
import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { emptyArtifact, type Artifact, type Provenance } from "./model";
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
import { addThread, appendMessage, setStatus } from "./threadModel";

/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
  id: string;
  status: "open" | "resolved";
  orphaned: boolean;
  range: { startLine: number; endLine: number };
}

interface DocState {
  docPath: string;
  uri: vscode.Uri;
  artifact: Artifact;
  /** thread id -> vscode thread. */
  vsThreads: Map<string, vscode.CommentThread>;
  /** thread id -> live offset range (within-session optimization, INV-3). */
  live: Map<string, OffsetRange>;
  /** thread id -> orphaned flag for the current render. */
  orphaned: Map<string, boolean>;
}

export class ThreadController implements vscode.Disposable {
  private readonly controller: vscode.CommentController;
  private readonly disposables: vscode.Disposable[] = [];
  private readonly docs = new Map<string, DocState>(); // keyed by docPath
  /** sidecar paths we just wrote, to ignore our own watcher events. */
  private readonly selfWrites = new Set<string>();

  constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
    this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
    this.controller.commentingRangeProvider = {
      provideCommentingRanges: (document) => {
        if (!this.isInRoot(document.uri)) return [];
        return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
      },
    };
    this.disposables.push(this.controller);

    this.disposables.push(
      vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()),
      vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)),
      vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) =>
        this.setThreadStatus(t, "resolved"),
      ),
      vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
        this.setThreadStatus(t, "open"),
      ),
      vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
      vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
    );

    // Re-anchor on external change to any sidecar (a git pull, a manual edit).
    const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
    const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri);
    watcher.onDidChange(onSidecar);
    watcher.onDidCreate(onSidecar);
    this.disposables.push(watcher);
  }

  private isInRoot(uri: vscode.Uri): boolean {
    return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir);
  }

  private docPathOf(uri: vscode.Uri): string {
    return vscode.workspace.asRelativePath(uri, false);
  }

  private currentAuthor(): Provenance {
    const id = (vscode.workspace.getConfiguration("git").get<string>("user.name")) || process.env.USER || "human";
    return { kind: "human", id };
  }

  private persist(state: DocState): void {
    this.selfWrites.add(this.store.sidecarPath(state.docPath));
    this.store.save(state.docPath, state.artifact);
  }

  private ensureState(document: vscode.TextDocument): DocState {
    const docPath = this.docPathOf(document.uri);
    let state = this.docs.get(docPath);
    if (!state) {
      state = {
        docPath,
        uri: document.uri,
        artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
        vsThreads: new Map(),
        live: new Map(),
        orphaned: new Map(),
      };
      this.docs.set(docPath, state);
    }
    return state;
  }

  // ---- PUC-1: create on selection -------------------------------------------------

  async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
    const editor = vscode.window.activeTextEditor;
    if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
    const document = editor.document;
    const state = this.ensureState(document);
    const offsets: OffsetRange = {
      start: document.offsetAt(editor.selection.start),
      end: document.offsetAt(editor.selection.end),
    };
    const fp = buildFingerprint(document.getText(), offsets);
    const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
    this.persist(state);
    this.renderThread(document, state, threadId, offsets, false);
    return threadId;
  }

  // ---- PUC-2: reply / resolve -----------------------------------------------------

  reply(r: vscode.CommentReply): void {
    const threadId = this.threadIdOf(r.thread);
    const state = this.stateOfThread(r.thread);
    if (!threadId || !state) return;
    appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
    this.persist(state);
    this.refreshComments(r.thread, state, threadId);
  }

  private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
    const threadId = this.threadIdOf(vsThread);
    const state = this.stateOfThread(vsThread);
    if (!threadId || !state) return;
    setStatus(state.artifact, threadId, status);
    this.persist(state);
    vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
    vsThread.contextValue = status;
  }

  // ---- PUC-3/4: render, reload, re-anchor -----------------------------------------

  /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
  renderAll(document: vscode.TextDocument): void {
    const docPath = this.docPathOf(document.uri);
    const state = this.ensureState(document);
    // fresh artifact from disk (reload / external change)
    state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
    for (const vsThread of state.vsThreads.values()) vsThread.dispose();
    state.vsThreads.clear();
    state.live.clear();
    state.orphaned.clear();
    const text = document.getText();
    for (const thread of state.artifact.threads) {
      const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
      const resolved = fp ? resolve(text, fp) : "orphaned";
      if (resolved === "orphaned") {
        const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
        this.renderThread(document, state, thread.id, { start: document.offsetAt(new vscode.Position(line, 0)), end: document.offsetAt(new vscode.Position(line, 0)) }, true);
      } else {
        this.renderThread(document, state, thread.id, resolved, false);
      }
    }
  }

  private onExternalSidecarChange(uri: vscode.Uri): void {
    const p = uri.fsPath;
    if (this.selfWrites.has(p)) {
      this.selfWrites.delete(p);
      return; // our own write
    }
    for (const state of this.docs.values()) {
      if (this.store.sidecarPath(state.docPath) === p) {
        const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
        if (doc) this.renderAll(doc);
      }
    }
  }

  private onDidChange(e: vscode.TextDocumentChangeEvent): void {
    const state = this.docs.get(this.docPathOf(e.document.uri));
    if (!state || state.live.size === 0) return;
    for (const change of e.contentChanges) {
      const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
      for (const [id, range] of state.live) {
        const next = shift(range, edit);
        state.live.set(id, next);
        const vsThread = state.vsThreads.get(id);
        if (vsThread && !state.orphaned.get(id)) {
          vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end));
        }
      }
    }
  }

  private onDidSave(document: vscode.TextDocument): void {
    const state = this.docs.get(this.docPathOf(document.uri));
    if (!state || state.live.size === 0) return;
    const text = document.getText();
    let changed = false;
    for (const thread of state.artifact.threads) {
      if (state.orphaned.get(thread.id)) continue;
      const range = state.live.get(thread.id);
      if (!range) continue;
      state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) };
      changed = true;
    }
    if (changed) this.persist(state);
  }

  // ---- rendering helpers ----------------------------------------------------------

  private renderThread(
    document: vscode.TextDocument,
    state: DocState,
    threadId: string,
    offsets: OffsetRange,
    orphaned: boolean,
  ): void {
    const thread = state.artifact.threads.find((t) => t.id === threadId)!;
    const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
    const vsThread = this.controller.createCommentThread(document.uri, range, thread.messages.map((m) => this.toComment(m.body, m.author.id)));
    vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
    vsThread.contextValue = orphaned ? "orphaned" : thread.status;
    vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
    vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
    state.vsThreads.set(threadId, vsThread);
    state.live.set(threadId, offsets);
    state.orphaned.set(threadId, orphaned);
  }

  private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
    const thread = state.artifact.threads.find((t) => t.id === threadId)!;
    vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id));
  }

  private toComment(body: string, authorName: string): vscode.Comment {
    return {
      body: new vscode.MarkdownString(body),
      mode: vscode.CommentMode.Preview,
      author: { name: authorName },
    };
  }

  private threadIdOf(vsThread: vscode.CommentThread): string | undefined {
    for (const state of this.docs.values()) {
      for (const [id, t] of state.vsThreads) if (t === vsThread) return id;
    }
    return undefined;
  }

  private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined {
    for (const state of this.docs.values()) {
      for (const t of state.vsThreads.values()) if (t === vsThread) return state;
    }
    return undefined;
  }

  // ---- test-facing surface --------------------------------------------------------

  getRendered(docPath: string): RenderedThread[] {
    const state = this.docs.get(docPath);
    if (!state) return [];
    const out: RenderedThread[] = [];
    for (const [id, vsThread] of state.vsThreads) {
      const t = state.artifact.threads.find((x) => x.id === id)!;
      out.push({
        id,
        status: t.status,
        orphaned: !!state.orphaned.get(id),
        range: { startLine: vsThread.range.start.line, endLine: vsThread.range.end.line },
      });
    }
    return out;
  }

  dispose(): void {
    for (const d of this.disposables) d.dispose();
  }
}
  • Step 7: Wire the controller into src/extension.ts

Replace src/extension.ts with (keeps showClineSdkInfo, adds the controller, returns a test API):

import * as vscode from "vscode";
import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";

const CHANNEL_NAME = "Cowriting (Cline SDK)";

export interface CowritingApi {
  threadController: ThreadController;
}

export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
  // --- POC command (Feature #2), unchanged ---
  const output = vscode.window.createOutputChannel(CHANNEL_NAME);
  context.subscriptions.push(output);
  context.subscriptions.push(
    vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
      try {
        const summary = await fetchSdkSummary();
        output.clear();
        output.appendLine(`@cline/sdk version: ${summary.version}`);
        output.appendLine(`Builtin tools (${summary.tools.length}):`);
        for (const tool of summary.tools) output.appendLine(`  • ${tool.id}${tool.description}`);
        output.show(true);
        await vscode.window.showInformationMessage(
          `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`,
        );
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        output.appendLine(`Failed to drive @cline/sdk: ${message}`);
        output.show(true);
        await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`);
      }
    }),
  );

  // --- F2: region-anchored threads (Feature #4) ---
  const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
  if (!root) return undefined; // no workspace → nothing to anchor against
  const store = new CoauthorStore(root);
  const threadController = new ThreadController(store, root);
  context.subscriptions.push(threadController);

  // Render threads for already-open editors, and on future opens.
  const renderIfOpen = (doc: vscode.TextDocument) => {
    if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc);
  };
  vscode.workspace.textDocuments.forEach(renderIfOpen);
  context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));

  return { threadController };
}

export function deactivate(): void {
  // Disposables registered on the context handle cleanup.
}
  • Step 8: Typecheck and build

Run: npm run typecheck && npm run build Expected: PASS — no type errors; out/extension.cjs rebuilt.

  • Step 9: Run the full unit suite

Run: npm test Expected: PASS — model, store, anchorer, threadModel, and the existing cline test all green (E2E excluded).

  • Step 10: Commit
git add src/threadModel.ts test/threadModel.test.ts src/threadController.ts src/extension.ts package.json
git commit -m "F2 SLICE-3: ThreadController on the Comments API + thread mutations (#4)"

Task 4 (SLICE-4): Reload + external-change re-anchoring + orphaned surface

The mechanics (FileSystemWatcher, renderAll reload, onDidChange/onDidSave live tracking, orphaned rendering) were implemented in Task 3's controller. This task hardens and verifies them with focused vscode-free unit tests of the re-anchor decision logic and a manual checklist; full behavioral proof lands in the SLICE-5 E2E.

Files:

  • Test: test/reanchor.test.ts (vscode-free coverage of the reload/edit/orphan decisions)

  • Step 1: Write test/reanchor.test.ts

This locks the spec's resolution-ladder behavior under the three SLICE-4 scenarios (reload unchanged, edit-then-reanchor, edit-to-orphan) at the pure level the controller relies on:

import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve } from "../src/anchorer";

const ORIGINAL = "intro\nThe quick brown fox\noutro\n";

describe("re-anchor scenarios (SLICE-4 decision logic)", () => {
  it("reload, document unchanged: resolves to the same range", () => {
    const start = ORIGINAL.indexOf("quick brown");
    const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
    expect(resolve(ORIGINAL, fp)).toEqual({ start, end: start + "quick brown".length });
  });

  it("external edit that moves the anchored text down: re-resolves to the new range", () => {
    const start = ORIGINAL.indexOf("quick brown");
    const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
    const edited = "a new first line\nanother line\n" + ORIGINAL;
    const newStart = edited.indexOf("quick brown");
    expect(newStart).not.toBe(start);
    expect(resolve(edited, fp)).toEqual({ start: newStart, end: newStart + "quick brown".length });
  });

  it("external edit that deletes the anchored text: orphaned, never moved (INV-1)", () => {
    const start = ORIGINAL.indexOf("quick brown");
    const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
    const edited = "intro\nThe lazy dog\noutro\n";
    expect(resolve(edited, fp)).toBe("orphaned");
  });
});
  • Step 2: Run it to verify it passes

Run: npm test -- test/reanchor.test.ts Expected: PASS — all three SLICE-4 decisions green (the controller composes exactly these calls).

  • Step 3: Self-write-guard review (no code change expected)

Confirm in src/threadController.ts that persist() adds the sidecar path to selfWrites before store.save, and onExternalSidecarChange() consumes-and-skips that path — so the watcher does not re-render on our own writes (which would dispose the thread the user is mid-reply on). If missing, add it as shown in Task 3 Step 6. Run npm run typecheck after any change.

  • Step 4: Commit
git add test/reanchor.test.ts
git commit -m "F2 SLICE-4: lock reload/re-anchor/orphan decision logic with unit tests (#4)"

Task 5 (SLICE-5): Host E2E with @vscode/test-electron

The Comments surface is native VS Code UI, so it is exercised in the real Extension Host. Where the harness cannot click the native Comments gutter, we drive the extension's returned ThreadController API and assert its rendered state + the on-disk sidecar (spec §6.8 fallback).

Files:

  • Modify: package.json (E2E dev deps + scripts)

  • Create: tsconfig.e2e.json

  • Create: test/e2e/runTest.ts

  • Create: test/e2e/suite/index.ts

  • Create: test/e2e/suite/threads.test.ts

  • Create: test/e2e/fixtures/workspace/docs/sample.md

  • Modify: .gitignore

  • Step 1: Add E2E dev deps and scripts to package.json

Add to devDependencies:

    "@vscode/test-electron": "^2.4.0",
    "mocha": "^10.7.0",
    "@types/mocha": "^10.0.7",
    "glob": "^11.0.0"

Add to scripts:

    "pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
    "test:e2e": "node ./out/test/e2e/runTest.js"
  • Step 2: Install the new dev deps

Run: npm install Expected: lockfile updated; @vscode/test-electron, mocha, glob present; no error exit.

  • Step 3: Create tsconfig.e2e.json

Compiles src + test/e2e to CommonJS under out/ so the host can require them:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "Node",
    "noEmit": false,
    "outDir": "out",
    "rootDir": ".",
    "sourceMap": true,
    "types": ["node", "mocha", "vscode"]
  },
  "include": ["src", "test/e2e"]
}

Note: src is compiled here only so the E2E suite can import controller types/helpers if needed; the extension itself still loads from the esbuild bundle out/extension.cjs via package.json#main. The dynamic import("@cline/sdk") in src/cline.ts is untouched by F2 and is not invoked by these tests (INV-5).

  • Step 4: Create the fixture document test/e2e/fixtures/workspace/docs/sample.md
# Sample Document

The quick brown fox jumps over the lazy dog.

A second paragraph with the phrase anchor target inside it.

Closing line.
  • Step 5: Create test/e2e/runTest.ts

Copies the fixture workspace to a temp dir (so the test's .threads/ writes never dirty the repo), then launches the host pointed at it:

import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import { runTests } from "@vscode/test-electron";

async function main(): Promise<void> {
  const projectRoot = path.resolve(__dirname, "../../../");
  const extensionDevelopmentPath = projectRoot;
  const extensionTestsPath = path.resolve(__dirname, "./suite/index");

  // Copy the fixture workspace to a temp dir so test writes are throwaway.
  const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace");
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
  fs.cpSync(fixture, workspace, { recursive: true });

  try {
    await runTests({
      extensionDevelopmentPath,
      extensionTestsPath,
      launchArgs: [workspace, "--disable-extensions"],
      extensionTestsEnv: { E2E_WORKSPACE: workspace },
    });
  } finally {
    fs.rmSync(workspace, { recursive: true, force: true });
  }
}

main().catch((err) => {
  console.error("E2E run failed:", err);
  process.exit(1);
});
  • Step 6: Create the mocha runner test/e2e/suite/index.ts
import * as path from "path";
import Mocha from "mocha";
import { glob } from "glob";

export async function run(): Promise<void> {
  const mocha = new Mocha({ ui: "bdd", color: true, timeout: 60000 });
  const testsRoot = path.resolve(__dirname);
  const files = await glob("**/*.test.js", { cwd: testsRoot });
  for (const f of files) mocha.addFile(path.resolve(testsRoot, f));
  await new Promise<void>((resolve, reject) => {
    mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve()));
  });
}
  • Step 7: Create the host E2E test/e2e/suite/threads.test.ts

Exercises create → reply → resolve → persist → reload → re-anchor → orphan via the extension API + on-disk sidecar:

import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import type { Artifact } from "../../../src/model";

const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/sample.md";

function sidecarPath(): string {
  return path.join(WS, ".threads", "docs", "sample.md.json");
}
function readSidecar(): Artifact {
  return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
}
async function openSample(): Promise<vscode.TextDocument> {
  const uri = vscode.Uri.file(path.join(WS, DOC_REL));
  const doc = await vscode.workspace.openTextDocument(uri);
  await vscode.window.showTextDocument(doc);
  return doc;
}
async function getApi(): Promise<CowritingApi> {
  const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
  const api = (await ext.activate()) as CowritingApi;
  assert.ok(api?.threadController, "extension should export threadController");
  return api;
}

suite("F2 region-anchored threads (host E2E)", () => {
  test("create on selection persists a thread sidecar", async () => {
    const doc = await openSample();
    const api = await getApi();
    const start = doc.getText().indexOf("quick brown fox");
    const editor = vscode.window.activeTextEditor!;
    editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));

    const threadId = await api.threadController.createThreadOnSelection("First note");
    assert.ok(threadId, "createThreadOnSelection returns an id");
    assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk");

    const art = readSidecar();
    assert.strictEqual(art.threads.length, 1);
    assert.strictEqual(art.threads[0].status, "open");
    assert.strictEqual(art.threads[0].messages[0].body, "First note");
    const anchorId = art.threads[0].anchorId;
    assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox");

    const rendered = api.threadController.getRendered(DOC_REL);
    assert.strictEqual(rendered.length, 1);
    assert.strictEqual(rendered[0].orphaned, false);
  });

  test("reply appends and resolve flips status, both persisted", async () => {
    const api = await getApi();
    const art0 = readSidecar();
    const threadId = art0.threads[0].id;

    api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as any);
    const art1 = readSidecar();
    assert.deepStrictEqual(art1.threads[0].messages.map((m) => m.body), ["First note", "A reply"]);

    setStatusViaApi(api, threadId, "resolved");
    const art2 = readSidecar();
    assert.strictEqual(art2.threads[0].status, "resolved");
  });

  test("reload renders the persisted thread at its anchor", async () => {
    const doc = await openSample();
    const api = await getApi();
    api.threadController.renderAll(doc);
    const rendered = api.threadController.getRendered(DOC_REL);
    assert.strictEqual(rendered.length, 1);
    assert.strictEqual(rendered[0].orphaned, false);
    const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
    assert.strictEqual(rendered[0].range.startLine, anchorLine);
  });

  test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => {
    const uri = vscode.Uri.file(path.join(WS, DOC_REL));
    const original = fs.readFileSync(uri.fsPath, "utf8");

    // Move the anchored text down by prepending lines (external change).
    fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original, "utf8");
    let doc = await vscode.workspace.openTextDocument(uri);
    const api = await getApi();
    api.threadController.renderAll(doc);
    let rendered = api.threadController.getRendered(DOC_REL);
    assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move");
    const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
    assert.strictEqual(rendered[0].range.startLine, movedLine);

    // Now delete the anchored text entirely → must orphan, not move.
    fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nThe slow grey cat\n" + original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps."), "utf8");
    doc = await vscode.workspace.openTextDocument(uri);
    api.threadController.renderAll(doc);
    rendered = api.threadController.getRendered(DOC_REL);
    assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)");
  });
});

// --- helpers that reach the live vscode thread for reply/resolve ---
function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread {
  const rendered = api.threadController.getRendered(DOC_REL);
  assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered");
  return (api.threadController as unknown as { docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }> }).docs
    .get(DOC_REL)!.vsThreads.get(threadId)!;
}
function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void {
  const vsThread = findVsThread(api, threadId);
  void vscode.commands.executeCommand(status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread", vsThread);
}

Note: findVsThread/setStatusViaApi reach the controller's internal docs map for the reply/resolve handlers (which expect a live vscode.CommentThread). This is the spec §6.8 "drive the ThreadController and assert Comments-controller state" fallback. Keep the docs field accessible (it is a private field but reachable via the cast above; do not add a public getter solely for this unless typecheck forces it).

  • Step 8: Add the E2E artifacts to .gitignore

Append to .gitignore:

# E2E host-test build output + throwaway sidecars
out/test/
test/e2e/fixtures/workspace/.threads/
  • Step 9: Compile the E2E suite (typecheck the host tests)

Run: npm run pretest:e2e Expected: PASS — npm run build writes out/extension.cjs; tsc -p tsconfig.e2e.json emits out/test/e2e/**/*.js and out/src/**/*.js with no type errors.

  • Step 10: Run the host E2E

Run: npm run test:e2e Expected: @vscode/test-electron downloads a VS Code build (first run only), launches the Extension Development Host on the temp workspace, and the 4 suites pass: create-persists, reply+resolve, reload, re-anchor+orphan.

If the sandbox cannot download VS Code (no network), record this as the one operator-run step: re-run npm run test:e2e on the workstation. Do not mark SLICE-5 done on an unrun suite — note it explicitly in the transcript's Deferred decisions and the finalize report.

  • Step 11: Commit
git add package.json package-lock.json tsconfig.e2e.json test/e2e .gitignore
git commit -m "F2 SLICE-5: @vscode/test-electron host E2E (create→reload→re-anchor→orphan) (#4)"

Task 6: README + full verification sweep

Files:

  • Modify: README.md

  • Step 1: Add an F2 section to README.md

Append (after the existing POC content):

## F2 — Region-anchored threads (Feature #4)

Attach durable, region-anchored discussion threads to any document. Threads
render in the native VS Code **Comments** gutter and persist as git-native
sidecars under `.threads/<doc-path>.json` (plain, diffable JSON — no server).

- **Create:** select text → run **"Cowriting: Add Coauthoring Thread on Selection"**
  (or the Comments gutter "+").
- **Reply / Resolve:** use the native Comments reply box and the thread's
  Resolve/Reopen actions.
- **Survives edits, reload, and external change (`git pull`):** a hybrid anchor
  (durable content fingerprint + live offset tracking) re-resolves the thread.
  If the anchored text can't be confidently re-found, the thread is shown as
  **orphaned** at its last-known line — never silently moved.

### Tests

- `npm test` — vitest unit suite (schema, store, anchorer, thread mutations).
- `npm run test:e2e``@vscode/test-electron` host E2E
  (create → reply → resolve → persist → reload → re-anchor → orphan).
  • Step 2: Full verification sweep

Run: npm install && npm run typecheck && npm test && npm run build Expected: install OK; typecheck OK; all vitest suites pass; build writes out/extension.cjs.

  • Step 3: Run the host E2E (if not already green)

Run: npm run test:e2e Expected: host suites pass (or recorded as the operator-run step per SLICE-5 Step 10).

  • Step 4: Commit
git add README.md
git commit -m "F2: document region-anchored threads + test commands (#4)"

Manual / operator verification (cannot be fully automated in-session)

  • Press F5 → Extension Development Host opens on a folder with a text doc, no activation error.
  • Select a phrase → "Cowriting: Add Coauthoring Thread on Selection" → a Comments thread appears on the gutter; .threads/<doc>.json is created.
  • Reply in the thread, then Resolve → the sidecar reflects the extra message and status: "resolved".
  • Close & reopen the folder → the thread reloads on the same span.
  • Edit above the anchor (push it down) and save → the thread tracks to the new line. Delete the anchored text → the thread shows as orphaned, not moved (INV-1).

This is F2's acceptance: region-anchored threads create/reply/resolve via native Comments, persist as git-native sidecars, and survive edits/reload/external change — orphaning rather than guessing — with unit + host E2E green (spec §7.3).


Spec coverage self-review

  • SLICE-1 schema + CoauthorStore → Task 1. Shared anchors/provenance + schemaVersion + empty attributions/proposals extension points (INV-4) present in model.ts. Stable formatting (INV-2) tested.
  • SLICE-2 Anchorer → Task 2. Resolution ladder exact→context→lineHint→orphaned (INV-1/INV-3) + live shift tested.
  • SLICE-3 ThreadController via vscode.comments (create/reply/resolve/render) → Task 3, with vscode-free threadModel unit-tested.
  • SLICE-4 reload + FileSystemWatcher external re-anchor + orphaned surface → Task 3 (mechanics) + Task 4 (decision-logic tests).
  • SLICE-5 @vscode/test-electron E2E across create→persist→reload→re-anchor → Task 5.
  • INV-5 no live @cline/sdk turn / no credentials → nothing in F2 calls the SDK; cline.ts untouched.
  • PUC-1..4 → create (T3), reply/resolve (T3), reload (T3/T5), re-anchor/orphan (T3/T4/T5).
  • UI surface ⇒ E2E host tests are first-class plan tasks → SLICE-5 is Task 5, not a follow-up (handbook §4 / spec §6.8, §7.2).