diff --git a/README.md b/README.md index ffb372d..cc8c202 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Registers one command, **`Cowriting: Show Cline SDK Info`**, which loads catalog (a pure, key-free SDK call) in a notification and the "Cowriting (Cline SDK)" output channel. +Features shipped so far: F2 region-anchored threads (Feature #4), F3 live +human/Claude attribution (Feature #6). + ## Architecture - CommonJS extension bundled with esbuild (`src/extension.ts` → `out/extension.cjs`). @@ -43,10 +46,40 @@ sidecars under `.threads/.json` (plain, diffable JSON — no server). Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No live `@cline/sdk` turn and no credentials are involved in F2. +## F3 — Live human/Claude attribution (Feature #6) + +As you and Claude coauthor, every span in the buffer carries an author: human +edits render with a subtle left border, Claude-authored spans with a background +tint. Text that predates tracking stays plain — the honest record. Edits that +touch a boundary (split, merge, partial overwrite) are handled char-precisely. + +Attribution is persisted git-natively in the same `.threads/` sidecars +(`attributions[]`, sharing the same `anchors` fingerprints as F2 threads). On +reload, fingerprints re-resolve spans against the current document; spans that +can't be confidently re-found are **orphaned** (status-bar count + "Cowriting +Attribution" output channel) rather than silently moved or discarded. + +**Commands** + +- **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an + instruction → a live `@cline/sdk` turn runs on the built-in `claude-code` + provider (rides your local Claude Code Pro/Max login; the extension stores no + credentials). The replacement lands in the buffer as a Claude-attributed span. +- **`Cowriting: Toggle Attribution`** — show/hide attribution decorations. +- `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit + ingress seam. Tests drive this directly so CI requires no LLM. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`. + ## Develop - `npm run watch` — rebuild on change. -- `npm test` — vitest unit suite (SDK driver, schema, store, anchorer, thread mutations). +- `npx vitest run` — unit suite (SDK driver, schema, store, anchorer, thread + mutations, attribution split/merge). - `npm run test:e2e` — `@vscode/test-electron` host E2E - (create → reply → resolve → persist → reload → re-anchor → orphan). + (create → reply → resolve → persist → reload → re-anchor → orphan; drives + `cowriting.applyAgentEdit` directly — no LLM required). +- `npm run smoke:live` — scripted live-turn smoke test for F3; requires Claude + Code installed and signed in. See + [`docs/MANUAL-SMOKE-F3.md`](docs/MANUAL-SMOKE-F3.md). - `npm run typecheck` — type-check without emit. diff --git a/docs/MANUAL-SMOKE-F3.md b/docs/MANUAL-SMOKE-F3.md new file mode 100644 index 0000000..fe82cfe --- /dev/null +++ b/docs/MANUAL-SMOKE-F3.md @@ -0,0 +1,48 @@ +# 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/.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: hard to simulate on a machine where Claude + Code is installed — neither stripping `PATH` nor hiding the + `~/.local/bin/claude` symlink works (the SDK discovers the real install + under `~/.local/share/claude/versions/…`). The only true trigger is being + signed out (or having no installation at all), so exercise this on a + signed-out machine when one is available. Expected: the script exits 1 + with a clear error (`runEditTurn` throws on any non-`completed` run + status); 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 | +| --- | --- | --- | +| 2026-06-10 | benstull mac (darwin) | PASS — `replacement: "The smoke test passed."`, model `sonnet`, sessionId `run_g_rYKGYl`, 6.9s, exit 0. Failure-path notes: turn still succeeded with `PATH="/usr/bin:/bin"` (6.2s) AND with `~/.local/bin/claude` renamed away (3.7s, `run_TQwQipJx`) — the SDK discovers the real install under `~/.local/share/claude/versions/…`, so only a genuine sign-out (not attempted from inside a live session) exercises the error path. | diff --git a/docs/superpowers/plans/2026-06-10-f3-live-attribution.md b/docs/superpowers/plans/2026-06-10-f3-live-attribution.md new file mode 100644 index 0000000..49eab48 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-f3-live-attribution.md @@ -0,0 +1,1508 @@ +# 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` 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) `. + +--- + +### 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([ + ...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 (post–Task-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; +} + +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(); + 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(); + + 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("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 { + 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(); + 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 { + 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( + `\n${instruction}\n\n\n${selectedText}\n`, + ); + 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/.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 (post–Task-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 { + 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 { + 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 { + 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 3–4 | +| 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 | + + + diff --git a/esbuild.mjs b/esbuild.mjs index f427684..35be90b 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -9,7 +9,7 @@ const options = { bundle: true, platform: "node", format: "cjs", - target: "node20", + target: "node22", sourcemap: true, // vscode is provided by the host; @cline/sdk is ESM-only and is loaded at // runtime via dynamic import() from node_modules, so keep both external. @@ -17,11 +17,26 @@ const options = { logLevel: "info", }; +/** @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); - await ctx.watch(); + const ctxLive = await context(liveTurnOptions); + await Promise.all([ctx.watch(), ctxLive.watch()]); console.log("esbuild: watching…"); } else { await build(options); - console.log("esbuild: build complete → out/extension.cjs"); + await build(liveTurnOptions); + console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs"); } diff --git a/package.json b/package.json index 81d8865..9302ef2 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,15 @@ }, { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" }, { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, - { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" } + { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }, + { "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" }, + { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }, + { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" } ], "menus": { + "commandPalette": [ + { "command": "cowriting.applyAgentEdit", "when": "false" } + ], "comments/commentThread/context": [ { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" } ], @@ -54,6 +60,7 @@ "test": "vitest run", "pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json", "test:e2e": "node ./out/test/e2e/runTest.js", + "smoke:live": "npm run build && node scripts/smoke-live-turn.mjs", "vscode:prepublish": "node esbuild.mjs" }, "dependencies": { diff --git a/scripts/smoke-live-turn.mjs b/scripts/smoke-live-turn.mjs new file mode 100644 index 0000000..1a5f17d --- /dev/null +++ b/scripts/smoke-live-turn.mjs @@ -0,0 +1,22 @@ +#!/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 instanceof Error ? err.message : String(err)}`); + process.exit(1); +} diff --git a/src/attributionController.ts b/src/attributionController.ts new file mode 100644 index 0000000..245d5e2 --- /dev/null +++ b/src/attributionController.ts @@ -0,0 +1,370 @@ +/** + * 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. Sidecar self-write suppression and the shared + * FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is + * co-owned with ThreadController). + */ +import * as fs from "node:fs"; +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 { minimizeReplace, 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; + /** + * true once this doc has had any span/record this session — lets save persist + * deliberate deletion to empty (so stale records don't come back as phantom + * orphans on reload). + */ + hadAttributions: boolean; +} + +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(); + 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; + + 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()), + ); + } + + 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("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(), hadAttributions: false }; + 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); + if (artifact.attributions.length > 0) s.hadAttributions = true; + } + this.render(document); + } + + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { + for (const s of this.docs.values()) { + if (this.store.sidecarPath(s.docPath) === uri.fsPath) { + const doc = vscode.workspace.textDocuments.find((d) => this.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 && this.matchesDisk(e.document)) { + // Disk sync (revert / external reload): buffer now equals the file on + // disk — re-resolve, never attribute (PUC-4). A real edit can also + // arrive with isDirty still false (VS Code flips the flag after the + // change event), but then the buffer no longer matches the disk. + this.loadAll(e.document); + return; + } + const s = this.state(docPath); + // Sort descending by offset so earlier changes don't invalidate later offsets + // (VS Code's order is undocumented; defensive sort is the safe guarantee). + for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) { + 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(); + // On a seam hit, attribute the agent's FULL intended replacement (the + // registered edit is diff-minimized for transport only): same delta, + // wider span — the agent owns every char it asserted (INV-9). + s.spans = applyChange(s.spans, hit?.full ?? edit, author, { + newId: () => newId("at"), + now: () => new Date().toISOString(), + turnId: hit?.turnId, + }); + } + if (s.spans.length > 0) s.hadAttributions = true; + this.render(e.document); + } + + /** + * True when the document buffer is byte-identical to the file on disk. + * Fires only on `!isDirty` change events — first change after every clean + * state (frequent under `files.autoSave: afterDelay`) and reverts/reloads; + * O(file size) sync read. A size pre-check avoids the full read in the + * common case: if the byte lengths differ (accounting for a possible 3-byte + * UTF-8 BOM that `document.getText()` never includes) we return early. When + * a BOM is present the disk read is stripped before comparing so a genuine + * revert does not clobber attribution. + */ + private matchesDisk(document: vscode.TextDocument): boolean { + try { + const bufLen = Buffer.byteLength(document.getText(), "utf8"); + const stat = fs.statSync(document.uri.fsPath); + // Allow size === bufLen (no BOM) OR size === bufLen + 3 (UTF-8 BOM). + if (stat.size !== bufLen && stat.size !== bufLen + 3) return false; + const disk = fs.readFileSync(document.uri.fsPath, "utf8").replace(/^/, ""); + return disk === document.getText(); + } catch { + // Unreadable/missing file: treat as a real edit (attribute), per + // fail-open honesty — misclassifying a sync as an edit is recoverable. + return false; + } + } + + // ---- 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). + * The pending edit is unregistered unconditionally afterwards (a no-op when + * `match` already consumed it), so a no-op edit never leaks a registration. + * The replace is self-minimized (common prefix/suffix trimmed) to mirror the + * host's WorkspaceEdit diff-minimization, so registered == applied == delivered. + * Callers issuing concurrent edits on the same document must serialize them or + * pass `expectedVersion` (offsets are computed against the call-time snapshot). + */ + async applyAgentEdit( + document: vscode.TextDocument, + range: vscode.Range, + newText: string, + provenance: Provenance, + opts?: { expectedVersion?: number; turnId?: string }, + ): Promise { + if (!this.isTracked(document)) return false; + if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; + const docPath = this.docPathOf(document.uri); + const startOffset = document.offsetAt(range.start); + const endOffset = document.offsetAt(range.end); + const oldText = document.getText(range); + const { prefix, suffix } = minimizeReplace(oldText, newText); + const minStart = startOffset + prefix; + const minEnd = endOffset - suffix; + const minText = newText.slice(prefix, newText.length - suffix); + if (minStart === minEnd && minText.length === 0) { + // Replacement equals the existing text — a no-op; nothing to attribute. + return true; + } + const pendingEdit = { + docPath, + start: minStart, + end: minEnd, + newText: minText, + provenance, + turnId: opts?.turnId, + full: { start: startOffset, end: endOffset, newLength: newText.length }, + }; + this.pending.register(pendingEdit); + const we = new vscode.WorkspaceEdit(); + we.replace(document.uri, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText); + const ok = await vscode.workspace.applyEdit(we); + const removed = this.pending.unregister(pendingEdit); + if (ok && removed) { + // workspace.applyEdit resolves AFTER onDidChangeTextDocument is dispatched + // synchronously to listeners, so a matching change event should have already + // consumed the registration before we reach here. If it is still present, + // the host minimized the diff differently than we predicted — attribution + // may be wrong for this edit (INV-9). + this.output.appendLine( + "WARN: seam edit applied but its change event never matched the registration " + + "(host minimized differently?) — the edit may be mis-attributed (INV-9).", + ); + } + 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); + // Allow save when hadAttributions is true even if spans/orphans are now empty: + // that means the user deliberately deleted all attributed text, and we must + // persist a.attributions=[] so stale records don't return as phantom orphans. + if (!s || (!s.hadAttributions && 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(); + 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.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 { + if (!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)); + const agentRanges = spans.filter((x) => x.author.kind === "agent").map(toRange); + const humanRanges = spans.filter((x) => x.author.kind === "human").map(toRange); + // Apply decorations to ALL visible split-editors showing this document, not + // just the first match — each editor pane has its own decoration layer. + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document === document) { + editor.setDecorations(this.agentType, agentRanges); + editor.setDecorations(this.humanType, humanRanges); + } + } + if (document === vscode.window.activeTextEditor?.document) { + 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(); + } +} diff --git a/src/attributionTracker.ts b/src/attributionTracker.ts new file mode 100644 index 0000000..0b95a23 --- /dev/null +++ b/src/attributionTracker.ts @@ -0,0 +1,85 @@ +/** + * 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); +} diff --git a/src/extension.ts b/src/extension.ts index 8671a27..b3b5ede 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,13 @@ 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; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -41,14 +43,120 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const threadController = new ThreadController(store, root); context.subscriptions.push(threadController); - // Render threads for already-open editors, and on future opens. + // --- F3: live attribution (Feature #6) --- + const attributionController = new AttributionController(store, root); + context.subscriptions.push(attributionController); + + // One SHARED sidecar watcher for both controllers; self-writes are + // suppressed centrally in the store (the sidecar is co-owned). + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => { + if (store.consumeSelfWrite(uri.fsPath)) return; + threadController.handleExternalSidecarChange(uri); + attributionController.handleExternalSidecarChange(uri); + }; + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + context.subscriptions.push(watcher); + + // The seam as a command (INV-9): the only machine-edit ingress, hidden from + // the palette — for the host E2E harness and future SDK turn plumbing. + 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 }); + }, + ), + ); + + // F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one + // claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.editSelection", async () => { + const editor = vscode.window.activeTextEditor; + if ( + !editor || + editor.selection.isEmpty || + editor.document.uri.scheme !== "file" || + !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; + if (editor.selection.isEmpty) { + void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first."); + 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); + if (turn.replacement === "") { + void vscode.window.showWarningMessage( + "Cowriting: Claude returned an empty replacement — nothing was applied.", + ); + return; + } + 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 edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.", + ); + } + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); + } + }), + ); + + // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { - if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc); + 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 }; + return { threadController, attributionController }; } export function deactivate(): void { diff --git a/src/liveTurn.ts b/src/liveTurn.ts new file mode 100644 index 0000000..9a4d7ed --- /dev/null +++ b/src/liveTurn.ts @@ -0,0 +1,64 @@ +/** + * 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. + * If `selectedText` itself was fence-wrapped (i.e. the user's selection was a + * fenced block), the model mirroring that format is correct — return + * `outputText` unchanged so the fence is preserved. + */ +export function extractReplacement(outputText: string, selectedText: string): string { + const fencePattern = /^\s*```[^\n]*\n[\s\S]*?\n?```\s*$/; + if (fencePattern.test(selectedText)) return outputText; + 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 { + 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( + `\n${instruction}\n\n\n${selectedText}\n`, + ); + // The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed" + // (@cline/shared agent.d.ts) — "completed" is the success status. + if (result.status !== "completed") { + 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, selectedText), model: modelId, sessionId: result.runId }; +} diff --git a/src/model.ts b/src/model.ts index c1113de..968578b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -47,6 +47,20 @@ export interface Thread { messages: Message[]; } +/** 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; +} + export interface Artifact { schemaVersion: number; /** repo-relative path; the sidecar key. */ @@ -54,8 +68,8 @@ export interface Artifact { /** SHARED primitive (INV-4). */ anchors: Record; threads: Thread[]; - /** F3 extension point — reuses anchors[]; not implemented in F2. */ - attributions: unknown[]; + /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ + attributions: AttributionRecord[]; /** F4 extension point — reuses anchors[] + provenance; not in F2. */ proposals: unknown[]; } @@ -117,7 +131,14 @@ export function serializeArtifact(a: Artifact): string { createdAt: m.createdAt, })), })), - attributions: a.attributions, + 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 } : {}), + })), proposals: a.proposals, }; return JSON.stringify(canonical, null, 2) + "\n"; diff --git a/src/pendingEdits.ts b/src/pendingEdits.ts new file mode 100644 index 0000000..5760eb8 --- /dev/null +++ b/src/pendingEdits.ts @@ -0,0 +1,85 @@ +/** + * 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"; + +/** + * Greedily trim the common prefix then common suffix between the replaced + * text and its replacement (non-overlapping), mirroring the host's own + * WorkspaceEdit diff-minimization so a registered seam edit matches the + * change event VS Code actually delivers (INV-9). + */ +export function minimizeReplace( + oldText: string, + newText: string, +): { prefix: number; suffix: number } { + const max = Math.min(oldText.length, newText.length); + let prefix = 0; + while (prefix < max && oldText[prefix] === newText[prefix]) prefix++; + let suffix = 0; + while ( + suffix < max - prefix && + oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix] + ) suffix++; + return { prefix, suffix }; +} + +export interface PendingEdit { + docPath: string; + /** half-open [start, end) offsets of the replaced range. */ + start: number; + end: number; + newText: string; + provenance: Provenance; + turnId?: string; + /** + * The agent's full intended replacement before self-minimization (pre-edit + * half-open range + replacement length). The minimized start/end/newText are + * transport-only (so the registration matches the host-delivered change); + * attribution uses this extent — the agent owns every char it asserted, + * including chars the minimized diff left unchanged. + */ + full?: { start: number; end: number; newLength: number }; +} + +export class PendingEditRegistry { + private pending: PendingEdit[] = []; + + register(edit: PendingEdit): void { + this.pending.push(edit); + } + + /** + * Remove a registration that failed to apply. Identity-based (`===`), so + * callers must keep the exact registered object. Returns `true` if the + * registration was still pending and was removed, `false` if it was already + * consumed by `match` (or was never registered). This lets callers detect + * seam misses for diagnosability (INV-9). + */ + unregister(edit: PendingEdit): boolean { + const before = this.pending.length; + this.pending = this.pending.filter((p) => p !== edit); + return this.pending.length < before; + } + + /** + * 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; + } +} diff --git a/src/store.ts b/src/store.ts index f96acd3..53c9a21 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,17 +1,45 @@ /** * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4). * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only), - * so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on - * external change is wired in the vscode layer (ThreadController, SLICE-4). + * so it is unit-testable; the SHARED FileSystemWatcher that triggers + * re-anchoring on external change is wired in the vscode layer (extension.ts), + * with self-write suppression centralized here because two controllers + * (ThreadController, AttributionController) co-own the sidecar (F3 §6.2). */ import * as fs from "node:fs"; import * as path from "node:path"; -import { serializeArtifact, type Artifact } from "./model"; +import { emptyArtifact, serializeArtifact, type Artifact } from "./model"; export class CoauthorStore { + /** + * Pending-write counts keyed by absolute sidecar path. A Map (not Set) because + * one doc save can trigger TWO store.update() calls (ThreadController then + * AttributionController), both writing the same sidecar — a Set would let the + * second watcher event leak through as "external" (comment-UI flicker). + */ + private readonly selfWrites = new Map(); + /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ constructor(private readonly rootDir: string) {} + /** + * Decrement-and-report: true iff `fsPath` was a sidecar this store just + * wrote via `update`. Each successful `update` increments the counter once; + * each call here decrements it (deleting the key at zero). The shared watcher + * (extension.ts) calls this to suppress self-write events before fanning out + * to the controllers. + */ + consumeSelfWrite(fsPath: string): boolean { + const count = this.selfWrites.get(fsPath); + if (!count) return false; + if (count === 1) { + this.selfWrites.delete(fsPath); + } else { + this.selfWrites.set(fsPath, count - 1); + } + return true; + } + /** `.threads/.json` (spec §6.3). */ sidecarPath(docPath: string): string { return path.join(this.rootDir, ".threads", `${docPath}.json`); @@ -28,4 +56,31 @@ export class CoauthorStore { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); } + + /** + * 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). `mutate` MUST be synchronous: the + * read-modify-write (and the self-write mark) completes within this call. + */ + update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { + const artifact = this.load(docPath) ?? emptyArtifact(docPath); + mutate(artifact); + const referenced = new Set([ + ...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); + // Increment AFTER the synchronous write: fs.writeFileSync completes before + // this line executes, and watcher events are delivered async, so marking + // post-write is race-free for suppression purposes. + const p = this.sidecarPath(docPath); + this.selfWrites.set(p, (this.selfWrites.get(p) ?? 0) + 1); + return artifact; + } } diff --git a/src/threadController.ts b/src/threadController.ts index 0631440..d0cf898 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -1,9 +1,11 @@ /** * ThreadController — the thin editor-facing layer (spec §6.4). Wires * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on- - * selection, reply, resolve, render, live-range tracking, reload, and - * FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER - * silently moved — an unresolvable anchor renders as an orphaned thread (INV-1). + * selection, reply, resolve, render, live-range tracking, reload, and external + * re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts + * via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore). + * Threads are NEVER silently moved — an unresolvable anchor renders as an + * orphaned thread (INV-1). */ import * as vscode from "vscode"; import { CoauthorStore } from "./store"; @@ -35,8 +37,6 @@ export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath - /** sidecar paths we just wrote, to ignore our own watcher events. */ - private readonly selfWrites = new Set(); constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); @@ -60,13 +60,6 @@ export class ThreadController implements vscode.Disposable { vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), ); - - // Re-anchor on external change to any sidecar (a git pull, a manual edit). - const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); - const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri); - watcher.onDidChange(onSidecar); - watcher.onDidCreate(onSidecar); - this.disposables.push(watcher); } private isInRoot(uri: vscode.Uri): boolean { @@ -83,8 +76,12 @@ export class ThreadController implements vscode.Disposable { } private persist(state: DocState): void { - this.selfWrites.add(this.store.sidecarPath(state.docPath)); - this.store.save(state.docPath, state.artifact); + 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]; + } + }); } private ensureState(document: vscode.TextDocument): DocState { @@ -169,12 +166,9 @@ export class ThreadController implements vscode.Disposable { } } - private onExternalSidecarChange(uri: vscode.Uri): void { + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { const p = uri.fsPath; - if (this.selfWrites.has(p)) { - this.selfWrites.delete(p); - return; // our own write - } for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === p) { const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); diff --git a/test/attributionPersistence.test.ts b/test/attributionPersistence.test.ts new file mode 100644 index 0000000..76670e1 --- /dev/null +++ b/test/attributionPersistence.test.ts @@ -0,0 +1,54 @@ +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 }); + }); +}); diff --git a/test/attributionTracker.test.ts b/test/attributionTracker.test.ts new file mode 100644 index 0000000..de0c9e8 --- /dev/null +++ b/test/attributionTracker.test.ts @@ -0,0 +1,153 @@ +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([]); + }); +}); + +describe("edge probes (post-review)", () => { + it("applyChange does not mutate its input array or spans", () => { + const input = [span("a", 0, 10, AGENT)]; + const snapshot = JSON.parse(JSON.stringify(input)); + applyChange(input, { start: 2, end: 4, newLength: 1 }, HUMAN, ctx()); + expect(input).toEqual(snapshot); + }); + it("an edit swallowing a whole middle span across three spans removes only it", () => { + // Deletion [3,9): a → [0,3); b swallowed; c's remainder [9,12) shifts -6 to + // [3,6). The two surviving HUMAN spans are adjacent at 3 with the same + // (undefined) turnId, so coalesce merges them into ONE span — only the + // agent-authored middle span's characters are gone (INV-6/INV-7). + const r = applyChange( + [span("a", 0, 4, HUMAN), span("b", 4, 8, AGENT), span("c", 8, 12, HUMAN)], + { start: 3, end: 9, newLength: 0 }, HUMAN, ctx(), + ); + expect(ranges(r)).toEqual([[0, 6]]); + expect(authors(r)).toEqual(["human"]); + }); + it("a replacement exactly covering a span replaces it with the editor's span", () => { + const r = applyChange([span("a", 4, 8, AGENT)], { start: 4, end: 8, newLength: 4 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[4, 8]]); + expect(authors(r)).toEqual(["human"]); + }); +}); diff --git a/test/e2e/fixtures/workspace/docs/attrib.md b/test/e2e/fixtures/workspace/docs/attrib.md new file mode 100644 index 0000000..63bd9cc --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/attrib.md @@ -0,0 +1,5 @@ +# Attribution fixture + +A stable first paragraph that predates tracking. + +The target sentence lives here for seam edits. diff --git a/test/e2e/suite/attribution.test.ts b/test/e2e/suite/attribution.test.ts new file mode 100644 index 0000000..5bc615d --- /dev/null +++ b/test/e2e/suite/attribution.test.ts @@ -0,0 +1,162 @@ +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 { + 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 { + 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 { + 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)); + +// Tests are ORDER-DEPENDENT: later tests consume earlier tests' document/sidecar +// state by design, mirroring the F2 suite. This suite owns docs/attrib.md; +// the threads suite owns docs/sample.md — keep fixtures disjoint. +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)"); + }); + + test("the applyAgentEdit command wrapper and the toggle command work end-to-end", async () => { + const doc = await openDoc(); + const api = await getApi(); + const anchor = "stable first paragraph"; + const start = doc.getText().indexOf(anchor); + const ok = await vscode.commands.executeCommand("cowriting.applyAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + anchor.length, + newText: "stable COMMAND-EDITED paragraph", + model: "sonnet", + sessionId: "e2e-cmd", + turnId: "turn-e2e-cmd", + }); + assert.strictEqual(ok, true, "command wrapper applies via the seam"); + await settle(); + const spans = api.attributionController.getSpans(DOC_REL); + const agent = spans.find((s) => s.turnId === "turn-e2e-cmd"); + assert.ok(agent, "command-driven agent span exists"); + assert.strictEqual(agent!.authorKind, "agent"); + + assert.strictEqual(api.attributionController.isVisible(), true); + await vscode.commands.executeCommand("cowriting.toggleAttribution"); + assert.strictEqual(api.attributionController.isVisible(), false, "toggle hides (PUC-5)"); + await vscode.commands.executeCommand("cowriting.toggleAttribution"); + assert.strictEqual(api.attributionController.isVisible(), true, "toggle restores"); + }); +}); diff --git a/test/liveTurn.test.ts b/test/liveTurn.test.ts new file mode 100644 index 0000000..da8596e --- /dev/null +++ b/test/liveTurn.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { extractReplacement } from "../src/liveTurn"; + +describe("extractReplacement", () => { + it("returns plain text untouched", () => { + expect(extractReplacement("Hello world.", "x")).toBe("Hello world."); + }); + it("strips a wrapping fence", () => { + expect(extractReplacement("```\nHello world.\n```", "x")).toBe("Hello world."); + }); + it("strips a language-tagged fence", () => { + expect(extractReplacement("```markdown\nHello.\n```\n", "x")).toBe("Hello."); + }); + it("leaves inner fences alone", () => { + const inner = "before\n```js\ncode\n```\nafter"; + expect(extractReplacement(inner, "x")).toBe(inner); + }); + it("keeps the fence when the original selection was itself fence-wrapped", () => { + const sel = "```python\nold()\n```"; + const out = "```python\nnew()\n```"; + expect(extractReplacement(out, sel)).toBe(out); + }); + it("still strips when the selection was plain but the model added a fence", () => { + expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain"); + }); +}); diff --git a/test/model.test.ts b/test/model.test.ts index 53d5805..c0bea25 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -56,3 +56,35 @@ describe("serializeArtifact", () => { expect(e.proposals).toEqual([]); }); }); + +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); + }); +}); diff --git a/test/pendingEdits.test.ts b/test/pendingEdits.test.ts new file mode 100644 index 0000000..81d241d --- /dev/null +++ b/test/pendingEdits.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { minimizeReplace, 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(); + }); + it("unregister reports whether the registration was still pending", () => { + const reg = new PendingEditRegistry(); + const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT }; + reg.register(p); + expect(reg.unregister(p)).toBe(true); + reg.register(p); + reg.match("d.md", { start: 0, end: 0, text: "x" }); + expect(reg.unregister(p)).toBe(false); + }); +}); + +describe("minimizeReplace (host diff-minimization mirror)", () => { + it("trims a common suffix (the E2E-observed case)", () => { + expect(minimizeReplace("target sentence", "REWRITTEN-BY-CLAUDE sentence")).toEqual({ prefix: 0, suffix: 9 }); + }); + it("trims a common prefix", () => { + expect(minimizeReplace("Hello world", "Hello there")).toEqual({ prefix: 6, suffix: 0 }); + }); + it("trims both, never overlapping", () => { + expect(minimizeReplace("aba", "aa")).toEqual({ prefix: 1, suffix: 1 }); + expect(minimizeReplace("aaaa", "aa")).toEqual({ prefix: 2, suffix: 0 }); + }); + it("identical texts minimize to nothing", () => { + expect(minimizeReplace("same", "same")).toEqual({ prefix: 4, suffix: 0 }); + }); + it("disjoint texts trim nothing", () => { + expect(minimizeReplace("abc", "xyz")).toEqual({ prefix: 0, suffix: 0 }); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 20eb736..9cc8448 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -54,4 +54,56 @@ describe("CoauthorStore", () => { expect(second).toBe(first); expect(first).toBe(serializeArtifact(a)); }); + + it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => { + const store = new CoauthorStore(root); + 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(root); + 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([]); + }); + + it("consumeSelfWrite counts multiple writes to the same sidecar (double-save suppression)", () => { + const store = new CoauthorStore(root); + 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.attributions.push({ + id: "at1", anchorId: "a_t", author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", + }); + }); + const p = store.sidecarPath("d.md"); + expect(store.consumeSelfWrite(p)).toBe(true); + expect(store.consumeSelfWrite(p)).toBe(true); + expect(store.consumeSelfWrite(p)).toBe(false); + }); });