Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-11-f9-authorship-preview.md
2026-06-11 14:34:04 -07:00

30 KiB
Raw Permalink Blame History

F9 Authorship Preview 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: Add an Authorship mode to the F7 rendered preview — a header toggle that re-renders the current markdown doc with each span colored by its F3 author (Claude blue / human green), inline and char-precise — so a writer can see what Claude composed even after the F6 baseline absorbs it.

Architecture: A new pure render engine renderAuthorship(currentText, authorSpans) in src/trackChangesModel.ts (vscode-free, unit-tested) injects Private-Use-Area sentinels at attribution-span boundaries through markdown-it, then post-processes them into <span class="cw-by-claude|cw-by-human">; code/mermaid fences stay atomic with a block-level author badge. AttributionController.spansFor(document) supplies the spans (resolving the URI-vs-keyOf key mismatch). TrackChangesPreviewController gains a per-panel mode and an attribution dependency; the webview header gets a segmented [ Track changes | Authorship ] toggle that posts setMode back.

Tech Stack: TypeScript, VS Code webview API, markdown-it, vitest (unit), @vscode/test-electron + mocha (host E2E). Design: docs/superpowers/specs/2026-06-11-authorship-preview-design.md.

Conventions (read first):

  • The render engine is vscode-free and deterministic (INV-22/28) — spans are passed as data, tested under vitest like test/trackChangesModel.test.ts.
  • Avoid */ inside block comments (an earlier F8 hiccup: a **/ glob closed a comment early). Keep glob examples out of JSDoc.
  • Attribution offsets are char offsets into document.getText() — the same string passed to the renderer as currentText, so they align.
  • Run unit: npm test. Typecheck: npm run typecheck. Build: npm run build. Host E2E: npm run test:e2e.
  • After each task: npm test green + npm run typecheck clean, then commit.

File Structure

Modify:

  • src/trackChangesModel.ts — add AuthorKind/AuthorSpan, splitBlocksWithRanges, renderAuthorship.
  • src/attributionController.ts — add spansFor(document): AuthorSpan[].
  • src/trackChangesPreview.ts — attribution dependency; per-panel mode; setMode handling; render branch; post mode/legend.
  • src/extension.ts — reorder so attribution exists before the F7 controller; pass it in.
  • media/preview.ts — segmented toggle + acquireVsCodeApi postMessage; legend vs summary per mode.
  • media/preview.css.cw-by-claude / .cw-by-human, toggle, legend, fence-badge styles.
  • README.md — F9 note.

Create:

  • test/e2e/suite/authorship.test.ts — host E2E.
  • docs/MANUAL-SMOKE-F9.md — manual smoke runbook.

Untouched: renderTrackChanges/diffBlocks behavior (track-changes mode), F6 baseline, F3 capture, the seam, persistence.


Task 1: splitBlocksWithRanges (block offsets)

Files:

  • Modify: src/trackChangesModel.ts

  • Test: test/trackChangesModel.test.ts

  • Step 1: Write the failing test (append to test/trackChangesModel.test.ts)

import { splitBlocksWithRanges } from "../src/trackChangesModel";

describe("splitBlocksWithRanges — block offsets align with the source string", () => {
  it("each block's [start,end) slices back to its raw from the source", () => {
    const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n";
    const blocks = splitBlocksWithRanges(text);
    expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]);
    for (const b of blocks) {
      expect(text.slice(b.start, b.end)).toBe(b.raw);
    }
    expect(blocks[1].raw).toBe("A prose paragraph.");
  });

  it("marks a mermaid fence and preserves its source offsets", () => {
    const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n";
    const blocks = splitBlocksWithRanges(text);
    expect(blocks[1].type).toBe("mermaid");
    expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw);
  });
});
  • Step 2: Run to verify it fails

Run: npm test -- trackChangesModel Expected: FAIL (splitBlocksWithRanges is not exported).

  • Step 3: Implement splitBlocksWithRanges in src/trackChangesModel.ts

Add after splitBlocks:

export interface BlockWithRange extends Block {
  /** char offset of the block's first char in the source string. */
  start: number;
  /** char offset one past the block's last char (source.slice(start,end) === raw). */
  end: number;
}

