Files
vscode-cowriting-plugin-con…/plans/2026-06-10-f3-live-attribution.md
T
2026-06-10 11:13:19 -07:00

68 KiB
Raw Blame History

F3 — Live Human/Claude Attribution 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 F3 (benstull/vscode-cowriting-plugin#6) — live, char-honest human/Claude span attribution rendered in the buffer, persisted in the F2 sidecar's reserved attributions[], with the applyAgentEdit seam as the only machine-edit ingress (INV-9) and a minimal live @cline/sdk turn on the claude-code provider — per spec vscode-cowriting-plugin-content/specs/coauthoring-attribution.md §7.2 SLICE-1..6.

Architecture: Three new vscode-free units (vitest-tested, the F2 pattern) plus thin editor-facing wiring:

  • src/model.ts (modify) — typed AttributionRecord fills the reserved attributions: unknown[]; schemaVersion stays 1 (additive, parent INV-4).
  • src/attributionTracker.ts (new) — pure span algebra over offset ranges: applyChange(spans, edit, author, ctx) shifts/splits/clips and coalesces (INV-7: char-precise, only adjacent same-author+same-turn merges).
  • src/pendingEdits.ts (new) — PendingEditRegistry: exact (start,end,text) registrations consumed by the change event so a seam edit is attributed to the agent, never to typing (INV-9).
  • src/liveTurn.ts (new) — vscode-free @cline/sdk driver (dynamic import(), like cline.ts): one Agent turn on providerId: "claude-code" (zero credentials, INV-8) returning replacement text + model + runId.
  • src/store.ts (modify) — CoauthorStore.update(docPath, mutate): fresh-load → mutate → prune unreferenced anchors → save, so ThreadController and AttributionController never clobber each other's sidecar sections.
  • src/attributionController.ts (new) — the vscode layer: change-event wiring (human vs. seam), applyAgentEdit, decorations (Claude tint / human gutter border / toggle), save-time fingerprint persistence, load/external-change resolve-or-orphan (Anchorer ladder, INV-1/INV-6), orphan status-bar count + output channel.
  • src/extension.ts (modify) — wires the controller, the cowriting.editSelection command (LiveTurn → seam), exposes the test API.

Extension stays a CJS esbuild bundle (vscode + @cline/sdk external); esbuild gains a second ESM entry out/liveTurn.mjs so scripts/smoke-live-turn.mjs can drive the real LiveTurn module for the documented manual smoke. No LLM in CI — unit + host E2E drive the seam; the live turn gets the scripted/manual smoke.

Tech Stack: TypeScript, VS Code Extension API (TextEditorDecorationType, WorkspaceEdit, onDidChangeTextDocument), esbuild, vitest (unit), @vscode/test-electron + mocha (host E2E), @cline/sdk@0.0.46 (Agent + claude-code provider).


Verified SDK facts (probed in-session against installed 0.0.46)

  • Agent (alias of AgentRuntime) is exported from @cline/sdk (re-export chain → @cline/agents/dist/agent-runtime.d.ts:43). Friendly config form: { providerId, modelId, systemPrompt?, sessionId?, maxIterations?, ... } — no apiKey needed for claude-code.
  • "claude-code" is in BUILT_IN_PROVIDER_IDS; it shells out to the local claude binary via ai-sdk-provider-claude-code (rides the operator's Pro/Max login; no key fields).
  • Valid modelIds for claude-code: "sonnet" | "opus" | "haiku" or a full model string (ai-sdk-provider-claude-code/dist/index.d.ts:430). Use "sonnet".
  • agent.run(input: string): Promise<AgentRunResult> where AgentRunResult = { runId, status: "success"|"failed"|"aborted", outputText, messages, usage, error? } (@cline/shared/dist/agent.d.ts:378). Use runId as the provenance sessionId.
  • The SDK is ESM-only: always await import("@cline/sdk") (never static import / never bundle — esbuild.mjs keeps it external; same rule as src/cline.ts).

File Structure

File Responsibility
src/model.ts (modify) Add AttributionRecord; type Artifact.attributions; serialize attributions with stable field order.
src/attributionTracker.ts (new) Pure live-span algebra: LiveSpan, applyChange, coalesce. vscode-free.
src/pendingEdits.ts (new) PendingEditRegistry: register / match(consume) seam edits. vscode-free.
src/liveTurn.ts (new) runEditTurn(instruction, selectedText){ replacement, model, sessionId } via claude-code; extractReplacement fence-stripper. vscode-free.
src/store.ts (modify) Add update(docPath, mutate) with anchor pruning.
src/threadController.ts (modify) persist() switches to section-merge store.update (writes only threads + their anchors).
src/attributionController.ts (new) vscode layer: tracking, seam, decorations, toggle, persistence, resolve/orphan, status bar.
src/extension.ts (modify) Wire AttributionController + cowriting.editSelection; extend CowritingApi.
esbuild.mjs (modify) Second build: src/liveTurn.tsout/liveTurn.mjs (ESM).
scripts/smoke-live-turn.mjs (new) Scripted live smoke (not run in CI).
docs/MANUAL-SMOKE-F3.md (new) Documented manual smoke + failure-path checks.
package.json (modify) Commands: editSelection, toggleAttribution, applyAgentEdit (palette-hidden); smoke:live script.
test/model.test.ts (modify) Attribution round-trip + stable serialization.
test/attributionTracker.test.ts (new) Exhaustive span-algebra cases.
test/pendingEdits.test.ts (new) Register/match/consume/miss.
test/store.test.ts (modify) update merge of two writers + anchor pruning.
test/liveTurn.test.ts (new) extractReplacement cases (pure; no SDK).
test/e2e/fixtures/workspace/docs/attrib.md (new) Dedicated E2E fixture (threads tests own sample.md).
test/e2e/suite/attribution.test.ts (new) Host E2E: type → human span → seam → agent span → save → reload → re-anchor → orphan.

Shared design rules (read before any task)

  • Edit normalization. A VS Code content change {rangeOffset, rangeLength, text} maps to the F2 TextEdit {start: rangeOffset, end: rangeOffset + rangeLength, newLength: text.length} (half-open replace, src/anchorer.ts:18).
  • Span algebra (INV-7). For span [s,e) and edit {start,end,newLength}, delta = newLength - (end - start):
    1. e <= start → unchanged. 2. s >= end → shift both by delta.
    2. Overlap → keep left remainder [s, start) (if s < start, keeps original id) and right remainder [end+delta, e+delta) (if e > end; gets a fresh id only when both remainders survive, i.e. a true split).
    3. If newLength > 0, the inserted range [start, start+newLength) becomes a new span authored by the edit's author (fresh id, createdAt = now).
    4. Drop empty spans; sort by start; coalesce adjacent (a.end === b.start) spans whose author is JSON-equal and turnId is equal (undefined === undefined); merged span keeps the earlier createdAt and the first span's id.
  • Author equality = deep equality of the Provenance object plus turnId. Human provenance comes from the same source as ThreadController.currentAuthor() (src/threadController.ts:80).
  • Reload-vs-typing rule. In onDidChangeTextDocument, if e.document.isDirty === false the change is a disk sync (revert / external reload) → re-resolve from the persisted artifact (PUC-4 path); never attribute it. Dirty changes are attributed (seam match → agent, else human).
  • Sidecar co-ownership. All persistence goes through CoauthorStore.update: each controller writes only its own section (threads / attributions) + upserts the anchors its records reference; update prunes anchors referenced by neither threads nor attributions (proposals are still [] in F3 — revisit in F4).
  • Conventions: SSH-only git; no inline comments trailing CLI commands; commits cite the spec + #6 and carry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>.

Task 0: Branch

  • Step 0.1: git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin checkout -b feat/f3-live-attribution (from up-to-date main).

Task 1: SLICE-1 — Typed attributions[] model

Files:

  • Modify: src/model.ts

  • Test: test/model.test.ts

  • Step 1.1: Write the failing tests — append to test/model.test.ts:

describe("attributions[] (F3 SLICE-1)", () => {
  it("round-trips an attribution record through serialize → parse", () => {
    const a = emptyArtifact("docs/x.md");
    a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } };
    a.attributions.push({
      id: "at_1",
      anchorId: "a_1",
      author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
      createdAt: "2026-06-10T00:00:00.000Z",
      updatedAt: "2026-06-10T00:00:00.000Z",
      turnId: "turn-1",
    });
    const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
    expect(parsed.attributions).toEqual(a.attributions);
  });

  it("omits turnId when undefined and serializes stably", () => {
    const a = emptyArtifact("docs/x.md");
    a.attributions.push({
      id: "at_2",
      anchorId: "a_2",
      author: { kind: "human", id: "ben" },
      createdAt: "2026-06-10T00:00:00.000Z",
      updatedAt: "2026-06-10T00:00:00.000Z",
    });
    const s1 = serializeArtifact(a);
    expect(s1.includes("turnId")).toBe(false);
    const s2 = serializeArtifact(JSON.parse(s1) as Artifact);
    expect(s2).toBe(s1);
  });
});

