4 Commits

Author SHA1 Message Date
BenStullsBets 8b98a36960 add plan ./plans/2026-07-02-71-surface-polish.md 2026-07-03 07:39:01 -07:00
BenStullsBets 0019d58e37 add plan ./plans/2026-07-02-70-inv5-reject-after-interior-edit.md 2026-07-02 18:53:51 -07:00
BenStullsBets b0687b6a26 update plan ./plans/2026-07-01-native-surfaces-migration.md 2026-07-02 16:11:14 -07:00
BenStullsBets ddaf3b0ece add plan ./plans/2026-07-01-native-surfaces-migration.md 2026-07-02 05:32:10 -07:00
3 changed files with 2122 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
# #70 — INV-5 Reject-After-Interior-Edit Gap 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:** `✗ Reject` on an optimistically-applied proposal restores the retained original (INV-5) even after the writer typed inside the pending range — and when it genuinely cannot locate the applied span, it fails loudly instead of reporting success while leaving the proposed text behind.
**Architecture:** Issue #70's fix direction **(a)** with an honest **(b)** fallback. A new per-doc `appliedSpans: Map<proposalId, OffsetRange>` becomes the real F12 applied-range bookkeeping: written at optimistic-apply time, re-synced whenever the exact anchor resolves at `renderAll`, shifted through interior-safe buffer edits (`shift()`), and **deleted (distrusted)** when an edit straddles a span boundary (e.g. a whole-buffer external replace — a shifted range clamps to garbage there, and restoring at a guessed offset would corrupt the doc, INV-11's "never applied by guess"). `revertInPlace` then reverts via exact resolve → `appliedSpans` fallback → hard failure (warning, proposal kept, `false` returned). Direction (c) (fuzzy resolve) is rejected: it guesses. `rejectAll` orders by the same fallback and reports skips like `acceptAllProposals` does.
**Tech Stack:** TypeScript VS Code extension; `npm test` (vitest unit), `npm run test:e2e` (@vscode/test-electron host suite — `ProposalController` is vscode-coupled, so coverage is host E2E, matching every existing F12 test).
## Global Constraints
- INV-5: "Reject restores the retained original exactly" — the contract this fix enforces; `revertInPlace`'s doc comment already promises "reverts the whole block regardless of any in-place edits the human made".
- INV-11: anchors are immutable for a proposal's life; stale text is flagged, **never applied by guess** — the fallback must only use a span whose tracking is provably continuous (no fuzzy resolve, no boundary-straddled ranges).
- INV-12: accept/reject stay human-only gestures; no new mutation paths.
- Keep `✓ Keep` (`finalizeInPlace`) behavior byte-identical — its lookup-by-id bypass of resolution is correct by design (issue "Contrast" note).
- Existing test suites must stay green: 265+ unit, full host E2E (notably `proposals.test.ts` INV-11 stale-accept, `f12InlineDiff.test.ts`, `fullLoop.test.ts`).
- Wiggleverse hygiene: no inline trailing comments in CLI blocks; commits cite the issue and carry the `Co-Authored-By` trailer.
---
### Task 1: `appliedSpans` bookkeeping + `revertInPlace` tracked-range fallback (the INV-5 repro)
**Files:**
- Modify: `src/proposalController.ts` (DocState ~line 35, `ensureState` ~127, `optimisticApply` ~336, `finalizeInPlace` ~417, `revertInPlace` ~434, `renderAll` ~495, `onDidChange` ~529)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (new suite appended)
**Interfaces:**
- Consumes: `shift(range, edit)` / `resolve(text, fp)` from `src/anchorer.ts` (existing); `DocState.applied: Set<string>` (existing).
- Produces: `DocState.appliedSpans: Map<string, OffsetRange>` (private bookkeeping; Tasks 23 rely on its distrust + ordering semantics); `revertInPlace(docPath, proposalId, opts?: { silent?: boolean }): Promise<boolean>` — signature gains the optional `opts` used by Task 3.
- [ ] **Step 1: Write the failing E2E test — the issue's repro sketch**
Append to `test/e2e/suite/f12InlineDiff.test.ts`:
```typescript
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
// still restore the retained original. The exact-substring anchor is orphaned by
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
// interior edits.
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
const ORIGINAL = "The quick brown fox jumps.";
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
const at = doc.getText().indexOf("rapid");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
const ORIGINAL = "A steady closing line.";
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "A wobbly closing line.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const at = doc.getText().indexOf("wobbly");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
await settle();
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
});
});
```
- [ ] **Step 2: Run the new suite to verify it fails**
```bash
npm run test:e2e 2>&1 | grep -A 3 "#70"
```
Expected: both tests FAIL — first on `doc.getText()` still holding the tweaked replacement (`RAPID-ish`) after a `true` return (the silent-skip bug), i.e. the "original restored exactly" assertion.
- [ ] **Step 3: Implement `appliedSpans` + the fallback**
In `src/proposalController.ts`:
(1) `DocState` gains the map (after `applied`):
```typescript
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
/**
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
* range is a guess, and stale text is never applied by guess (INV-11). Lets
* Reject restore the original after the human types INSIDE the pending range
* (which orphans the exact-substring anchor).
*/
appliedSpans: Map<string, OffsetRange>;
```
(2) `ensureState` initializes it: `appliedSpans: new Map(),` after `applied: new Set(),`.
(3) `optimisticApply` records the span where it already re-anchors (after `state.applied.add(proposalId);` and the `appliedStart`/`appliedEnd` computation — move the `add` down beside it or record right after the existing lines):
```typescript
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
```
(4) `renderAll`'s resolved branch re-syncs (covers the prior-session-applied reload, where the in-memory map starts empty):
```typescript
} else {
this.recordProposal(state, proposal, resolved, true);
if (state.applied.has(proposal.id)) state.appliedSpans.set(proposal.id, resolved);
}
```
(5) `onDidChange` maintains it — interior-safe edits shift, boundary-straddling edits distrust. Replace the method body:
```typescript
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
// #70: an edit fully inside a tracked applied span (or fully outside it)
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
// external replace — makes the shifted range a clamped guess, so the span
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
for (const [id, range] of state.appliedSpans) {
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (outside || inside) state.appliedSpans.set(id, shift(range, edit));
else state.appliedSpans.delete(id);
}
}
}
```
(6) `revertInPlace` — the fix itself. Replace the method (keep its position; doc comment updated to describe the layered lookup; `opts` is consumed by Task 3):
```typescript
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text: when an
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
* the shift-tracked applied span (#70, INV-5). When neither locates the span
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
* loudly — warning shown, proposal kept, `false` returned — instead of silently
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
*/
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
// Never optimistically applied → nothing of ours is in the buffer; clearing
// the record IS the reject (the legacy pending-only path).
if (hit.proposal.original === undefined) {
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
if (!span) {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's applied text can't be located (it changed or moved) — undo your edits, or Keep it to leave the buffer as-is (it is never reverted by guess).",
);
}
return false;
}
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
```
(7) `finalizeInPlace` cleans the map beside its existing `applied.delete`:
```typescript
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
```
- [ ] **Step 4: Typecheck + unit tests + the new E2E**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: typecheck clean; all unit tests PASS (no unit test touches `ProposalController`); host E2E PASS including the two new #70 tests and all pre-existing F12/proposals/fullLoop suites.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): reject after an interior tweak restores the original via tracked applied span (INV-5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: The honest hard-failure path (fix direction (b) for the residual orphan)
**Files:**
- Modify: `src/proposalController.ts` (only if Step 2 exposes a gap — the Task 1 code already implements the path)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `appliedSpans` distrust semantics and `revertInPlace` failure contract.
- Produces: nothing new — locks the contract with a regression test.
- [ ] **Step 1: Write the failing/locking E2E test**
Append inside the `#70` suite from Task 1:
> **Execution note:** the first cut of this test used a whole-buffer rewrite as the
> distrusting edit and FAILED — VS Code minimizes workspace edits before change
> events fire, so a rewrite sharing a prefix/suffix with the old text decomposes
> into interior hunks the span legitimately survives (and the revert then correctly
> restores the original in place). The distrusting edit must straddle a span
> boundary even after minimization — a deletion from outside the span into its
> interior, as below.
```typescript
// Fix direction (b): when the tracked span itself is distrusted (an edit
// straddled its boundary — here a deletion running from the heading into the
// span's interior), the reject FAILS honestly: warning path, proposal kept,
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
// back; reverting at a clamped guessed offset (INV-11) must not either.
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
// decomposes into interior hunks the span legitimately survives.)
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
const ORIGINAL = "Sentence one stands here.";
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
// Delete from inside the heading through the middle of the applied span:
// the edit straddles the span's start boundary → the tracked span is
// distrusted AND the exact anchor no longer resolves.
const mid = doc.getText().indexOf("ONE");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
await settle();
const before = doc.getText();
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
await settle();
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
// Cleanup for later tests: the never-locatable husk is discarded via the
// plain record-only reject.
assert.strictEqual(p.rejectById(docKey, id!), true);
});
```
- [ ] **Step 2: Run it**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "distrusted"
```
Expected: PASS if Task 1's implementation is complete (this is the locking regression); if it FAILS, the failure pinpoints which leg (distrust bookkeeping vs. failure return vs. proposal retention) is wrong — fix `src/proposalController.ts` accordingly, not the test.
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite/f12InlineDiff.test.ts src/proposalController.ts
git commit -m "test(#70): lock the honest hard-failure reject path (distrusted span → warn, keep, false)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: `rejectAll` parity — tweaked proposals revert too, skips are reported
**Files:**
- Modify: `src/proposalController.ts` (`rejectAll` ~line 461), `src/extension.ts` (`cowriting.rejectAllProposals` handler ~line 220)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `revertInPlace(docPath, id, opts?: { silent?: boolean })` and `appliedSpans`.
- Produces: `rejectAll(document): Promise<{ reverted: number; skipped: number }>` — additive `skipped` field (existing `{ reverted }` destructurings stay valid).
- [ ] **Step 1: Write the failing E2E test**
Append inside the `#70` suite:
```typescript
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
// tracked-span fallback, ordered descending by that span so earlier reverts
// never shift later ones — and must count what it could not revert.
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const p = api.proposalController;
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle();
for (const id of ids) await p.optimisticApply(doc, id);
await settle();
// Tweak INSIDE the first applied block → its exact anchor orphans.
const at = doc.getText().indexOf("AAA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
const { reverted, skipped } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
assert.strictEqual(skipped, 0, "nothing skipped");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
```
- [ ] **Step 2: Run it to verify the current gap**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "rejectAll reverts tweaked"
```
Expected: FAIL — today the tweaked proposal sorts as an orphan (`start: -1`, reverted last), and after the first successful revert's `renderAll` the old code silently removes it without restoring (`document fully restored` assertion fails), and `skipped` does not exist on the return type (typecheck catches first — that is the same failure).
- [ ] **Step 3: Implement — order by the fallback span, count skips, report them**
Replace `rejectAll` in `src/proposalController.ts`:
```typescript
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
* DESCENDING span order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
* one with no locatable span is SKIPPED — counted, never guessed (INV-11) —
* and the per-proposal warning is suppressed in favor of the batch report.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
return { id: p.id, start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
let skipped = 0;
for (const it of ordered) {
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
else skipped++;
}
return { reverted, skipped };
}
```
And in `src/extension.ts`, the `cowriting.rejectAllProposals` handler mirrors accept-all's skip note:
```typescript
const { reverted, skipped } = await proposalController.rejectAll(doc);
if (reverted === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
);
```
- [ ] **Step 4: Typecheck + full test run**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: all PASS — including the pre-existing `rejectAll` E2E tests (`f12InlineDiff` "rejectAll reverts every pending proposal", "control parity", `proposals.test.ts` cleanup calls), whose `{ reverted }` destructuring is unaffected by the additive field.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts src/extension.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Close the loop — fullLoop E2E rejects a tweaked proposal (the deliberate avoidance falls)
**Files:**
- Modify: `test/e2e/suite/fullLoop.test.ts` (the PUC-2 reject section, ~line 144)
**Interfaces:**
- Consumes: the shipped Task 1 behavior; existing fullLoop fixtures (`rejectId`, `REJECT_REPLACEMENT`, `ORIG_REJECT`).
- Produces: nothing — upgrades the flagship E2E so the gap can't silently regress.
- [ ] **Step 1: Tweak inside the reject proposal's range before rejecting**
In `test/e2e/suite/fullLoop.test.ts`, immediately before the existing line
`assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");`,
insert:
```typescript
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
// original deliberately rejected only an un-tweaked proposal because the
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
assert.ok(rejAt >= 0, "reject proposal's applied text present");
const rejTweak = new vscode.WorkspaceEdit();
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
null,
"tweak orphans the reject proposal's exact anchor (INV-11)",
);
```
(The existing assertions right after — proposal cleared, `ORIG_REJECT` restored, `REJECT_REPLACEMENT` gone — now verify the #70 path. If `REJECT_REPLACEMENT` is a multi-word string whose slice at `+2` splits a word the later `!doc.getText().includes(REJECT_REPLACEMENT)` assertion still holds; the `includes(ORIG_REJECT)` restore assertion is the INV-5 check.)
- [ ] **Step 2: Run the fullLoop suite**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 8 "fullLoop\|full-loop\|full loop"
```
Expected: PASS end-to-end (would FAIL on unfixed code: the buffer would keep the tweaked `REJECT_REPLACEMENT`, so `includes(ORIG_REJECT)` fails).
- [ ] **Step 3: Full verification sweep**
```bash
npm run typecheck && npm test && npm run test:e2e
```
Expected: everything green.
- [ ] **Step 4: Commit**
```bash
git add test/e2e/suite/fullLoop.test.ts
git commit -m "test(#70): fullLoop reject leg now tweaks inside the pending range first (INV-5 end-to-end)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Post-execution review wave (not in the original tasks)
A high-effort multi-agent branch review after Task 4 confirmed 10 findings; all
but one were fixed in a follow-up commit on this branch ("harden the
tracked-span revert per branch review"): pure `shiftTracked` in `anchorer.ts`
(+11 unit tests; insertion-at-span-start lands before the span), tracked spans
cleared on document close, rebuild-only resync at `renderAll`, `guard.isReadOnly`
on `revertInPlace`/`finalizeInPlace`, a "Discard proposal (leave text)" action on
the hard-fail warning, CodeLens pair anchored at the tracked span (+1 E2E),
QuickPick batch menu routed through the reporting commands, and one
`clearProposal` helper (also fixes `reject()`'s stale `applied`/`appliedSpans`).
The remaining finding — `resolve()` accepts a single exact occurrence without a
context check, so a duplicated block can hijack any anchor consumer — pre-dates
this fix and is filed as its own issue.
## Self-review notes
- **Issue coverage:** direction (a) → Task 1 (`appliedSpans` fallback); direction (b) → Task 2 (hard failure, warn, keep); direction (c) explicitly rejected (guessing violates INV-11). The issue's repro sketch is Task 1's test verbatim; the "full-loop E2E deliberately rejects only an un-tweaked proposal" note is retired by Task 4; `rejectAll` (same `revertInPlace` seam) is covered by Task 3.
- **Type consistency:** `revertInPlace(docPath, proposalId, opts?: { silent?: boolean })` defined in Task 1, consumed in Task 3; `rejectAll` returns `{ reverted, skipped }` (additive); `appliedSpans: Map<string, OffsetRange>` used in Tasks 1 and 3.
- **Contrast guard:** `finalizeInPlace` untouched except symmetric `appliedSpans.delete` cleanup — the Keep bypass stays.
+342
View File
@@ -0,0 +1,342 @@
# #71 Surface Polish Batch 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:** Land the six batched Minor findings from the native-surfaces migration's final whole-branch review (issue #71) — misleading `pin()` warning, orphaned command declarations, a wrong when-clause key, README F3/banner drift, an ungated palette entry, and an uncleared debounce timer.
**Architecture:** Six independent point fixes across `src/diffViewController.ts`, `src/gitBaseline.ts`, `package.json`, and `README.md`. No new modules, no model/persistence changes, no new invariants. Verification is the existing suites (all six items are copy/declaration/cleanup changes with no unit-testable pure surface — the unit suite is vscode-free and these all live at the vscode boundary; E2E cannot observe warning-message text or palette visibility).
**Tech Stack:** VS Code extension (TypeScript, esbuild CJS bundle), vitest unit suite (`npm test`), `@vscode/test-electron` host E2E (`npm run test:e2e`).
## Global Constraints
- Work happens on branch `worktree-s71-surface-polish` in the worktree at `.claude/worktrees/s71-surface-polish` (canonical checkout is occupied by concurrent session 0062 — never touch it).
- Commits cite #71, read like surrounding history, and carry the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
- Warning copy for the not-coediting case is exactly `"Run ✦ Coedit this Document with Claude first."` — the string `src/threadController.ts:181` already uses. Do not coin a variant.
- No inline trailing comments on shell commands (wgl `no-inline-cli-comments`).
- Final gate before PR: `npm test` (unit), `npm run typecheck`, `npm run build`, `npm run test:e2e` all green.
---
### Task 1: `pin()` explicit not-coediting branch (item 1)
**Files:**
- Modify: `src/diffViewController.ts:145-155`
**Interfaces:**
- Consumes: `this.modes: Map<string, "head" | "snapshot">` (set only by `establish()`).
- Produces: nothing new — same `pin(document): void` signature; only the warning copy forks.
`pin()` currently emits "this document is git-tracked — commit to advance the baseline" for **any** non-snapshot mode, including `undefined` (a document that never established, i.e. is not being coedited) — a wrong message for that case.
- [ ] **Step 1: Fork the guard on `undefined`**
Replace lines 145155 (the whole `pin` method) with:
```ts
pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
const mode = this.modes.get(key);
if (mode === undefined) {
// never established — the document is not being coedited (#71 item 1)
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (mode !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
```
Keep the existing doc comment above the method unchanged.
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/diffViewController.ts
git commit -m "fix: markReviewed on a never-established doc says 'coedit first', not 'git-tracked' (#71 item 1)"
```
---
### Task 2: delete orphaned `acceptProposal` / `rejectProposal` declarations (item 2)
**Files:**
- Modify: `package.json` (two `contributes.commands` entries ~85-93, two `contributes.menus.commandPalette` entries ~180-187)
**Interfaces:** none — pure declaration removal. `test/e2e/suite-no-workspace/noWorkspace.test.ts:42-43` already asserts these commands are **not registered** at runtime; nothing registers them in `src/`.
- [ ] **Step 1: Delete the two command declarations**
In `contributes.commands`, delete both objects:
```json
{
"command": "cowriting.acceptProposal",
"title": "✓ Accept Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.rejectProposal",
"title": "✗ Reject Proposal",
"category": "Cowriting"
},
```
- [ ] **Step 2: Delete their palette-hiding menu entries**
In `contributes.menus.commandPalette`, delete both objects (they exist only to hide the now-deleted declarations):
```json
{
"command": "cowriting.acceptProposal",
"when": "false"
},
{
"command": "cowriting.rejectProposal",
"when": "false"
},
```
- [ ] **Step 3: Verify no dangling references**
Run: `grep -rn "acceptProposal\|rejectProposal" package.json src/`
Expected: no matches (the only remaining references are the retirement assertions in `test/e2e/suite-no-workspace/noWorkspace.test.ts`).
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore: delete orphaned acceptProposal/rejectProposal declarations (#71 item 2)"
```
---
### Task 3: `openReviewPreview` editor/title when-key (item 3)
**Files:**
- Modify: `package.json:260` (the `editor/title` menu entry for `cowriting.openReviewPreview`)
**Interfaces:** none — one-token when-clause fix. Title menus key off the resource, so `resourceLangId` is correct (every sibling `editor/title` entry already uses it); `editorLangId` is the odd one out.
- [ ] **Step 1: Fix the key**
In `contributes.menus."editor/title"`, change:
```json
{
"command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
```
to:
```json
{
"command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown",
"group": "navigation@9"
}
```
(Only the `editor/title` entry. The `commandPalette` entries elsewhere legitimately use `editorLangId`; the `editor/title/context` and `explorer/context` entries already use `resourceLangId`.)
- [ ] **Step 2: Commit**
```bash
git add package.json
git commit -m "fix: openReviewPreview title-menu when uses resourceLangId like its siblings (#71 item 3)"
```
---
### Task 4: gate the `cowriting.createThread` palette entry (item 5)
**Files:**
- Modify: `package.json` (`contributes.menus.commandPalette`)
**Interfaces:** none — `createThreadOnSelection` (`src/threadController.ts:197-201`) already returns `undefined` silently without a selection / coediting / authorable scheme; the palette entry is currently unconditionally visible, so the command silently no-ops from the palette. Fix = gate the palette entry with the **same when-clause its `editor/context` entry uses** (`package.json:290-293`), so the palette only offers it when it can act — consistent with how `markReviewed`'s palette gate was tightened at merge.
- [ ] **Step 1: Add the palette gate**
In `contributes.menus.commandPalette`, insert after the `cowriting.proposeAgentEdit` entry:
```json
{
"command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
},
```
- [ ] **Step 2: Verify JSON**
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 3: Commit**
```bash
git add package.json
git commit -m "fix: gate createThread palette entry on selection+coediting like its context-menu entry (#71 item 5)"
```
---
### Task 5: clear the `gitBaseline` reflog debounce timer in `dispose()` (item 6)
**Files:**
- Modify: `src/gitBaseline.ts:113-126` (`watchReflog`)
**Interfaces:** none — same disposable contract; the pushed disposable additionally clears the pending debounce timeout so no one-shot `repo.status()` fires post-dispose.
- [ ] **Step 1: Clear the timer in the pushed disposable**
In `watchReflog`, change:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
```
to:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({
dispose: () => {
watcher.close();
if (pending) clearTimeout(pending);
},
});
```
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/gitBaseline.ts
git commit -m "fix: clear reflog debounce timer on GitBaselineWatcher dispose (#71 item 6)"
```
---
### Task 6: README F3 drift + stale chord notes in superseded banners (item 4)
**Files:**
- Modify: `README.md` (F3 section ~106, F7 banner ~230-233, F10 banner ~313-317)
**Interfaces:** none — docs only. Current reality the banner must state: `Cowriting: Toggle Attribution` no longer exists (annotations toggle via **Toggle Annotations**, How it works §6); "Ask Claude to Edit Selection" is palette-hidden (`package.json:201-203``"when": "false"`), superseded by **Ask Claude to Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) and the comments-first **Ask Claude** (§3). `Ctrl+Alt+R` today is **Review Changes** (native diff), not any preview.
- [ ] **Step 1: Add the F3 banner**
Immediately after the `## F3 — Live human/Claude attribution (Feature #6)` heading (before the "As you and Claude coauthor…" paragraph), insert:
```markdown
> **Commands superseded (native-surfaces migration).** The attribution **data
> layer** below is current, but the Commands block is historical:
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
> it works §3). Kept below as a historical record.
```
- [ ] **Step 2: Chord note in the F7 banner**
Extend the F7 superseded banner's last sentence. Change:
```markdown
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design.
```
to:
```markdown
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design. (The
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
```
- [ ] **Step 3: Chord note in the F10 banner**
Change the F10 banner's last sentence from:
```markdown
> **How it works** above. Kept below as a historical record.
```
to:
```markdown
> **How it works** above. Kept below as a historical record. (The `Ctrl+Alt+R`
> chord below now opens the native **Review Changes** diff, not this panel.)
```
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: F3 superseded-commands banner + stale-chord notes in F7/F10 banners (#71 item 4)"
```
---
### Task 7: full verification + PR + merge
**Files:** none new.
- [ ] **Step 1: Full local gate**
Run each; all must be green:
```bash
npm test
npm run typecheck
npm run build
npm run test:e2e
```
Expected: unit suite passes (~250 tests), typecheck clean, build succeeds, host E2E passes (undo suites may self-skip via the #54 runtime probe — a loud warning, not a failure).
- [ ] **Step 2: Push branch + open PR**
```bash
git push -u origin worktree-s71-surface-polish
```
Open a PR against `main` on `benstull/vscode-cowriting-plugin` (Gitea, SSH remote) titled `Surface polish: batched Minor findings from the native-surfaces final review (#71)`; body lists the six items and cites the issue.
- [ ] **Step 3: Merge (squash) and confirm #71 auto-close/close**
Merge the PR; verify `main` carries the change and close #71 if the merge didn't.