/**
 * Like splitBlocks, but each block carries its [start,end) char range in the
 * SOURCE string (offsets align with document.getText(), so F3 attribution
 * offsets map directly). Lines are tracked with their true source offsets
 * (a trailing CR stays in the line, so \r\n sources keep correct offsets).
 */
export function splitBlocksWithRanges(text: string): BlockWithRange[] {
  // line raw (excluding the \n terminator) + its source start offset.
  const lines: { raw: string; start: number }[] = [];
  let from = 0;
  for (let i = 0; i <= text.length; i++) {
    if (i === text.length || text[i] === "\n") {
      lines.push({ raw: text.slice(from, i), start: from });
      from = i + 1;
      if (i === text.length) break;
    }
  }
  const out: BlockWithRange[] = [];
  const range = (lo: number, hi: number): { start: number; end: number } => {
    const start = lines[lo].start;
    const end = lines[hi].start + lines[hi].raw.length;
    return { start, end };
  };
  let buf: number[] = []; // indices of buffered prose lines
  const flushProse = () => {
    if (buf.length) {
      const { start, end } = range(buf[0], buf[buf.length - 1]);
      const raw = text.slice(start, end);
      if (raw.trim()) out.push({ ...makeBlockRange(raw, "prose"), start, end });
    }
    buf = [];
  };
  let i = 0;
  while (i < lines.length) {
    const line = lines[i].raw;
    const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
    if (fence) {
      flushProse();
      const marker = fence[2][0];
      const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
      const open = i;
      i++;
      while (i < lines.length) {
        const closed = lines[i].raw.trim().startsWith(marker.repeat(3));
        i++;
        if (closed) break;
      }
      const close = i - 1;
      const { start, end } = range(open, close);
      const raw = text.slice(start, end);
      out.push({ ...makeBlockRange(raw, info === "mermaid" ? "mermaid" : "code"), start, end });
      continue;
    }
    if (line.trim() === "") {
      flushProse();
      i++;
      continue;
    }
    buf.push(i);
    i++;
  }
  flushProse();
  return out;
}

function makeBlockRange(raw: string, type: BlockType): Block {
  return makeBlock(raw, type);
}

(makeBlockRange is a thin alias so the spread { ...makeBlockRange(...), start, end } reads clearly; it reuses the existing makeBlock.)

  • Step 4: Run to verify it passes

Run: npm test -- trackChangesModel Expected: PASS.

  • Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f9): splitBlocksWithRanges — block offsets aligned to the source"

Task 2: renderAuthorship — inline spans + atomic fences

Files:

  • Modify: src/trackChangesModel.ts

  • Test: test/trackChangesModel.test.ts

  • Step 1: Write the failing tests (append)

import { renderAuthorship, type AuthorSpan } from "../src/trackChangesModel";

const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => {
  const start = text.indexOf(sub);
  return { start, end: start + sub.length, author };
};

describe("renderAuthorship", () => {
  it("empty spans → plain render (no author wrappers)", () => {
    const text = "# Hi\n\nplain paragraph.\n";
    const html = renderAuthorship(text, []);
    expect(html).not.toContain("cw-by-claude");
    expect(html).not.toContain("cw-by-human");
    expect(html).toContain("plain paragraph.");
  });

  it("wraps a single Claude span inline", () => {
    const text = "The cat sat on the mat.\n";
    const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]);
    expect(html).toContain('<span class="cw-by-claude">cat sat</span>');
  });

  it("marks two authors in one paragraph at exact boundaries", () => {
    const text = "Alpha beta gamma.\n";
    const html = renderAuthorship(text, [
      spanAt(text, "Alpha", "human"),
      spanAt(text, "gamma", "claude"),
    ]);
    expect(html).toContain('<span class="cw-by-human">Alpha</span>');
    expect(html).toContain('<span class="cw-by-claude">gamma</span>');
  });

  it("clips a span to its block (does not bleed across blocks)", () => {
    const text = "Para one.\n\nPara two.\n";
    // a span covering the whole text; each block wraps only its own slice
    const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
    expect(html).toContain('<span class="cw-by-claude">Para one.</span>');
    expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
  });

  it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => {
    const text = "```js\nconst x = 1;\n```\n";
    const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
    expect(html).toContain("cw-by-claude");
    expect(html).toContain("cw-badge");
    expect(html).not.toContain(""); // no sentinel leaked into the code
    expect(html).toContain("const x = 1;");
  });

  it("a mermaid fence authored by Claude renders as a diagram with a badge", () => {
    const text = "```mermaid\ngraph TD; A-->B;\n```\n";
    const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
    expect(html).toContain('pre class="mermaid"');
    expect(html).toContain("cw-by-claude");
    expect(html).toContain("cw-badge");
  });

  it("is deterministic", () => {
    const text = "Stable input paragraph.\n";
    const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
    expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
  });
});
  • Step 2: Run to verify it fails