Adjust the file's import line to include anything missing (it already imports emptyArtifact, serializeArtifact, types from ../src/model).

  • Step 1.2: Run npx vitest run test/model.test.ts — expect the new tests FAIL (TS error: attributions is unknown[], push of typed record fine but toEqual comparison compiles — the real failure is the typecheck once the type is used; if it passes untyped, proceed: the type lands next step).
  • Step 1.3: Implement in src/model.ts:

Add after the Thread interface:

/** F3 (spec §6.3): an anchored, author-attributed span — state, not history. */
export interface AttributionRecord {
  id: string;
  /** shared anchors map — same Fingerprint primitive (INV-4). */
  anchorId: string;
  author: Provenance;
  /** ISO-8601. */
  createdAt: string;
  /** ISO-8601; bumped when the span's extent/fingerprint changes. */
  updatedAt: string;
  /** groups all spans applied by one live turn. */
  turnId?: string;
}

Change Artifact.attributions from unknown[] to AttributionRecord[] (update the doc comment: "F3: anchored attributed spans (filled since F3)"). In serializeArtifact, replace attributions: a.attributions with stable field ordering:

    attributions: a.attributions.map((at) => ({
      id: at.id,
      anchorId: at.anchorId,
      author: serializeProvenance(at.author),
      createdAt: at.createdAt,
      updatedAt: at.updatedAt,
      ...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
    })),
  • Step 1.4: npx vitest run test/model.test.ts → PASS; npm run typecheck → clean.
  • Step 1.5: Commitgit add src/model.ts test/model.test.ts && git commit message: F3 SLICE-1: typed attributions[] model (spec §6.3, INV-4) (#6) + Co-Authored-By trailer.

Task 2: SLICE-2 — AttributionTracker span algebra

Files:

  • Create: src/attributionTracker.ts

  • Test: test/attributionTracker.test.ts

  • Step 2.1: Create src/attributionTracker.ts:

/**
 * AttributionTracker — pure span algebra over offset ranges (spec §6.2/§6.5).
 * Char-honest (INV-7): attributes exactly the characters each author produced —
 * split character-precisely, coalesce only adjacent same-author/same-turn
 * spans. vscode-free; the controller converts vscode change events to TextEdit.
 */
import type { TextEdit } from "./anchorer";
import type { Provenance } from "./model";

export interface LiveSpan {
  id: string;
  /** half-open [start, end) character offsets in the live document. */
  start: number;
  end: number;
  author: Provenance;
  turnId?: string;
  /** ISO-8601; survives splits/merges (earliest wins on merge). */
  createdAt: string;
}

/** Injected effects so the algebra stays pure and deterministic in tests. */
export interface TrackerCtx {
  newId: () => string;
  now: () => string;
  turnId?: string;
}

function sameAuthor(a: LiveSpan, b: LiveSpan): boolean {
  return JSON.stringify(a.author) === JSON.stringify(b.author) && a.turnId === b.turnId;
}

/** Merge adjacent same-author/same-turn spans; drop empties; sort by start. */
export function coalesce(spans: LiveSpan[]): LiveSpan[] {
  const sorted = spans.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start);
  const out: LiveSpan[] = [];
  for (const s of sorted) {
    const last = out[out.length - 1];
    if (last && last.end === s.start && sameAuthor(last, s)) {
      last.end = s.end;
      if (s.createdAt < last.createdAt) last.createdAt = s.createdAt;
    } else {
      out.push({ ...s });
    }
  }
  return out;
}

/**
 * Apply one document edit (the half-open range [start,end) replaced by
 * newLength chars) authored by `author`. Existing spans shift/split/clip;
 * inserted chars become a new span of `author` (INV-7).
 */
export function applyChange(
  spans: LiveSpan[],
  edit: TextEdit,
  author: Provenance,
  ctx: TrackerCtx,
): LiveSpan[] {
  const delta = edit.newLength - (edit.end - edit.start);
  const out: LiveSpan[] = [];
  for (const s of spans) {
    if (s.end <= edit.start) {
      out.push({ ...s });
    } else if (s.start >= edit.end) {
      out.push({ ...s, start: s.start + delta, end: s.end + delta });
    } else {
      const left = s.start < edit.start ? { ...s, end: edit.start } : null;
      const right =
        s.end > edit.end ? { ...s, start: edit.end + delta, end: s.end + delta } : null;
      if (left) out.push(left);
      if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
    }
  }
  if (edit.newLength > 0) {
    out.push({
      id: ctx.newId(),
      start: edit.start,
      end: edit.start + edit.newLength,
      author,
      turnId: ctx.turnId,
      createdAt: ctx.now(),
    });
  }
  return coalesce(out);
}
  • Step 2.2: Create test/attributionTracker.test.ts — exhaustive cases, both author directions:
import { describe, it, expect } from "vitest";
import { applyChange, coalesce, type LiveSpan, type TrackerCtx } from "../src/attributionTracker";
import type { Provenance } from "../src/model";

const HUMAN: Provenance = { kind: "human", id: "ben" };
const AGENT: Provenance = {
  kind: "agent", id: "claude",
  agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
};

function ctx(turnId?: string): TrackerCtx {
  let n = 0;
  return { newId: () => `n${++n}`, now: () => "2026-06-10T00:00:00.000Z", turnId };
}
function span(id: string, start: number, end: number, author: Provenance, turnId?: string): LiveSpan {
  return { id, start, end, author, turnId, createdAt: "2026-06-09T00:00:00.000Z" };
}
const ranges = (s: LiveSpan[]) => s.map((x) => [x.start, x.end]);
const authors = (s: LiveSpan[]) => s.map((x) => x.author.kind);

