Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-10-f3-live-attribution.md
T
2026-06-10 11:09:59 -07:00

1509 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `coalesce`s (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 `modelId`s 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.ts``out/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`.
3. 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).
4. If `newLength > 0`, the inserted range `[start, start+newLength)` becomes a **new span** authored by the edit's author (fresh id, `createdAt = now`).
5. 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`:
```typescript
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:
```typescript
/** 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:
```typescript
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: Commit**`git 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`:**
```typescript
/**
* 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:
```typescript
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: Commit**`F3 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`:**
```typescript
/**
* 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`:**
```typescript
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`):
```typescript
/**
* 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[]`:
```typescript
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):
```typescript
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: Commit**`F3 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`:**
```typescript
/**
* 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):
```typescript
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:
```typescript
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:
```typescript
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`:
```json
{ "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`:
```json
"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: Commit**`F3 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`:**
```typescript
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: Commit**`F3 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`:**
```typescript
/**
* 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):
```typescript
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:
```typescript
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:
```json
{ "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:
```javascript
/** @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`:**
```javascript
#!/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`:**
```markdown
# 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: Commit**`F3 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`:
```markdown
# 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`:**
```typescript
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: Commit**`F3 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 |