Run: npm test -- trackChangesModel Expected: FAIL (renderAuthorship/AuthorSpan not exported).

  • Step 3: Implement in src/trackChangesModel.ts
export type AuthorKind = "claude" | "human";
export interface AuthorSpan {
  start: number;
  end: number;
  author: AuthorKind;
}

// Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author.
const SENT = {
  claude: { open: "", close: "" },
  human: { open: "", close: "" },
} as const;

function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
  if (authors.size === 0) return null;
  if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
  const only = [...authors][0];
  return only === "claude"
    ? { cls: "cw-by-claude", label: "Claude" }
    : { cls: "cw-by-human", label: "You" };
}

/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
  // Build insertions as (localOffset, marker); apply right-to-left so offsets stay valid.
  const inserts: { at: number; marker: string }[] = [];
  for (const s of spans) {
    const lo = Math.max(0, s.start - blockStart);
    const hi = Math.min(raw.length, s.end - blockStart);
    if (hi <= lo) continue;
    inserts.push({ at: lo, marker: SENT[s.author].open });
    inserts.push({ at: hi, marker: SENT[s.author].close });
  }
  // Close markers must come BEFORE open markers at the same offset to keep nesting
  // tidy; otherwise sort by offset descending and, at equal offset, close first.
  inserts.sort((a, b) => (b.at - a.at) || (isClose(a.marker) ? -1 : 1));
  let out = raw;
  for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at);
  return out;
}
function isClose(m: string): boolean {
  return m === SENT.claude.close || m === SENT.human.close;
}

/** Replace the rendered sentinels with author <span> tags. */
function sentinelsToSpans(html: string): string {
  return html
    .split(SENT.claude.open).join('<span class="cw-by-claude">')
    .split(SENT.claude.close).join("</span>")
    .split(SENT.human.open).join('<span class="cw-by-human">')
    .split(SENT.human.close).join("</span>");
}

/**
 * Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
 * span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
 * code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a
 * block-level author badge, never inner sentinels. Deterministic.
 */
export function renderAuthorship(
  currentText: string,
  spans: AuthorSpan[],
  opts: RenderOptions = {},
): string {
  const render = opts.render ?? defaultRender;
  const safe = (src: string): string => {
    try {
      return render(src);
    } catch (err) {
      return chip(err instanceof Error ? err.message : String(err));
    }
  };
  return splitBlocksWithRanges(currentText)
    .map((b) => {
      const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end);
      if (b.type !== "prose") {
        const badge = authorBadge(new Set(overlapping.map((s) => s.author)));
        const inner = safe(b.raw);
        if (!badge) return `<div class="cw-blk">${inner}</div>`;
        return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
      }
      const injected = injectSentinels(b.raw, b.start, overlapping);
      return `<div class="cw-blk">${sentinelsToSpans(safe(injected))}</div>`;
    })
    .join("\n");
}
  • Step 4: Run to verify it passes

Run: npm test -- trackChangesModel Expected: PASS. If the inline-span test fails because markdown-it wraps prose in <p>, the assertion still holds (<span ...>cat sat</span> appears inside the <p>). If a sentinel survives in output, check sentinelsToSpans ordering.

  • Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f9): renderAuthorship — inline author spans + atomic fence badges (INV-26/27/28)"

Task 3: AttributionController.spansFor

Files:

  • Modify: src/attributionController.ts

  • Step 1: Add the method

In src/attributionController.ts, import the type and add a public method near getSpans:

import type { AuthorSpan } from "./trackChangesModel";
  /**
   * F9: the document's live attribution as authorship spans for the preview —
   * current-buffer char ranges mapped to author kind (agent→claude). Computes
   * the document key internally (so callers pass a TextDocument, not the key).
   */
  spansFor(document: vscode.TextDocument): AuthorSpan[] {
    return this.getSpans(this.keyOf(document)).map((s) => ({
      start: s.range.start,
      end: s.range.end,
      author: s.authorKind === "agent" ? "claude" : "human",
    }));
  }
  • Step 2: Verify typecheck

Run: npm run typecheck Expected: clean.

  • Step 3: Commit
git add src/attributionController.ts
git commit -m "feat(f9): AttributionController.spansFor — authorship spans for the preview"

Task 4: Wire mode + attribution into the preview controller

Files:

  • Modify: src/trackChangesPreview.ts, src/extension.ts

  • Step 1: Controller — attribution dep, mode state, render branch, setMode

In src/trackChangesPreview.ts:

Imports + fields:

import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel";
import type { AttributionController } from "./attributionController";

Add a per-panel mode map field:

  private readonly mode = new Map<string, "changes" | "authorship">();

Constructor — add the attribution param (after extensionUri):

  constructor(
    private readonly diffView: DiffViewController,
    private readonly extensionUri: vscode.Uri,
    private readonly attribution: AttributionController,
  ) {

In show(...), after creating the panel, wire incoming messages (the toggle). Add to the panel.onDidDispose registration area:

    panel.webview.onDidReceiveMessage(
      (m: { type?: string; mode?: "changes" | "authorship" }) => {
        if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) {
          this.mode.set(key, m.mode);
          this.refresh(document);
        }
      },
      null,
      this.disposables,
    );

Rewrite refresh(document) to branch on mode:

  refresh(document: vscode.TextDocument): void {
    const key = document.uri.toString();
    const panel = this.panels.get(key);
    if (!panel) return;
    const mode = this.mode.get(key) ?? "changes";
    const current = document.getText();
    if (mode === "authorship") {
      const spans = this.attribution.spansFor(document);
      void panel.webview.postMessage({
        type: "render",
        mode,
        html: renderAuthorship(current, spans),
        legend: { claude: spans.some((s) => s.author === "claude"), human: spans.some((s) => s.author === "human") },
      });
      this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current));
      return;
    }
    const baseline = this.diffView.getBaseline(key);
    const baselineText = baseline?.text ?? current;
    const ops = diffBlocks(baselineText, current);
    this.lastModel.set(key, ops);
    const summary = {
      added: ops.filter((o) => o.kind === "added").length,
      removed: ops.filter((o) => o.kind === "removed").length,
      changed: ops.filter((o) => o.kind === "changed").length,
    };
    void panel.webview.postMessage({
      type: "render",
      mode,
      html: renderTrackChanges(baselineText, current),
      epoch: this.epochLabel(baseline),
      summary,
    });
  }

Add a test seam for the current mode (used by E2E):

  getMode(uriString: string): "changes" | "authorship" {
    return this.mode.get(uriString) ?? "changes";
  }
  setMode(uriString: string, mode: "changes" | "authorship"): void {
    this.mode.set(uriString, mode);
    const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
    if (doc) this.refresh(doc);
  }
  • Step 2: extension.ts — reorder + pass attribution

In src/extension.ts, the F7 controller is currently constructed in the F6/F7 block (before the authoring stack). Move the TrackChangesPreviewController construction to AFTER attributionController is created, and pass it:

  1. Delete the existing block:
  const trackChangesPreviewController = new TrackChangesPreviewController(
    diffViewController,
    context.extensionUri,
  );
  context.subscriptions.push(trackChangesPreviewController);
  1. Re-create it right after const attributionController = new AttributionController(...) + its context.subscriptions.push(attributionController);:
  // --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode ---
  // Constructed after attribution so the authorship view can read F3 spans.
  const trackChangesPreviewController = new TrackChangesPreviewController(
    diffViewController,
    context.extensionUri,
    attributionController,
  );
  context.subscriptions.push(trackChangesPreviewController);

(The CowritingApi return already includes trackChangesPreviewController — unchanged. The F6 diffViewController stays where it is.)

  • Step 3: Verify typecheck + unit

Run: npm run typecheck && npm test Expected: clean + 150+ tests green (Tasks 13 added tests).

  • Step 4: Commit