describe("applyChange — insertions", () => {
  it("insertion into empty span list creates one author span", () => {
    const r = applyChange([], { start: 5, end: 5, newLength: 3 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[5, 8]]);
    expect(r[0].author).toEqual(HUMAN);
  });
  it("insertion before a span shifts it", () => {
    const r = applyChange([span("a", 10, 14, AGENT)], { start: 0, end: 0, newLength: 2 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 2], [12, 16]]);
    expect(authors(r)).toEqual(["human", "agent"]);
  });
  it("insertion after a span leaves it untouched", () => {
    const r = applyChange([span("a", 0, 4, AGENT)], { start: 10, end: 10, newLength: 2 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 4], [10, 12]]);
  });
  it("PUC-3: insertion strictly inside a different-author span splits it char-precisely", () => {
    const r = applyChange([span("a", 0, 10, AGENT)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 4], [4, 7], [7, 13]]);
    expect(authors(r)).toEqual(["agent", "human", "agent"]);
  });
  it("symmetric: agent insertion inside a human span splits it (INV-7)", () => {
    const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, AGENT, ctx("t1"));
    expect(authors(r)).toEqual(["human", "agent", "human"]);
    expect(r[1].turnId).toBe("t1");
  });
  it("insertion inside a SAME-author span coalesces back to one span", () => {
    const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 13]]);
  });
  it("insertion at a span boundary does not split; same author coalesces", () => {
    const r = applyChange([span("a", 0, 5, HUMAN)], { start: 5, end: 5, newLength: 2 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 7]]);
  });
});

describe("applyChange — deletions", () => {
  it("deletion before a span shifts it left", () => {
    const r = applyChange([span("a", 10, 14, HUMAN)], { start: 0, end: 4, newLength: 0 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[6, 10]]);
  });
  it("deletion entirely inside a span shrinks it (no re-authoring, INV-6)", () => {
    const r = applyChange([span("a", 0, 10, AGENT)], { start: 3, end: 6, newLength: 0 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 7]]);
    expect(authors(r)).toEqual(["agent"]);
  });
  it("deletion covering a whole span removes it", () => {
    const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx());
    expect(r).toEqual([]);
  });
  it("deletion clipping a span head keeps the tail", () => {
    const r = applyChange([span("a", 4, 10, AGENT)], { start: 0, end: 6, newLength: 0 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 4]]);
  });
  it("deletion clipping a span tail keeps the head", () => {
    const r = applyChange([span("a", 0, 6, AGENT)], { start: 4, end: 10, newLength: 0 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 4]]);
  });
});

describe("applyChange — replacements", () => {
  it("replacement inside an agent span: human chars in the middle, agent halves keep ids/createdAt", () => {
    const r = applyChange([span("a", 0, 12, AGENT)], { start: 4, end: 8, newLength: 2 }, HUMAN, ctx());
    expect(ranges(r)).toEqual([[0, 4], [4, 6], [6, 10]]);
    expect(authors(r)).toEqual(["agent", "human", "agent"]);
    expect(r[0].id).toBe("a");
    expect(r[0].createdAt).toBe("2026-06-09T00:00:00.000Z");
  });
  it("replacement spanning two spans clips both and inserts the author's span between", () => {
    const r = applyChange(
      [span("a", 0, 6, AGENT), span("b", 6, 12, HUMAN)],
      { start: 4, end: 8, newLength: 5 }, AGENT, ctx("t2"),
    );
    expect(ranges(r)).toEqual([[0, 4], [4, 9], [9, 13]]);
    expect(authors(r)).toEqual(["agent", "agent", "human"]);
    expect(r[1].turnId).toBe("t2");
  });
});

describe("multi-edit sequences", () => {
  it("type, agent-replace, type inside agent span — ends char-honest", () => {
    const c = ctx();
    let s = applyChange([], { start: 0, end: 0, newLength: 20 }, HUMAN, c);
    s = applyChange(s, { start: 5, end: 10, newLength: 8 }, AGENT, { ...c, turnId: "t1" });
    s = applyChange(s, { start: 9, end: 9, newLength: 1 }, HUMAN, c);
    expect(authors(s)).toEqual(["human", "agent", "human", "agent", "human"]);
    expect(ranges(s)).toEqual([[0, 5], [5, 9], [9, 10], [10, 14], [14, 24]]);
  });
});

describe("coalesce", () => {
  it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => {
    const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" };
    const b = { ...span("b", 3, 6, HUMAN), createdAt: "2026-06-08T00:00:00.000Z" };
    const r = coalesce([a, b]);
    expect(ranges(r)).toEqual([[0, 6]]);
    expect(r[0].id).toBe("a");
    expect(r[0].createdAt).toBe("2026-06-08T00:00:00.000Z");
  });
  it("does NOT merge different authors, different turnIds, or non-adjacent spans", () => {
    expect(coalesce([span("a", 0, 3, HUMAN), span("b", 3, 6, AGENT)])).toHaveLength(2);
    expect(coalesce([span("a", 0, 3, AGENT, "t1"), span("b", 3, 6, AGENT, "t2")])).toHaveLength(2);
    expect(coalesce([span("a", 0, 3, HUMAN), span("b", 4, 6, HUMAN)])).toHaveLength(2);
  });
  it("drops empty spans", () => {
    expect(coalesce([span("a", 5, 5, HUMAN)])).toEqual([]);
  });
});
  • Step 2.3: npx vitest run test/attributionTracker.test.ts → all PASS (write the test first if you prefer strict TDD ordering; both files land in this task either way).
  • Step 2.4: npm run typecheck → clean.
  • Step 2.5: CommitF3 SLICE-2: AttributionTracker span algebra (INV-6/INV-7) (#6) + trailer.

Task 3: SLICE-3a — PendingEditRegistry + CoauthorStore.update (sidecar co-ownership)

Files:

  • Create: src/pendingEdits.ts

  • Modify: src/store.ts, src/threadController.ts:85-88 (persist)

  • Test: test/pendingEdits.test.ts, test/store.test.ts

  • Step 3.1: Create src/pendingEdits.ts:

/**
 * PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before
 * applyAgentEdit applies a WorkspaceEdit, it registers the exact expected
 * change; the controller's change handler consumes a matching registration to
 * attribute that change to the agent. Anything unmatched is human typing.
 * vscode-free and unit-testable.
 */
import type { Provenance } from "./model";

export interface PendingEdit {
  docPath: string;
  /** half-open [start, end) offsets of the replaced range. */
  start: number;
  end: number;
  newText: string;
  provenance: Provenance;
  turnId?: string;
}

export class PendingEditRegistry {
  private pending: PendingEdit[] = [];

  register(edit: PendingEdit): void {
    this.pending.push(edit);
  }

  /** Remove a registration that failed to apply. */
  unregister(edit: PendingEdit): void {
    this.pending = this.pending.filter((p) => p !== edit);
  }

  /**
   * Find-and-consume the registration exactly matching a change event
   * (same doc, same replaced range, same inserted text). Null → human edit.
   */
  match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
    const i = this.pending.findIndex(
      (p) =>
        p.docPath === docPath &&
        p.start === change.start &&
        p.end === change.end &&
        p.newText === change.text,
    );
    if (i === -1) return null;
    const [hit] = this.pending.splice(i, 1);
    return hit;
  }
}
  • Step 3.2: Create test/pendingEdits.test.ts:
import { describe, it, expect } from "vitest";
import { PendingEditRegistry } from "../src/pendingEdits";
import type { Provenance } from "../src/model";

const AGENT: Provenance = {
  kind: "agent", id: "claude",
  agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
};