git add src/trackChangesPreview.ts src/extension.ts
git commit -m "feat(f9): preview gains authorship mode + attribution dep + setMode wiring"

Task 5: Webview toggle, legend, and CSS

Files:

  • Modify: src/trackChangesPreview.ts (shell HTML), media/preview.ts, media/preview.css

  • Step 1: Shell HTML — add the segmented toggle + legend slot

In src/trackChangesPreview.ts shellHtml(...), replace the <div id="cw-header">…</div> with:

  <div id="cw-header">
    <div id="cw-mode" role="group">
      <button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
      <button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
    </div>
    <span id="cw-epoch">Track changes</span>
    <span id="cw-summary"></span>
    <span id="cw-legend" hidden></span>
  </div>
  • Step 2: media/preview.ts — post setMode; render per mode

Replace the message interface + handler and add the toggle wiring:

interface RenderMessage {
  type: "render";
  mode: "changes" | "authorship";
  html: string;
  epoch?: string;
  summary?: { added: number; removed: number; changed: number };
  legend?: { claude: boolean; human: boolean };
}

const vscodeApi = acquireVsCodeApi();
const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!;
const segs = Array.from(document.querySelectorAll<HTMLButtonElement>(".cw-seg"));

for (const seg of segs) {
  seg.addEventListener("click", () => {
    vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode });
  });
}

window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
  const msg = event.data;
  if (msg?.type !== "render") return;
  body.innerHTML = msg.html;
  for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode);
  const authorship = msg.mode === "authorship";
  header.hidden = authorship;
  summary.hidden = authorship;
  legend.hidden = !authorship;
  if (authorship) {
    const parts: string[] = [];
    if (msg.legend?.claude) parts.push('<span class="cw-by-claude cw-swatch">Claude</span>');
    if (msg.legend?.human) parts.push('<span class="cw-by-human cw-swatch">You</span>');
    legend.innerHTML = parts.join(" ") || "no attribution yet";
  } else {
    header.textContent = `Track changes since ${msg.epoch ?? ""}`;
    summary.innerHTML =
      `<span class="cw-add">+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)}</span> ` +
      `<span class="cw-del">${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}</span>`;
  }
  void renderMermaid();
});

(acquireVsCodeApi is a webview global; add declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; near the top of the file if the type isn't already present.)

  • Step 3: media/preview.css — author + toggle + legend styles

Append:

.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; }
.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; }
/* Block-level author badges reuse .cw-badge; tint the whole fence block. */
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; }
.cw-seg {
  background: transparent; color: var(--vscode-foreground);
  border: 1px solid var(--vscode-panel-border); padding: 0 0.5em; cursor: pointer; font-size: 0.9em;
}
.cw-seg:first-child { border-radius: 3px 0 0 3px; }
.cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; }
.cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }

(Background tints use translucent fallbacks so they read against any theme; the inline spans are intentionally subtle.)

  • Step 4: Build + verify

Run: npm run build && npm run typecheck && npm test Expected: build emits out/media/preview.js; typecheck clean; unit green.

  • Step 5: Commit
git add src/trackChangesPreview.ts media/preview.ts media/preview.css
git commit -m "feat(f9): webview segmented mode toggle + authorship legend + author CSS"

Task 6: Host E2E

Files:

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

  • Step 1: Write the suite

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";

const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 300));

async function getApi(): Promise<CowritingApi> {
  const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
  const api = (await ext.activate()) as CowritingApi;
  assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
  return api;
}