describe("PendingEditRegistry (INV-9)", () => {
  it("matches and consumes an exact registration", () => {
    const reg = new PendingEditRegistry();
    reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
    const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
    expect(hit?.turnId).toBe("t1");
    expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
  });
  it("does not match a different doc, range, or text (human typing stays human)", () => {
    const reg = new PendingEditRegistry();
    reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
    expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
    expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
    expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
  });
  it("unregister removes a failed application", () => {
    const reg = new PendingEditRegistry();
    const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
    reg.register(p);
    reg.unregister(p);
    expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
  });
});
  • Step 3.3: npx vitest run test/pendingEdits.test.ts → PASS.
  • Step 3.4: Add CoauthorStore.update in src/store.ts (import emptyArtifact from ./model):
  /**
   * Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller
   * mutates only its own section against a FRESH load, so ThreadController and
   * AttributionController never clobber each other. After the mutation, anchors
   * referenced by neither threads nor attributions are pruned (proposals are
   * still empty in F3 — revisit in F4).
   */
  update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
    const artifact = this.load(docPath) ?? emptyArtifact(docPath);
    mutate(artifact);
    const referenced = new Set<string>([
      ...artifact.threads.map((t) => t.anchorId),
      ...artifact.attributions.map((a) => a.anchorId),
    ]);
    for (const id of Object.keys(artifact.anchors)) {
      if (!referenced.has(id)) delete artifact.anchors[id];
    }
    this.save(docPath, artifact);
    return artifact;
  }
  • Step 3.5: Switch ThreadController.persist (src/threadController.ts:85-88) to section-merge so it stops clobbering attributions[]:
  private persist(state: DocState): void {
    this.selfWrites.add(this.store.sidecarPath(state.docPath));
    this.store.update(state.docPath, (a) => {
      a.threads = state.artifact.threads;
      for (const t of state.artifact.threads) {
        a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId];
      }
    });
  }
  • Step 3.6: Add tests to test/store.test.ts (two-writer merge + pruning):
  it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => {
    const store = new CoauthorStore(tmp);
    store.update("d.md", (a) => {
      a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } };
      a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] });
    });
    store.update("d.md", (a) => {
      a.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
      a.attributions.push({
        id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" },
        createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
      });
    });
    const loaded = store.load("d.md")!;
    expect(loaded.threads).toHaveLength(1);
    expect(loaded.attributions).toHaveLength(1);
    expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]);
  });

  it("update() prunes anchors no longer referenced by threads or attributions", () => {
    const store = new CoauthorStore(tmp);
    store.update("d.md", (a) => {
      a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } };
      a.attributions.push({
        id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" },
        createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
      });
    });
    store.update("d.md", (a) => {
      a.attributions = [];
    });
    expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]);
  });

Match the existing file's temp-dir fixture variable name (read test/store.test.ts first; it creates a temp root — reuse its pattern, here shown as tmp).

  • Step 3.7: npx vitest run (full unit suite) → PASS; npm run typecheck → clean.
  • Step 3.8: CommitF3 SLICE-3: pending-edit registry + sidecar section-merge update (INV-9 groundwork) (#6) + trailer.

Amendment (postTask-3 code review). Two controllers now co-own the sidecar, so per-controller FileSystemWatchers + per-controller selfWrites would make each controller's persist look "external" to the other (comment-UI flicker on every save) and the 1-write=1-event assumption is fragile. Task 4 therefore deviates from the code blocks below in four ways:

  1. Self-write suppression moves into CoauthorStore (the single write funnel): update() records sidecarPath in a store-level selfWrites set; new consumeSelfWrite(fsPath): boolean removes-and-reports.
  2. One shared sidecar watcher in extension.ts replaces the per- controller watchers: on event, store.consumeSelfWrite(p) → ignore; else notify BOTH controllers (threadController.handleExternalSidecarChange, attributionController.handleExternalSidecarChange — the existing private handlers made public, with their own selfWrites checks removed). ThreadController's constructor watcher + selfWrites set are removed.
  3. applyAgentEdit unregisters unconditionally after applyEdit resolves (identity-based unregister is a no-op if match already consumed it) — prevents the no-op-edit registration leak.
  4. Doc notes: update's mutate must be synchronous; unregister is identity-based (callers keep the registered object).

Task 4: SLICE-3b — AttributionController: seam, human wiring, decorations, toggle

Files:

  • Create: src/attributionController.ts
  • Modify: src/extension.ts, package.json

This is the vscode layer — no unit tests here (host E2E covers it in Task 7); the gate for this task is npm run typecheck + npm run build + the Task 7 E2E.

  • Step 4.1: Create src/attributionController.ts:
/**
 * AttributionController — the thin editor-facing layer for F3 (spec §6.2).
 * Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to
 * the editor: human typing → human spans, seam edits → agent spans (INV-9),
 * decorations (Claude tint / human gutter border / toggle), save-time
 * persistence, load/external-change resolve-or-orphan (INV-1/INV-6), and the
 * orphan status-bar count.
 */
import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { newId, type AttributionRecord, type Provenance } from "./model";
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
import { PendingEditRegistry } from "./pendingEdits";

/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
  id: string;
  authorKind: "human" | "agent";
  authorId: string;
  turnId?: string;
  range: { start: number; end: number };
}

interface DocAttribution {
  docPath: string;
  spans: LiveSpan[];
  /** persisted records whose fingerprints failed to resolve (PUC-4). */
  orphans: AttributionRecord[];
  /** record metadata per live span id (updatedAt bookkeeping). */
  records: Map<string, AttributionRecord>;
}

const AGENT_DECO: vscode.DecorationRenderOptions = {
  backgroundColor: "rgba(99, 102, 241, 0.18)",
  overviewRulerColor: "rgba(99, 102, 241, 0.8)",
  overviewRulerLane: vscode.OverviewRulerLane.Right,
};
const HUMAN_DECO: vscode.DecorationRenderOptions = {
  borderColor: "rgba(16, 185, 129, 0.8)",
  borderStyle: "solid",
  borderWidth: "0 0 0 2px",
};