// F9 host E2E (no LLM): authorship mode marks Claude's landed span. Owns its own
// markdown doc, disjoint from the other suites' fixtures.
suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => {
  const DOC_REL = "docs/f9authorship.md";
  const TARGET = "The sentence Claude will compose over.";

  test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => {
    const abs = path.join(WS, DOC_REL);
    fs.mkdirSync(path.dirname(abs), { recursive: true });
    fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8");
    const uri = vscode.Uri.file(abs);
    const doc = await vscode.workspace.openTextDocument(uri);
    await vscode.window.showTextDocument(doc);
    await settle();
    const api = await getApi();
    const key = uri.toString();

    // open the preview (track-changes mode by default)
    await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
    await settle();
    assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
    assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes");

    // Claude composes via the seam (propose → accept)
    const start = doc.getText().indexOf(TARGET);
    const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
      uri: key, start, end: start + TARGET.length, newText: "The sentence CLAUDE COMPOSED.", model: "sonnet", sessionId: "e2e-f9", turnId: "turn-f9",
    });
    assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies");
    await settle();

    // attribution has a Claude span now
    const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
    assert.ok(claudeSpan, "Claude span recorded by F3");

    // flip to authorship mode → the model should reflect Claude authorship
    api.trackChangesPreviewController.setMode(key, "authorship");
    await settle();
    assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship");
    const spans = api.attributionController.spansFor(doc);
    assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");

    // back to track-changes — still functional (regression)
    api.trackChangesPreviewController.setMode(key, "changes");
    await settle();
    assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes");
    assert.ok((api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, "track-changes model still computed");
  });
});
  • Step 2: Run E2E

Run: npm run test:e2e Expected: both EDH passes green (the new F9 suite + all prior suites). Debug per superpowers:systematic-debugging if red; do not weaken assertions.

  • Step 3: Commit
git add test/e2e/suite/authorship.test.ts
git commit -m "test(f9): host E2E — authorship mode marks Claude's landed span"

Task 7: Docs

Files:

  • Create: docs/MANUAL-SMOKE-F9.md

  • Modify: README.md

  • Step 1: docs/MANUAL-SMOKE-F9.md

# Manual smoke — F9 authorship view in the preview

Confirms the rendered preview's Authorship mode colors Claude's vs your text. One
live turn hits the SDK.

## Prereqs
- `npm run build`; launch the Extension Development Host (F5) with `sandbox/` open.

## Steps
1. Open a markdown file. Open **Cowriting: Open Track-Changes Preview** (`Ctrl+Alt+R`).
2. The header shows a `[ Track changes | Authorship ]` toggle. It opens in **Track
   changes** (unchanged behavior).
3. Select a sentence → **Ask Claude to Edit Selection** → instruct → accept.
4. Click **Authorship**. Expect: Claude's accepted text is tinted (blue) and your
   own typing is tinted (green); the header shows a legend (● Claude / ● You).
   Text you never touched (original content) is plain.
5. Type a few words yourself, mixing into a Claude sentence. Expect: the colors
   split mid-paragraph at the exact boundaries.
6. If the doc has a mermaid or code fence Claude authored, expect the whole fence
   to carry a small author badge (not per-character).
7. Flip back to **Track changes**. Expect: the diff view is exactly as before.
  • Step 2: README F9 note — add after the F8 section (match heading style):
## F9 — Authorship view in the preview (Feature ~#27)

The rendered preview (F7) gains a second mode, switched by a `[ Track changes |
Authorship ]` toggle in its header. **Authorship** mode re-renders the current
document with each span colored by its F3 author — Claude (blue) vs you (green),
inline and char-precise — with a legend. Unlike track-changes (which diffs against
the F6 baseline, and so hides Claude's text once the baseline advances past a
landing), authorship reads F3 attribution directly, so Claude's contributions stay
visible. Code/mermaid fences carry a block-level author badge (atomic). Read-only,
sealed webview, no new persistence (INV-26..28).

Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`.
Live smoke: [`docs/MANUAL-SMOKE-F9.md`](docs/MANUAL-SMOKE-F9.md).
  • Step 3: Verify + commit
npm run typecheck && npm test
git add docs/MANUAL-SMOKE-F9.md README.md
git commit -m "docs(f9): manual smoke + README authorship-mode note"

Self-Review checklist (run before PR)

  • Spec coverage: §2 modes → Tasks 4/5; §3.1 render engine → Tasks 1/2; §3.2 wiring → Tasks 3/4/5; §3.3 INV-26/27/28 → Tasks 2/4; §5 testing → Tasks 1/2/6 + smoke; §6 slices → Tasks 17.
  • Untouched: renderTrackChanges/diffBlocks logic, F6 baseline, F3 capture, the seam, persistence (git diff --stat should show no behavioral change to those).
  • Done (spec §6): authorship mode marks Claude (blue) + you (green) inline; header toggle flips modes; fences get a block badge; track-changes unchanged; unit + host E2E green; smoke performed once.