export class AttributionController implements vscode.Disposable {
  private readonly disposables: vscode.Disposable[] = [];
  private readonly docs = new Map<string, DocAttribution>();
  private readonly pending = new PendingEditRegistry();
  private readonly agentType = vscode.window.createTextEditorDecorationType(AGENT_DECO);
  private readonly humanType = vscode.window.createTextEditorDecorationType(HUMAN_DECO);
  private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90);
  private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
  private visible = true;
  /** 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.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
    this.disposables.push(
      vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
      vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
      vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
      vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
    );
    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 isTracked(document: vscode.TextDocument): boolean {
    return document.uri.scheme === "file" && document.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 state(docPath: string): DocAttribution {
    let s = this.docs.get(docPath);
    if (!s) {
      s = { docPath, spans: [], orphans: [], records: new Map() };
      this.docs.set(docPath, s);
    }
    return s;
  }

  // ---- PUC-4: load / resolve-or-orphan ---------------------------------------------

  /** Load the sidecar and re-resolve every attribution (live span | orphan). */
  loadAll(document: vscode.TextDocument): void {
    if (!this.isTracked(document)) return;
    const docPath = this.docPathOf(document.uri);
    const s = this.state(docPath);
    const artifact = this.store.load(docPath);
    s.spans = [];
    s.orphans = [];
    s.records.clear();
    if (artifact) {
      const text = document.getText();
      for (const rec of artifact.attributions) {
        const fp = artifact.anchors[rec.anchorId]?.fingerprint;
        const resolved = fp ? resolve(text, fp) : "orphaned";
        if (resolved === "orphaned") {
          s.orphans.push(rec);
        } else {
          s.spans.push({
            id: rec.id, start: resolved.start, end: resolved.end,
            author: rec.author, turnId: rec.turnId, createdAt: rec.createdAt,
          });
          s.records.set(rec.id, rec);
        }
      }
      s.spans = coalesce(s.spans);
    }
    this.render(document);
  }

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

  // ---- PUC-1/PUC-3: live tracking ---------------------------------------------------

  private onDidChange(e: vscode.TextDocumentChangeEvent): void {
    if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
    const docPath = this.docPathOf(e.document.uri);
    if (!e.document.isDirty) {
      // Disk sync (revert / reload): re-resolve, never attribute (PUC-4).
      this.loadAll(e.document);
      return;
    }
    const s = this.state(docPath);
    for (const change of e.contentChanges) {
      const edit = {
        start: change.rangeOffset,
        end: change.rangeOffset + change.rangeLength,
        newLength: change.text.length,
      };
      const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
      const author = hit ? hit.provenance : this.currentAuthor();
      s.spans = applyChange(s.spans, edit, author, {
        newId: () => newId("at"),
        now: () => new Date().toISOString(),
        turnId: hit?.turnId,
      });
    }
    this.render(e.document);
  }

  // ---- the seam (INV-9) ---------------------------------------------------------------

  /**
   * The ONLY machine-edit ingress: register the exact expected change, then
   * apply it as a WorkspaceEdit. Returns false (applying nothing) on a stale
   * document version or a rejected edit — never partial-applies (spec §6.9).
   */
  async applyAgentEdit(
    document: vscode.TextDocument,
    range: vscode.Range,
    newText: string,
    provenance: Provenance,
    opts?: { expectedVersion?: number; turnId?: string },
  ): Promise<boolean> {
    if (!this.isTracked(document)) return false;
    if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
    const docPath = this.docPathOf(document.uri);
    const pendingEdit = {
      docPath,
      start: document.offsetAt(range.start),
      end: document.offsetAt(range.end),
      newText,
      provenance,
      turnId: opts?.turnId,
    };
    this.pending.register(pendingEdit);
    const we = new vscode.WorkspaceEdit();
    we.replace(document.uri, range, newText);
    const ok = await vscode.workspace.applyEdit(we);
    if (!ok) this.pending.unregister(pendingEdit);
    return ok;
  }

  // ---- PUC-4: persistence on save ----------------------------------------------------

  private onDidSave(document: vscode.TextDocument): void {
    if (!this.isTracked(document)) return;
    const docPath = this.docPathOf(document.uri);
    const s = this.docs.get(docPath);
    if (!s || (s.spans.length === 0 && s.orphans.length === 0)) return;
    const text = document.getText();
    const now = new Date().toISOString();
    const records: AttributionRecord[] = [];
    const anchorOf = new Map<string, OffsetRange>();
    for (const span of s.spans) {
      const prev = s.records.get(span.id);
      const rec: AttributionRecord = {
        id: span.id,
        anchorId: prev?.anchorId ?? newId("a"),
        author: span.author,
        createdAt: span.createdAt,
        updatedAt: prev ? prev.updatedAt : now,
        ...(span.turnId !== undefined ? { turnId: span.turnId } : {}),
      };
      anchorOf.set(rec.anchorId, { start: span.start, end: span.end });
      records.push(rec);
      s.records.set(span.id, rec);
    }
    this.selfWrites.add(this.store.sidecarPath(docPath));
    this.store.update(docPath, (a) => {
      for (const rec of records) {
        const range = anchorOf.get(rec.anchorId)!;
        const fp = buildFingerprint(text, range);
        const prevFp = a.anchors[rec.anchorId]?.fingerprint;
        if (prevFp && JSON.stringify(prevFp) !== JSON.stringify(fp)) rec.updatedAt = now;
        a.anchors[rec.anchorId] = { fingerprint: fp };
      }
      // Orphans ride along unchanged (recoverable, spec §6.9): their records
      // stay in attributions[], so update()'s prune keeps their anchors too.
      a.attributions = [...records, ...s.orphans];
    });
  }

  // ---- PUC-5: rendering ----------------------------------------------------------------

  private toggle(): void {
    this.visible = !this.visible;
    this.renderActive();
  }

  private renderActive(): void {
    const editor = vscode.window.activeTextEditor;
    if (editor) this.render(editor.document);
  }

  private render(document: vscode.TextDocument): void {
    const editor = vscode.window.visibleTextEditors.find((e) => e.document === document);
    if (!editor || !this.isTracked(document)) return;
    const s = this.docs.get(this.docPathOf(document.uri));
    const spans = this.visible && s ? s.spans : [];
    const toRange = (sp: LiveSpan) =>
      new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end));
    editor.setDecorations(this.agentType, spans.filter((x) => x.author.kind === "agent").map(toRange));
    editor.setDecorations(this.humanType, spans.filter((x) => x.author.kind === "human").map(toRange));
    this.renderStatus(s);
  }

  private renderStatus(s: DocAttribution | undefined): void {
    const n = s?.orphans.length ?? 0;
    if (n === 0) {
      this.statusItem.hide();
      return;
    }
    this.statusItem.text = `$(warning) ${n} orphaned attribution${n === 1 ? "" : "s"}`;
    this.statusItem.tooltip = "Cowriting: attribution anchors that no longer resolve (see output channel)";
    this.statusItem.show();
    this.output.clear();
    for (const o of s!.orphans) this.output.appendLine(`orphaned ${o.id} (${o.author.kind}:${o.author.id})`);
  }

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

  getSpans(docPath: string): RenderedSpan[] {
    const s = this.docs.get(docPath);
    if (!s) return [];
    return s.spans.map((sp) => ({
      id: sp.id,
      authorKind: sp.author.kind,
      authorId: sp.author.id,
      turnId: sp.turnId,
      range: { start: sp.start, end: sp.end },
    }));
  }
  getOrphanCount(docPath: string): number {
    return this.docs.get(docPath)?.orphans.length ?? 0;
  }
  isVisible(): boolean {
    return this.visible;
  }

  dispose(): void {
    for (const d of this.disposables) d.dispose();
  }
}

Note the deliberate behaviors: (a) onDidChange consults the registry BEFORE falling back to human (INV-9); (b) !isDirty changes re-resolve instead of attributing (reload rule); (c) onDidSave refreshes fingerprints and bumps updatedAt only for changed fingerprints; (d) orphans are carried through saves untouched in attributions[] — recoverable (spec §6.9), and store.update's prune keeps their anchors because the records remain referenced.

  • Step 4.2: Wire into src/extension.ts — extend the API and activation (full replacement of the F2 block):
import * as vscode from "vscode";
import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";

const CHANNEL_NAME = "Cowriting (Cline SDK)";

export interface CowritingApi {
  threadController: ThreadController;
  attributionController: AttributionController;
}

In activate, after the ThreadController wiring:

  const attributionController = new AttributionController(store, root);
  context.subscriptions.push(attributionController);

  context.subscriptions.push(
    vscode.commands.registerCommand(
      "cowriting.applyAgentEdit",
      (args: {
        uri: string; start: number; end: number; newText: string;
        model?: string; sessionId?: string; turnId?: string;
      }) => {
        const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
        if (!doc) return Promise.resolve(false);
        const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end));
        const provenance = {
          kind: "agent" as const,
          id: "claude",
          agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
        };
        return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId });
      },
    ),
  );

Update the open/render hook so attributions load alongside threads, and the return value:

  const renderIfOpen = (doc: vscode.TextDocument) => {
    if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
      threadController.renderAll(doc);
      attributionController.loadAll(doc);
    }
  };
  vscode.workspace.textDocuments.forEach(renderIfOpen);
  context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));

  return { threadController, attributionController };
  • Step 4.3: package.json contributions — add to contributes.commands:
      { "command": "cowriting.toggleAttribution", "title": "Cowriting: Toggle Attribution", "category": "Cowriting" },
      { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }

and hide the internal seam from the palette via contributes.menus:

    "commandPalette": [
      { "command": "cowriting.applyAgentEdit", "when": "false" }
    ]

(commandPalette is a new key inside the existing "menus" object.)

  • Step 4.4: npm run typecheck → clean; npm run build → bundle OK; npx vitest run → still green.
  • Step 4.5: CommitF3 SLICE-3: applyAgentEdit seam + live tracking + decorations/toggle (INV-7/INV-9) (#6) + trailer.

Task 5: SLICE-4 — persistence/reload is already wired; verify the decision logic in unit tests

Task 4 lands the save/load/orphan code paths (they are inseparable from the controller). This task adds the vscode-free unit coverage the spec requires (§6.8 "resolve→orphan for attribution fingerprints") and a tracker↔anchorer integration test.

Files:

  • Test: test/attributionPersistence.test.ts (new)

  • Step 5.1: Create test/attributionPersistence.test.ts:

import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve } from "../src/anchorer";
import { applyChange, type LiveSpan } from "../src/attributionTracker";
import type { Provenance } from "../src/model";

const HUMAN: Provenance = { kind: "human", id: "ben" };
const DOC = "intro\nClaude wrote this sentence here.\noutro\n";

function liveSpan(): LiveSpan {
  const start = DOC.indexOf("Claude wrote this sentence");
  return {
    id: "at_1", start, end: start + "Claude wrote this sentence".length,
    author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "r1" } },
    createdAt: "2026-06-10T00:00:00.000Z",
  };
}

describe("attribution persistence decision logic (SLICE-4, spec §6.5 PUC-4)", () => {
  it("save → reload: fingerprint built from the live span resolves to the same range", () => {
    const s = liveSpan();
    const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
    expect(resolve(DOC, fp)).toEqual({ start: s.start, end: s.end });
  });

  it("external edit above: fingerprint re-resolves at the moved range", () => {
    const s = liveSpan();
    const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
    const edited = "a new line\n" + DOC;
    const moved = edited.indexOf("Claude wrote this sentence");
    expect(resolve(edited, fp)).toEqual({ start: moved, end: moved + (s.end - s.start) });
  });

  it("anchored text mangled: orphaned, never guessed (INV-1/INV-6)", () => {
    const s = liveSpan();
    const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
    const mangled = DOC.replace("Claude wrote this sentence", "Someone rewrote everything");
    expect(resolve(mangled, fp)).toBe("orphaned");
  });

  it("in-session edits keep the live span fingerprint-able (tracker → anchorer round trip)", () => {
    let spans = [liveSpan()];
    let doc = DOC;
    const insertAt = DOC.indexOf("intro") + "intro".length;
    const inserted = " (note)";
    doc = doc.slice(0, insertAt) + inserted + doc.slice(insertAt);
    spans = applyChange(spans, { start: insertAt, end: insertAt, newLength: inserted.length }, HUMAN, {
      newId: () => "n1", now: () => "2026-06-10T01:00:00.000Z",
    });
    const agent = spans.find((x) => x.author.kind === "agent")!;
    expect(doc.slice(agent.start, agent.end)).toBe("Claude wrote this sentence");
    const fp = buildFingerprint(doc, { start: agent.start, end: agent.end });
    expect(resolve(doc, fp)).toEqual({ start: agent.start, end: agent.end });
  });
});
  • Step 5.2: npx vitest run test/attributionPersistence.test.ts → PASS.
  • Step 5.3: CommitF3 SLICE-4: persistence/re-anchor/orphan decision-logic tests (INV-1/INV-6) (#6) + trailer.

Task 6: SLICE-5 — Live claude-code turn + documented manual smoke

Files:

  • Create: src/liveTurn.ts, scripts/smoke-live-turn.mjs, docs/MANUAL-SMOKE-F3.md

  • Modify: src/extension.ts, esbuild.mjs, package.json

  • Test: test/liveTurn.test.ts

  • Step 6.1: Create src/liveTurn.ts:

/**
 * LiveTurn — the minimal machine-edit ingress (spec §6.2): one @cline/sdk
 * Agent turn on the built-in `claude-code` provider, which rides the
 * operator's local Claude Code Pro/Max login — the extension holds NO
 * credentials of any kind (INV-8). vscode-free; the editSelection command
 * (extension.ts) feeds the result into the applyAgentEdit seam (INV-9).
 *
 * Like src/cline.ts: @cline/sdk is ESM-only, loaded via dynamic import(),
 * never bundled (esbuild keeps it external).
 */

export interface EditTurnResult {
  replacement: string;
  model: string;
  /** the SDK run id — recorded as provenance sessionId. */
  sessionId: string;
}

const SYSTEM_PROMPT = [
  "You are a precise text editor embedded in VS Code.",
  "You will be given a piece of text and an instruction.",
  "Respond with ONLY the edited replacement text — no explanation, no preamble,",
  "no markdown code fences. Preserve the original's leading/trailing whitespace",
  "unless the instruction says otherwise.",
].join(" ");

/** Strip a wrapping markdown code fence if the model added one anyway. */
export function extractReplacement(outputText: string): string {
  const m = outputText.match(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/);
  return m ? m[1] : outputText;
}

export async function runEditTurn(
  instruction: string,
  selectedText: string,
  opts?: { modelId?: string },
): Promise<EditTurnResult> {
  const sdk = await import("@cline/sdk");
  const modelId = opts?.modelId ?? "sonnet";
  const agent = new sdk.Agent({
    providerId: "claude-code",
    modelId,
    systemPrompt: SYSTEM_PROMPT,
  });
  const result = await agent.run(
    `<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
  );
  if (result.status !== "success") {
    throw new Error(
      `claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
        "(is Claude Code installed and signed in?)",
    );
  }
  return { replacement: extractReplacement(result.outputText), model: modelId, sessionId: result.runId };
}

If new sdk.Agent(...) upsets TypeScript under moduleResolution: Bundler (the SDK's types resolve through re-exports), fall back to const { Agent } = sdk as { Agent: new (c: object) => { run(i: string): Promise<{ runId: string; status: string; outputText: string; error?: Error }> } }; — but try the typed path first; the d.ts chain was verified to exist.

  • Step 6.2: Create test/liveTurn.test.ts (pure helper only — no SDK, no LLM):
import { describe, it, expect } from "vitest";
import { extractReplacement } from "../src/liveTurn";

describe("extractReplacement", () => {
  it("returns plain text untouched", () => {
    expect(extractReplacement("Hello world.")).toBe("Hello world.");
  });
  it("strips a wrapping fence", () => {
    expect(extractReplacement("```\nHello world.\n```")).toBe("Hello world.");
  });
  it("strips a language-tagged fence", () => {
    expect(extractReplacement("```markdown\nHello.\n```\n")).toBe("Hello.");
  });
  it("leaves inner fences alone", () => {
    const inner = "before\n```js\ncode\n```\nafter";
    expect(extractReplacement(inner)).toBe(inner);
  });
});
  • Step 6.3: npx vitest run test/liveTurn.test.ts → PASS.
  • Step 6.4: The cowriting.editSelection command — in src/extension.ts activate, after the applyAgentEdit command registration:
  context.subscriptions.push(
    vscode.commands.registerCommand("cowriting.editSelection", async () => {
      const editor = vscode.window.activeTextEditor;
      if (!editor || editor.selection.isEmpty || !editor.document.uri.fsPath.startsWith(root)) {
        void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first.");
        return;
      }
      const instruction = await vscode.window.showInputBox({
        prompt: "What should Claude do with the selection?",
        placeHolder: "e.g. tighten this paragraph",
      });
      if (!instruction) return;
      const document = editor.document;
      const selection = editor.selection;
      const expectedVersion = document.version;
      const selectedText = document.getText(selection);
      const turnId = `turn-${Date.now().toString(36)}`;
      try {
        await vscode.window.withProgress(
          { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
          async () => {
            const { runEditTurn } = await import("./liveTurn");
            const turn = await runEditTurn(instruction, selectedText);
            const ok = await attributionController.applyAgentEdit(
              document,
              new vscode.Range(selection.start, selection.end),
              turn.replacement,
              {
                kind: "agent",
                id: "claude",
                agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
              },
              { expectedVersion, turnId },
            );
            if (!ok) {
              void vscode.window.showWarningMessage(
                "Cowriting: the document changed while Claude was editing — nothing was applied.",
              );
            }
          },
        );
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
      }
    }),
  );

NOTE: await import("./liveTurn") from the CJS bundle is fine — esbuild bundles relative dynamic imports; only @cline/sdk stays external (the inner import("@cline/sdk") is preserved, same as cline.ts).

  • Step 6.5: package.json — add the command + script:
      { "command": "cowriting.editSelection", "title": "Cowriting: Ask Claude to Edit Selection", "category": "Cowriting" }

and in "scripts": "smoke:live": "npm run build && node scripts/smoke-live-turn.mjs".

  • Step 6.6: esbuild.mjs second entry — after the existing build options, build LiveTurn as ESM for the smoke script. Replace the bottom if (watch) … else … block with:
/** @type {import('esbuild').BuildOptions} */
const liveTurnOptions = {
  entryPoints: ["src/liveTurn.ts"],
  outfile: "out/liveTurn.mjs",
  bundle: true,
  platform: "node",
  format: "esm",
  target: "node22",
  sourcemap: true,
  external: ["vscode", "@cline/sdk"],
  logLevel: "info",
};

if (watch) {
  const ctx = await context(options);
  const ctxLive = await context(liveTurnOptions);
  await Promise.all([ctx.watch(), ctxLive.watch()]);
  console.log("esbuild: watching…");
} else {
  await build(options);
  await build(liveTurnOptions);
  console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs");
}
  • Step 6.7: Create scripts/smoke-live-turn.mjs:
#!/usr/bin/env node
// Scripted half of the F3 manual smoke (docs/MANUAL-SMOKE-F3.md). Drives the
// REAL LiveTurn module against the local Claude Code login. Not run in CI.
import { runEditTurn } from "../out/liveTurn.mjs";

const instruction = process.argv[2] ?? "Replace this sentence with exactly: The smoke test passed.";
const text = process.argv[3] ?? "This sentence is the smoke-test input.";

console.log(`instruction: ${instruction}`);
console.log(`text:        ${text}`);
try {
  const t0 = Date.now();
  const result = await runEditTurn(instruction, text);
  console.log(`replacement: ${JSON.stringify(result.replacement)}`);
  console.log(`model:       ${result.model}`);
  console.log(`sessionId:   ${result.sessionId}`);
  console.log(`elapsed:     ${((Date.now() - t0) / 1000).toFixed(1)}s`);
  process.exit(0);
} catch (err) {
  console.error(`live turn failed (expected when Claude Code is absent/signed out): ${err.message}`);
  process.exit(1);
}
  • Step 6.8: Create docs/MANUAL-SMOKE-F3.md:
# F3 manual smoke — live `claude-code` turn (spec §6.8)

The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It
gets this documented smoke, run once per machine that has Claude Code
installed and signed in (Pro/Max). The extension itself holds no credentials
(INV-8) — auth is entirely the local Claude Code login.

## 1. Scripted smoke (the quick check)

    npm run smoke:live

Expected: prints a `replacement:` line containing "The smoke test passed.",
the model id, a non-empty `sessionId`, and exits 0.

## 2. In-editor smoke (the real PUC-2)

1. `npm run build`, then F5 (Extension Development Host) opening this repo.
2. Open any markdown file in the workspace; type a sentence — it renders with
   the human gutter border (left edge) as you type.
3. Select the sentence → run **“Cowriting: Ask Claude to Edit Selection”** →
   instruction: `rewrite this more formally`.
4. Expected: progress notification; on completion the replacement text lands
   **tinted** (Claude-attributed), the human border remains on your other
   edits, and after **save** the sidecar
   `.threads/<doc>.json` has an `attributions[]` entry with
   `author.kind: "agent"`, `agent.model`, `agent.sessionId`, and a `turnId`.
5. **Cowriting: Toggle Attribution** hides/shows both decorations (PUC-5).

## 3. Failure paths (INV-8 — graceful, tracking unaffected)

- Signed out / no Claude Code: `PATH="" npm run smoke:live` (or sign out) →
  the script exits 1 with a clear error; in-editor, the command shows an
  error notification and NO edit is applied.
- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the
  document before it completes → warning notification "document changed", no
  partial application (stale `expectedVersion`, spec §6.9).

## Smoke log

| Date | Machine | Result |
| --- | --- | --- |
| (fill on first run) | | |
  • Step 6.9: npm run typecheck && npm run build && npx vitest run → all green.
  • Step 6.10: Run the scripted smoke once (this machine has Claude Code signed in): npm run smoke:live → record the result row in docs/MANUAL-SMOKE-F3.md's Smoke log table. Also run the failure path PATH="" node scripts/smoke-live-turn.mjs → confirm clean exit-1 error. (If the live call is unexpectedly slow, give it ~2 min before judging.)
  • Step 6.11: CommitF3 SLICE-5: live claude-code turn via applyAgentEdit seam + manual smoke (INV-8) (#6) + trailer.

Amendment 2 (postTask-7 host-E2E findings). The host E2E exposed two real product bugs invisible to the unit suite (both host-only behaviors):

  1. isDirty cannot classify disk syncs. VS Code delivers the content change BEFORE flipping the dirty flag, so the first edit on a clean document arrives with isDirty === false — identical signature to a revert. Fix: on a !isDirty change event, compare document.getText() to the file's on-disk content — equal → disk sync (re-resolve via loadAll); different → real edit (attribute). Read errors → attribute.
  2. VS Code diff-minimizes WorkspaceEdits, so the seam's exact (start,end,text) match misses whenever newText shares a prefix/suffix with the replaced text (the agent edit then mis-attributes to the human — an INV-9 break). Fix: applyAgentEdit minimizes the replace itself (greedy common-prefix then common-suffix trim, non-overlapping) and both registers and applies the minimized triple; an edit that minimizes to nothing is a no-op returning true without applying. New vscode-free helper minimizeReplace(oldText, newText) in src/pendingEdits.ts + unit tests. Minimization is TRANSPORT-only: the registration carries the pre-minimization extent (PendingEdit.full) and the tracker attributes the agent's FULL intended replacement (intent-honest reading of INV-7/PUC-2 — the turn's whole replacement is the unit of machine authorship), not just the minimized delta.

Task 7: SLICE-6 — Host E2E (no LLM in CI)

Files:

  • Create: test/e2e/fixtures/workspace/docs/attrib.md, test/e2e/suite/attribution.test.ts

  • Step 7.1: Create the fixture test/e2e/fixtures/workspace/docs/attrib.md:

# Attribution fixture

A stable first paragraph that predates tracking.

The target sentence lives here for seam edits.
  • Step 7.2: Create test/e2e/suite/attribution.test.ts:
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/attrib.md";

function sidecarPath(): string {
  return path.join(WS, ".threads", "docs", "attrib.md.json");
}
function readSidecar(): Artifact {
  return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
}
async function openDoc(): 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?.attributionController, "extension exports attributionController");
  return api;
}
async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise<vscode.TextDocument> {
  fs.writeFileSync(uri.fsPath, content, "utf8");
  const doc = await vscode.workspace.openTextDocument(uri);
  await vscode.window.showTextDocument(doc);
  await vscode.commands.executeCommand("workbench.action.files.revert");
  return doc;
}
const settle = () => new Promise((r) => setTimeout(r, 300));

suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => {
  const TYPED = "A human-typed sentence. ";

  test("typing produces a live human span", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const editor = vscode.window.activeTextEditor!;
    const insertAt = doc.getText().length;
    await editor.edit((eb) => eb.insert(doc.positionAt(insertAt), TYPED));
    await settle();
    const spans = api.attributionController.getSpans(DOC_REL);
    assert.strictEqual(spans.length, 1);
    assert.strictEqual(spans[0].authorKind, "human");
    assert.strictEqual(doc.getText().slice(spans[0].range.start, spans[0].range.end), TYPED);
  });

  test("a seam edit produces an agent span; mixed edits stay char-honest (INV-7/INV-9)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const target = "target sentence";
    const start = doc.getText().indexOf(target);
    const ok = await api.attributionController.applyAgentEdit(
      doc,
      new vscode.Range(doc.positionAt(start), doc.positionAt(start + target.length)),
      "REWRITTEN-BY-CLAUDE sentence",
      { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-run" } },
      { turnId: "turn-e2e-1" },
    );
    assert.strictEqual(ok, true, "seam edit applies");
    await settle();
    const spans = api.attributionController.getSpans(DOC_REL);
    const agent = spans.find((s) => s.authorKind === "agent");
    assert.ok(agent, "agent span exists");
    assert.strictEqual(agent!.turnId, "turn-e2e-1");
    assert.strictEqual(
      doc.getText().slice(agent!.range.start, agent!.range.end),
      "REWRITTEN-BY-CLAUDE sentence",
    );
    assert.ok(spans.some((s) => s.authorKind === "human"), "human span still tracked");
  });

  test("stale expectedVersion refuses to apply (spec §6.9)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const ok = await api.attributionController.applyAgentEdit(
      doc,
      new vscode.Range(doc.positionAt(0), doc.positionAt(1)),
      "X",
      { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e" } },
      { expectedVersion: doc.version - 1 },
    );
    assert.strictEqual(ok, false);
  });

  test("save persists attributions[]; reload restores spans at re-resolved anchors (PUC-4)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    await doc.save();
    await settle();
    const art = readSidecar();
    assert.ok(art.attributions.length >= 2, "human + agent records persisted");
    const agentRec = art.attributions.find((a) => a.author.kind === "agent")!;
    assert.strictEqual(art.anchors[agentRec.anchorId].fingerprint.text, "REWRITTEN-BY-CLAUDE sentence");
    assert.strictEqual(agentRec.turnId, "turn-e2e-1");

    api.attributionController.loadAll(doc);
    const spans = api.attributionController.getSpans(DOC_REL);
    assert.ok(spans.some((s) => s.authorKind === "agent"), "agent span restored from sidecar");
    assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);
  });

  test("external move re-anchors; mangling the text orphans (INV-1/INV-6)", async () => {
    const uri = vscode.Uri.file(path.join(WS, DOC_REL));
    const original = fs.readFileSync(uri.fsPath, "utf8");
    const api = await getApi();

    let doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + original);
    await settle();
    api.attributionController.loadAll(doc);
    let spans = api.attributionController.getSpans(DOC_REL);
    const agent = spans.find((s) => s.authorKind === "agent")!;
    const moved = doc.getText().indexOf("REWRITTEN-BY-CLAUDE sentence");
    assert.strictEqual(agent.range.start, moved, "agent span re-anchored after move");
    assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);

    const mangled = doc.getText().replace("REWRITTEN-BY-CLAUDE sentence", "now something else entirely");
    doc = await externalWriteAndReload(uri, mangled);
    await settle();
    api.attributionController.loadAll(doc);
    spans = api.attributionController.getSpans(DOC_REL);
    assert.ok(!spans.some((s) => s.authorKind === "agent"), "mangled agent span not rendered");
    assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)");
  });
});
  • Step 7.3: npm run test:e2e → BOTH suites green (F2 threads + F3 attribution). Debugging notes: the settle() waits exist because decorations/change events are async-dispatched; if the human-span count flakes, check whether the revert in earlier tests left attrib.md dirty. The fixture doc is dedicated to this suite — do not touch sample.md (the threads suite owns it).
  • Step 7.4: CommitF3 SLICE-6: host E2E — type/seam/persist/re-anchor/orphan, no LLM in CI (#6) + trailer.

Task 8: Ship

  • Step 8.1: Full gate: npm run typecheck && npx vitest run && npm run build && npm run test:e2e → all green (the §9 pipeline's localhost+E2E stage; this app has no PPE/prod — non-shippable, spec §7.3).
  • Step 8.2: README — add an F3 section beside the F2 one: what attribution is, the three commands, sidecar attributions[], link to docs/MANUAL-SMOKE-F3.md, and the test commands (npx vitest run, npm run test:e2e, npm run smoke:live).
  • Step 8.3: Push branch (SSH remote, push before PR per convention): git push -u origin feat/f3-live-attribution.
  • Step 8.4: PR to main: title F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6), body citing spec coauthoring-attribution.md §7.2 SLICE-1..6 + acceptance (#6), and Fixes #6 if the tracker convention closes features on merge (F2's PR #5 precedent — mirror it). Merge (autonomous posture).
  • Step 8.5: Verify main green post-merge (git pull + Step 8.1 gate), then hand off to wgl-session-finalize (archives this plan to the content repo plans/, publishes the transcript, updates memory).

Self-review notes (spec → task traceability)

Spec requirement Task
SLICE-1 typed attributions[] + round-trip (§6.3) Task 1
SLICE-2 span algebra + exhaustive units (INV-7) Task 2
SLICE-3 seam + registry + human wiring + decorations/toggle (INV-9, PUC-1/2/3/5) Tasks 34
SLICE-4 save/reload/external-change/orphan (INV-1/INV-6, PUC-4) Task 4 (code) + Task 5 (unit coverage) + Task 7 (E2E)
SLICE-5 live turn + manual smoke + graceful failure (INV-8) Task 6
SLICE-6 host E2E, no LLM in CI (§6.8) Task 7
Sidecar co-ownership (Thread + Attribution writers) Task 3 (store.update)
schemaVersion stays 1; unattributed = absence of span Task 1 / by construction
Status-bar orphan count + output channel (§5 UX) Task 4