add spec ./specs/coauthoring-propose-accept.md (status: graduated)
This commit is contained in:
@@ -0,0 +1,472 @@
|
|||||||
|
---
|
||||||
|
status: graduated
|
||||||
|
---
|
||||||
|
# Solution Design: Propose/Accept Diff Flow (F4)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Author(s)** | Ben Stull (with Claude) |
|
||||||
|
| **Reviewers / approvers** | Ben Stull |
|
||||||
|
| **Status** | `draft` |
|
||||||
|
| **Version** | v0.1.0 |
|
||||||
|
| **Source artifacts** | Epic `benstull/vscode-cowriting-plugin#1` · Feature `#12` (F4) · Parent specs: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md` (graduated; §6.3 reserved `proposals[]`, §9 OPEN→F3/F4) and `specs/coauthoring-attribution.md` (graduated; §9 OPEN→F4) · F3 shipped: `#6` (PR #7, session 0007) · F2 shipped: `#4` (PR #5) · Lineage: `ben.stull/rfc-app#48` |
|
||||||
|
|
||||||
|
**Change log**
|
||||||
|
|
||||||
|
| Date | Version | Change | By |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-06-10 | v0.1.0 | Initial draft — brainstorming session 0011 | Ben Stull + Claude |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Business Context
|
||||||
|
|
||||||
|
### 1.1 Executive Summary
|
||||||
|
|
||||||
|
F4 inverts the trust model of machine edits: instead of Claude's edits landing
|
||||||
|
in the document and being reviewed *after the fact* via the attribution tint,
|
||||||
|
they arrive as **proposals** — visible, anchored, diff-rendered — that the human
|
||||||
|
explicitly **accepts or rejects**. Accepting applies the edit through the
|
||||||
|
established `applyAgentEdit` seam, so accepted text lands Claude-attributed with
|
||||||
|
zero new attribution machinery; rejecting leaves the document untouched. Pending
|
||||||
|
proposals are first-class git-native state in the sidecar's reserved
|
||||||
|
`proposals[]` extension point, persisting and re-anchoring exactly like threads
|
||||||
|
and attributions. This delivers Epic #1's third acceptance pillar —
|
||||||
|
*"accept / reject Claude's proposed diffs (propose-by-default)"* — and completes
|
||||||
|
the inner-loop triad: threads (F2) · attribution (F3) · propose/accept (F4).
|
||||||
|
|
||||||
|
### 1.2 Background
|
||||||
|
|
||||||
|
F3 (#6) shipped the seam — `applyAgentEdit`, the single machine-edit ingress
|
||||||
|
(INV-9) — and a minimal live `@cline/sdk` turn (the edit-selection command) that
|
||||||
|
drives it **directly**: the turn completes and the buffer changes in the same
|
||||||
|
gesture. Both parent specs reserved exactly this moment: the inner-loop spec
|
||||||
|
holds `proposals: []` open in the sidecar ("F4 extension point — reuses
|
||||||
|
`anchors[]` + provenance"), and the attribution spec's §9 routes "OPEN → F4:
|
||||||
|
propose/accept drives the same seam; accepted diffs render into this attribution
|
||||||
|
substrate." Issue #12 carried five design forks to this session: the
|
||||||
|
`proposals[]` shape, the rendering primitive, propose-by-default vs. a retained
|
||||||
|
direct mode, multi-range seam growth, and accept/reject granularity. All five
|
||||||
|
are resolved here (§6.7, §9).
|
||||||
|
|
||||||
|
### 1.3 Business Actors / Roles
|
||||||
|
|
||||||
|
- **Coauthor (human)** — the writer/engineer; now also the **ratifier**: the
|
||||||
|
only actor who can move proposed text into the document.
|
||||||
|
- **Coauthor (machine)** — Claude via `@cline/sdk`; from F4 on it **proposes**
|
||||||
|
rather than writes.
|
||||||
|
|
||||||
|
### 1.4 Problem Statement
|
||||||
|
|
||||||
|
The machine writes first and the human objects later. A completed live turn
|
||||||
|
mutates the buffer immediately; the human's only recourse to an unwanted machine
|
||||||
|
edit is undo. There is no ratify-before-landing gesture — no inline diff to
|
||||||
|
inspect, no keep/reject decision point — so the epic's propose-by-default trust
|
||||||
|
model is unmet.
|
||||||
|
|
||||||
|
### 1.5 Pain Points
|
||||||
|
|
||||||
|
- No way to see *what the machine intends to change* before it changes it.
|
||||||
|
- No explicit keep/reject gesture; review is post-hoc (attribution tint + undo).
|
||||||
|
- A machine edit and a human ratification are indistinguishable events — the
|
||||||
|
provenance story stops at "agent wrote this," with no record that a human
|
||||||
|
chose to let it stand.
|
||||||
|
|
||||||
|
### 1.6 Targeted Business Outcomes
|
||||||
|
|
||||||
|
A live Claude turn produces a visible, anchored proposal (current text vs.
|
||||||
|
proposed replacement) and the document is untouched; the human accepts — the
|
||||||
|
edit applies through the seam and lands Claude-attributed — or rejects — the
|
||||||
|
proposal disappears and nothing changed. Pending proposals survive reload and
|
||||||
|
re-anchor across edits. F5 inherits a complete provenance story:
|
||||||
|
proposed → ratified → attributed.
|
||||||
|
|
||||||
|
### 1.7 Scope (business)
|
||||||
|
|
||||||
|
**In scope:** the `proposals[]` shape on the shared `anchors`/`Provenance`
|
||||||
|
primitives; flipping the live turn propose-by-default; in-editor rendering of
|
||||||
|
pending proposals as anchored diffs; the accept/reject UX; apply-on-accept
|
||||||
|
through the unchanged single-range seam; git-native persistence + reload +
|
||||||
|
re-anchor/orphan of pending proposals; a programmatic propose ingress so tests
|
||||||
|
drive the whole flow with no LLM.
|
||||||
|
|
||||||
|
**Out of scope (deferred, not forgotten):** hunk-level / partial acceptance
|
||||||
|
within one proposal (the model makes it additive later — §6.7); F5 cross-rung
|
||||||
|
format + Gitea round-trip (#46); multi-range growth of the seam contract
|
||||||
|
(not needed — §6.7); chat-panel UX; multi-file orchestration; turn-taking
|
||||||
|
choreography; attribution of out-of-session edits; any server.
|
||||||
|
|
||||||
|
### 1.8 Assumptions · Constraints · Dependencies
|
||||||
|
|
||||||
|
- **Parent:** Epic #1. **Blocked by:** F3 #6 (shipped) and F2 #4 (shipped).
|
||||||
|
**Feeds:** F5 (cross-rung provenance story).
|
||||||
|
- Builds on shipped, unchanged machinery: the shared `anchors` map +
|
||||||
|
`Fingerprint` + `Provenance` primitives (parent INV-4), the Anchorer's
|
||||||
|
resolve/orphan ladder (INV-1/INV-3), `CoauthorStore.update` (sole-writer
|
||||||
|
read-modify-write with anchor prune + counted self-writes), the seam
|
||||||
|
(`applyAgentEdit`, INV-9), the F3 attribution substrate, and the F2
|
||||||
|
Comments-API pattern.
|
||||||
|
- The sidecar's `proposals: unknown[]` is already declared and serialized
|
||||||
|
pass-through (`src/model.ts`); `SCHEMA_VERSION` stays **1** — filling a
|
||||||
|
reserved extension point is additive, the F3 precedent.
|
||||||
|
- `CoauthorStore.update`'s anchor prune currently references only thread +
|
||||||
|
attribution anchorIds (noted F4 TODO at `src/store.ts:65`) — F4 extends it.
|
||||||
|
- No new auth surface: the live turn's `claude-code` provider arrangement (F3
|
||||||
|
INV-8) is untouched; accept/reject and all tests are LLM-free.
|
||||||
|
|
||||||
|
### 1.9 Business Use Cases
|
||||||
|
|
||||||
|
- **BUC-1** A coauthor asks Claude to edit a selection and **reviews the
|
||||||
|
proposed diff before anything lands**: accept applies it (attributed),
|
||||||
|
reject discards it, the document never changes without their say-so.
|
||||||
|
- **BUC-2** A coauthor leaves proposals pending — closes the workspace, comes
|
||||||
|
back, edits other parts of the document — and the proposals are still there,
|
||||||
|
re-anchored, still decidable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Solution Proposal
|
||||||
|
|
||||||
|
Fill the sidecar's reserved `proposals[]` with **pending machine edits**: each
|
||||||
|
proposal is one anchored target range + one proposed replacement + agent
|
||||||
|
provenance, born when a live turn (or the programmatic ingress) completes.
|
||||||
|
The document is untouched at propose time (INV-10). A **ProposalController**
|
||||||
|
renders each pending proposal two ways at once: an **amber tint** on the target
|
||||||
|
range (the in-buffer signal) and an anchored **proposal thread** — a second
|
||||||
|
Comments-API controller — whose body is a fenced unified diff (current →
|
||||||
|
proposed) with **Accept** / **Reject** actions. Accept re-resolves the anchor
|
||||||
|
(exact fingerprint text match — the existing ladder doubles as the staleness
|
||||||
|
guard, INV-11), then applies through the unchanged single-range
|
||||||
|
`applyAgentEdit` seam: the F3 substrate attributes the spans with zero new
|
||||||
|
attribution code, and the proposal leaves `proposals[]`. Reject removes the
|
||||||
|
proposal; nothing else happens. A multi-edit turn simply files N single-range
|
||||||
|
proposals sharing a `turnId` — the seam contract does not grow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Product Personas
|
||||||
|
|
||||||
|
- **PP-1 Inner-loop coauthor** — the human writer/engineer (as in F2/F3), now
|
||||||
|
the ratifier of machine edits.
|
||||||
|
- **PP-2 Machine coauthor (Claude)** — proposes edits; its text enters the
|
||||||
|
document only via human acceptance.
|
||||||
|
|
||||||
|
## 4. Product Use Cases
|
||||||
|
|
||||||
|
- **PUC-1 (propose)** Select a region, run "Ask Claude to edit selection," give
|
||||||
|
an instruction → a proposal appears (amber-tinted range + diff thread); the
|
||||||
|
document text is unchanged.
|
||||||
|
- **PUC-2 (accept)** Accept a pending proposal → the replacement applies, the
|
||||||
|
new spans render Claude-attributed (F3), the proposal and its UI disappear.
|
||||||
|
- **PUC-3 (reject)** Reject a pending proposal → the document is untouched, the
|
||||||
|
proposal and its UI disappear.
|
||||||
|
- **PUC-4 (persist / re-anchor / stale)** Pending proposals survive save +
|
||||||
|
reopen at their re-resolved anchors. Editing **around** a proposal shifts it;
|
||||||
|
editing the **target text itself** makes it stale — flagged, not acceptable,
|
||||||
|
recoverable (undo restores acceptability), never silently applied.
|
||||||
|
- **PUC-5 (coexist)** Several pending proposals on distinct ranges coexist;
|
||||||
|
each is independently decidable, in any order.
|
||||||
|
|
||||||
|
## 5. UX Layout
|
||||||
|
|
||||||
|
No bespoke UI surface — native VS Code primitives only (per the
|
||||||
|
Claude-Design-vs-Claude-Code rubric this is squarely **Claude Code** territory:
|
||||||
|
code-resident native surfaces, no visual design artifacts to produce). Pending
|
||||||
|
proposals render as: (1) an **amber background tint** on the target range
|
||||||
|
(sibling of F3's indigo agent tint / green human gutter), and (2) a **proposal
|
||||||
|
thread** anchored at the range — a second Comments controller
|
||||||
|
(`cowriting.proposals`, distinct from `cowriting.threads` so discussion and
|
||||||
|
ratification never mix) whose single comment body shows the instruction (when
|
||||||
|
present) and a fenced diff of current → proposed text, with **✓ Accept** and
|
||||||
|
**✗ Reject** as thread title actions. The status bar surfaces a count of
|
||||||
|
stale/orphaned proposals (F3's orphan-count pattern). The existing
|
||||||
|
"Ask Claude to edit selection" command keeps its name and gesture — it now ends
|
||||||
|
in a proposal instead of a buffer mutation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Technical Design
|
||||||
|
|
||||||
|
### 6.1 Invariants
|
||||||
|
|
||||||
|
Parent invariants INV-1..INV-9 carry over unchanged. F4 adds:
|
||||||
|
|
||||||
|
- **INV-10** A proposal **never mutates the document**. Proposed text lives only
|
||||||
|
in the sidecar and the review UI until accepted; the document changes only via
|
||||||
|
the seam, only on accept. (INV-9 still holds: the seam remains the single
|
||||||
|
machine-edit ingress — F4 moves its *trigger* from turn-completion to human
|
||||||
|
acceptance.)
|
||||||
|
- **INV-11** Accept is **fingerprint-guarded**: a proposal applies only when its
|
||||||
|
anchor re-resolves with an exact text match at decision time. A proposal whose
|
||||||
|
target text changed (stale) or cannot be found (orphaned) is surfaced and
|
||||||
|
undecidable-as-accept — never applied by guess (extends INV-1).
|
||||||
|
- **INV-12** Accept and reject are **human-only gestures**: no code path lets
|
||||||
|
the machine ratify its own proposal.
|
||||||
|
- **INV-13** `proposals[]` holds **pending proposals only** — state, not
|
||||||
|
history (the F3 span-list precedent). Accept converts a proposal into
|
||||||
|
attribution spans (that substrate plus git history is the durable record);
|
||||||
|
reject removes it.
|
||||||
|
|
||||||
|
### 6.2 High-level architecture
|
||||||
|
|
||||||
|
One new vscode-free model slice plus one new controller, mirroring the F2/F3
|
||||||
|
unit pattern; everything else is reused unchanged:
|
||||||
|
|
||||||
|
- **`proposals` model** — the typed `Proposal` shape replacing the `unknown[]`
|
||||||
|
placeholder (`schemaVersion` stays 1). Pure helpers for diff-body formatting
|
||||||
|
and stale-detection are vscode-free and unit-testable.
|
||||||
|
- **ProposalController** — owns the proposal lifecycle: programmatic ingress
|
||||||
|
(`proposeAgentEdit`), persistence via `CoauthorStore.update`, live range
|
||||||
|
tracking + resolve/stale/orphan on load and external change (Anchorer ladder),
|
||||||
|
rendering (amber decoration + proposal threads on its own
|
||||||
|
`CommentController`), and the accept/reject actions.
|
||||||
|
- **LiveTurn — retargeted, not rewritten:** `editSelection` keeps its
|
||||||
|
guards/turn/replacement-extraction and now ends in `proposeAgentEdit` instead
|
||||||
|
of `applyAgentEdit`.
|
||||||
|
- **Seam, AttributionController, CoauthorStore, Anchorer — reused unchanged**,
|
||||||
|
except the store's anchor prune learns about proposal anchorIds (the noted F4
|
||||||
|
TODO).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
sel["selection + instruction"] --> turn["@cline/sdk live turn\n(claude-code)"]
|
||||||
|
turn --> prop["proposeAgentEdit\n(anchor + persist, INV-10)"]
|
||||||
|
e2e["E2E / programmatic ingress\n(cowriting.proposeAgentEdit)"] --> prop
|
||||||
|
prop --> ui["amber tint + proposal thread\n(fenced diff, ✓/✗)"]
|
||||||
|
ui -- "✓ accept (human, INV-12)" --> guard{"re-resolve exact?\n(INV-11)"}
|
||||||
|
guard -- yes --> seam["applyAgentEdit seam\n(unchanged, INV-9)"]
|
||||||
|
seam --> attr["F3 attribution substrate\n(spans, Claude tint)"]
|
||||||
|
guard -- "no (stale/orphan)" --> flag["flagged, undecidable-as-accept,\nrecoverable"]
|
||||||
|
ui -- "✗ reject (human)" --> gone["proposal removed,\ndocument untouched"]
|
||||||
|
seam --> gone
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Data model & ownership
|
||||||
|
|
||||||
|
Same sidecar, same `anchors` map, same `Provenance` — `proposals[]` is filled,
|
||||||
|
not reshaped (parent INV-4; no `schemaVersion` bump):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"proposals": [
|
||||||
|
{
|
||||||
|
"id": "pr1",
|
||||||
|
"anchorId": "a9", // shared anchors map; fingerprint.text IS the
|
||||||
|
// exact target text — the staleness oracle
|
||||||
|
"replacement": "…", // the full proposed text for the anchored range
|
||||||
|
"author": { "kind": "agent", "id": "claude",
|
||||||
|
"agent": { "sdk": "@cline/sdk", "model": "…", "sessionId": "…" } },
|
||||||
|
"createdAt": "ISO-8601",
|
||||||
|
"turnId": "turn-…", // groups N proposals born of one live turn
|
||||||
|
"instruction": "…" // optional: what the human asked for (review context)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pending-only** (INV-13): accept/reject remove the entry; there is no status
|
||||||
|
field to go stale.
|
||||||
|
- **Staleness/orphanhood are not persisted** — resolve-time outcomes, exactly
|
||||||
|
like F2 threads and F3 attributions: the anchor's `fingerprint.text` is
|
||||||
|
compared at render/decision time.
|
||||||
|
- **Persisted at propose time** (the F2 thread-mutation precedent, not the F3
|
||||||
|
save-time one): a proposal is the product of a completed paid turn — losing it
|
||||||
|
to a crash wastes the turn. `CoauthorStore.update` is the only writer; the
|
||||||
|
anchor prune's referenced-set gains `proposals[].anchorId`.
|
||||||
|
- Ownership/format rules unchanged: extension sole writer; stable key ordering;
|
||||||
|
ids via `newId()`.
|
||||||
|
|
||||||
|
### 6.4 Interfaces & contracts
|
||||||
|
|
||||||
|
- **ProposalController**:
|
||||||
|
`proposeAgentEdit(doc, range, replacement, provenance, opts?: { turnId?, instruction? }): Promise<Proposal>`
|
||||||
|
— fingerprints the range (Anchorer), persists, renders; **does not touch the
|
||||||
|
document** (INV-10). Also contributed as the hidden command
|
||||||
|
`cowriting.proposeAgentEdit` (the E2E entry point, mirroring
|
||||||
|
`cowriting.applyAgentEdit`).
|
||||||
|
`accept(proposalId): Promise<boolean>` — re-resolve → exact-text guard
|
||||||
|
(INV-11) → `applyAgentEdit(doc, resolvedRange, replacement, author,
|
||||||
|
{ expectedVersion: doc.version, turnId })` → on success remove + dispose UI.
|
||||||
|
`reject(proposalId): Promise<void>` — remove + dispose UI.
|
||||||
|
`getPending(docPath)` / `getStaleCount()` — test-facing, F3's
|
||||||
|
`getSpans`/`getOrphanCount` pattern.
|
||||||
|
- **Seam — contract unchanged** (single-range `applyAgentEdit`); F4 is its
|
||||||
|
intended driver, exactly as the F3 spec reserved.
|
||||||
|
- **LiveTurn**: `runEditTurn` unchanged; the `editSelection` wiring calls
|
||||||
|
`proposeAgentEdit` with the turn's model/sessionId provenance + the
|
||||||
|
instruction.
|
||||||
|
- **Commands/menus**: `cowriting.acceptProposal` / `cowriting.rejectProposal`
|
||||||
|
on the proposal thread's title menu (palette-hidden); `editSelection` and
|
||||||
|
`createThread` context-menu entries unchanged.
|
||||||
|
|
||||||
|
### 6.5 Per–Product-Use-Case design
|
||||||
|
|
||||||
|
- **PUC-1 (propose):** existing `editSelection` guards + turn → on completion,
|
||||||
|
`proposeAgentEdit` anchors the selection (buildFingerprint), persists via
|
||||||
|
`store.update`, creates the proposal thread (body = instruction + fenced
|
||||||
|
diff of fingerprint.text → replacement) and applies the amber decoration.
|
||||||
|
Document version untouched; no pending-edit registration occurs.
|
||||||
|
- **PUC-2 (accept):** thread action → re-resolve the anchor on the current
|
||||||
|
document; require an exact resolve (the ladder's text match — INV-11); call
|
||||||
|
the seam with the resolved range, the stored replacement, the stored agent
|
||||||
|
provenance, `expectedVersion = document.version` captured at that instant,
|
||||||
|
and the stored `turnId`. Seam success → attribution spans land (F3, zero new
|
||||||
|
code) → `store.update` removes the proposal → thread + decoration disposed.
|
||||||
|
Seam `false` (version race) → report, proposal stays pending.
|
||||||
|
- **PUC-3 (reject):** thread action → `store.update` removes the proposal →
|
||||||
|
UI disposed. No document interaction at all.
|
||||||
|
- **PUC-4 (persist / re-anchor / stale):** on load/external change, resolve
|
||||||
|
each proposal's anchor: clean resolve → render at the new range; text
|
||||||
|
mismatch or no match → stale/orphaned — thread labeled
|
||||||
|
("⚠ stale — target text changed"), accept action disabled-by-guard, status
|
||||||
|
bar counts it; a later edit restoring the text re-resolves it back to
|
||||||
|
decidable (recoverable, INV-1 spirit). Within a session, the thread's live
|
||||||
|
range shifts with edits (Comments API + `anchorer.shift`, the F2 pattern);
|
||||||
|
the exact-text guard runs at decision time regardless.
|
||||||
|
- **PUC-5 (coexist):** proposals are independent records on independent
|
||||||
|
anchors; accepting one shifts the others' live ranges via the normal change
|
||||||
|
event; order-independence falls out of per-proposal resolve-at-decision.
|
||||||
|
|
||||||
|
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||||
|
|
||||||
|
O(pending proposals) render/resolve cost — trivially small at inner-loop scale.
|
||||||
|
Proposal bodies add sidecar bytes proportional to proposed text (bounded: one
|
||||||
|
selection's replacement each; pending-only keeps the array short-lived).
|
||||||
|
Accept-time work is one resolve + one seam call. No new credential surface
|
||||||
|
(INV-8 untouched); proposed text is operator content, same sensitivity class as
|
||||||
|
the document itself. Skip non-text/oversized files (F2 rule). Live-turn
|
||||||
|
latency UX unchanged (progress notification); the version-race window narrows
|
||||||
|
to accept-click → applyEdit, guarded by `expectedVersion` as today.
|
||||||
|
|
||||||
|
### 6.7 Key decisions & alternatives considered
|
||||||
|
|
||||||
|
| Decision | Chosen | Alternatives rejected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `proposals[]` shape | Pending-only: anchored range + replacement + provenance (+ turnId, instruction); accept→attribution, reject→remove (INV-13) | status-field lifecycle records (state that can go stale; duplicates what removal expresses); event log of decisions (history is git's + the transcript's job; heavy churn) |
|
||||||
|
| Rendering primitive | Second Comments controller (anchored thread, fenced-diff body, ✓/✗ title actions) + amber range decoration | ghost-text decorations (effectively single-line; no action surface; can't show multi-line replacements); `vscode.diff` editor (leaves the buffer; heavyweight; no inline anchoring); refactor-preview panel (not inline; no persistence story; partial-confirm mismatches the seam registration); writing conflict-markers into the buffer (violates INV-10 — document must stay untouched) |
|
||||||
|
| Propose-by-default vs. direct mode | Wholesale flip: `editSelection` always proposes; **no user-facing direct mode** (the seam stays direct as accept path + hidden E2E command) | a `cowriting.machineEdits` setting (two modes to test/explain for no demonstrated need — YAGNI; revisit on friction); separate propose-vs-edit commands (same cost, plus gesture ambiguity) |
|
||||||
|
| Multi-range seam growth | **Not grown.** One proposal = one anchored range + one replacement; a multi-edit turn files N proposals sharing `turnId`; each accept is one single-range seam call | widening `applyAgentEdit` to ranges[] (touches the registry/minimization machinery F3 hardened, for a capability N-proposals already provides); atomic multi-range accept (no user story at 1-selection turns) |
|
||||||
|
| Accept/reject granularity | Whole-proposal (= per anchored range). Hunk-splitting deferred: the N-proposals-per-`turnId` model makes "split one turn into independently-decidable pieces" purely additive later | per-hunk decisions inside one proposal now (needs a real LCS/Myers diff + sub-hunk anchoring today, for the single-selection turn that mostly yields one coherent rewrite) |
|
||||||
|
| Staleness guard | The existing Anchorer exact-text resolve at decision time (INV-11) — fingerprint.text doubles as the base-text record | storing a separate baseText + custom comparison (duplicates the fingerprint); applying onto whatever the range now holds (silently overwrites newer human edits — unacceptable) |
|
||||||
|
| Persistence moment | At propose time (thread precedent) | save-time only, like attribution spans (a crash between turn and save discards a paid turn's output) |
|
||||||
|
|
||||||
|
### 6.8 Testing strategy
|
||||||
|
|
||||||
|
- **Unit (vitest, vscode-free):** `Proposal` model round-trip (serialize →
|
||||||
|
reload, stable formatting, prune interplay: proposal anchors retained,
|
||||||
|
decided-proposal anchors pruned when unreferenced); diff-body formatting;
|
||||||
|
stale-detection given (fingerprint, current text) pairs.
|
||||||
|
- **Host E2E (`@vscode/test-electron`, F2/F3 pattern, no LLM):** programmatic
|
||||||
|
`cowriting.proposeAgentEdit` → thread + decoration present **and document
|
||||||
|
text unchanged** (INV-10) → accept → text replaced, attribution spans agent
|
||||||
|
(`getSpans`), proposal gone → reject path → untouched + gone → propose →
|
||||||
|
save → reopen → still pending, re-anchored → edit target text → stale
|
||||||
|
(accept refused, status-bar count) → undo → decidable again → edit *above*
|
||||||
|
the target → shifted, still decidable → multiple pending proposals decided
|
||||||
|
out of order.
|
||||||
|
- **Live smoke (manual, documented — extends `docs/MANUAL-SMOKE-F3.md`):**
|
||||||
|
select → instruct → proposal appears (document untouched) → accept → lands
|
||||||
|
Claude-attributed. Signed-out failure path unchanged from F3.
|
||||||
|
|
||||||
|
### 6.9 Failure modes, rollback & flags
|
||||||
|
|
||||||
|
Stale/orphaned proposals — flagged, undecidable-as-accept, recoverable
|
||||||
|
(INV-11); reject always works. Accept-time version race — seam returns `false`,
|
||||||
|
reported, proposal stays pending (retryable). Crash mid-accept (seam applied,
|
||||||
|
removal not yet persisted) — benign on reload: the proposal resolves **stale**
|
||||||
|
against its own accepted replacement, is flagged, and the human rejects the
|
||||||
|
husk; the attribution record is already correct; never double-applies (INV-11).
|
||||||
|
Sidecar merge conflicts — plain JSON, human-resolvable (unchanged). No feature
|
||||||
|
flag: additive schema fill; an empty `proposals[]` is the off state; the
|
||||||
|
propose-by-default flip is the feature itself and shipping it is the rollout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Delivery Plan
|
||||||
|
|
||||||
|
### 7.1 Approach / strategy
|
||||||
|
|
||||||
|
One planning-and-executing session (F4 = #12), plan written just-in-time from
|
||||||
|
this spec, per the F2/F3 precedent.
|
||||||
|
|
||||||
|
### 7.2 Slicing plan
|
||||||
|
|
||||||
|
- **SLICE-1** Typed `proposals[]` model + store prune extension (+ round-trip
|
||||||
|
and prune unit tests).
|
||||||
|
- **SLICE-2** ProposalController core: programmatic ingress
|
||||||
|
(`proposeAgentEdit` + hidden command), persistence at propose time,
|
||||||
|
resolve/stale/orphan ladder on load/external change (+ unit tests on the
|
||||||
|
pure parts).
|
||||||
|
- **SLICE-3** Review UI: proposal threads (second Comments controller,
|
||||||
|
diff-fenced body, ✓/✗ title actions), amber pending-range decoration,
|
||||||
|
accept (fingerprint-guarded seam call) / reject wiring, status-bar
|
||||||
|
stale count.
|
||||||
|
- **SLICE-4** LiveTurn flip to propose-by-default; manual smoke doc updated
|
||||||
|
(propose → accept → attributed); graceful-failure paths re-verified.
|
||||||
|
- **SLICE-5** Host E2E per §6.8 (propose/accept/reject/persist/stale/shift/
|
||||||
|
coexist; INV-10 asserted) + README update.
|
||||||
|
|
||||||
|
E2E are first-class plan tasks (handbook §9 / §4; this app's required tier is
|
||||||
|
host E2E — a VS Code extension has no browser surface or deploy stage, the
|
||||||
|
F2/F3 precedent).
|
||||||
|
|
||||||
|
### 7.3 Rollout / launch plan
|
||||||
|
|
||||||
|
Still non-shippable (no marketplace publish). "Done" = issue #12 acceptance
|
||||||
|
met: unit + host E2E green, live smoke performed once on this machine showing
|
||||||
|
propose → accept → Claude-attributed.
|
||||||
|
|
||||||
|
### 7.4 Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| Accept applies over text that changed since proposing | INV-11: exact fingerprint-text resolve at decision time; stale = undecidable-as-accept, recoverable |
|
||||||
|
| Proposal UI confused with discussion threads | separate `CommentController` + distinct labels/icons + amber (not indigo) tint |
|
||||||
|
| Crash between seam apply and proposal removal | reload resolves the husk as stale against its own replacement; flagged, rejectable; never double-applies |
|
||||||
|
| Comments-API rendering limits for large diffs | fenced-diff markdown body (native rendering); selection-scoped turns bound size; diff body is presentation-only (truth is `fingerprint.text` → `replacement`) |
|
||||||
|
| Flipping propose-by-default breaks F3 E2E/smoke assumptions | seam + `cowriting.applyAgentEdit` command unchanged (F3 E2E untouched); only the `editSelection` tail moves; smoke doc updated in the same slice |
|
||||||
|
| Sidecar churn from abandoned proposals | pending-only array (INV-13); reject is one gesture; stale proposals are visible debt in the status bar, not silent rot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Traceability matrix
|
||||||
|
|
||||||
|
| Requirement (issue #12 acceptance) | Use case | Design | Slice |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Live turn yields a proposal, not a mutation; inline diff rendered | PUC-1 | INV-10, §6.2, §6.5 | SLICE-2/3/4 |
|
||||||
|
| Accept applies via the seam; spans land Claude-attributed | PUC-2 | INV-9/11/12, §6.4, §6.5 | SLICE-3 |
|
||||||
|
| Reject leaves the document untouched | PUC-3 | §6.5 | SLICE-3 |
|
||||||
|
| Pending proposals persist git-natively + reload | PUC-4 | INV-13, §6.3 | SLICE-1/2 |
|
||||||
|
| Proposals survive edits: re-anchor; stale/orphan never applied | PUC-4 | INV-11, Anchorer ladder | SLICE-2/3 |
|
||||||
|
| Unit + host E2E coverage, no LLM in CI | — | §6.8 | SLICE-1/2/5 |
|
||||||
|
|
||||||
|
## 9. Open Questions & Decisions log
|
||||||
|
|
||||||
|
- **RESOLVED (this session — the five forks routed by #12):**
|
||||||
|
(a) `proposals[]` = pending-only anchored-range + replacement + provenance
|
||||||
|
records (INV-13); (b) rendering = second Comments controller with fenced-diff
|
||||||
|
thread + amber range decoration; (c) propose-by-default **wholesale** — no
|
||||||
|
user-facing direct mode, the seam stays as accept path + E2E ingress;
|
||||||
|
(d) seam stays **single-range** — multi-edit turns are N proposals sharing
|
||||||
|
`turnId`; (e) granularity = whole-proposal accept/reject, hunk-splitting
|
||||||
|
additive later via the same N-proposals model. Plus: staleness guard =
|
||||||
|
fingerprint-text resolve at decision time (INV-11); persistence at propose
|
||||||
|
time.
|
||||||
|
- **OPEN → F5:** the cross-rung format may re-home `proposals[]` with the rest
|
||||||
|
of the sidecar; the proposed→ratified→attributed story is what crosses rungs.
|
||||||
|
- **OPEN → later:** hunk-splitting one turn into multiple proposals (needs a
|
||||||
|
real diff algorithm); proposal **counter-edits** (human amends the proposed
|
||||||
|
text before accepting — today: reject and re-instruct); a queue/peek UX when
|
||||||
|
many proposals are pending (status-bar count suffices at inner-loop scale);
|
||||||
|
whether rejected proposals deserve an optional history trail for F5.
|
||||||
|
|
||||||
|
## 10. Glossary & References
|
||||||
|
|
||||||
|
- **Proposal** — a pending machine edit: one anchored target range + one
|
||||||
|
proposed replacement + agent provenance; never in the document until
|
||||||
|
accepted. **Stale** — a pending proposal whose target text no longer matches
|
||||||
|
its fingerprint (undecidable-as-accept, recoverable). **Ratify** — the
|
||||||
|
human's accept gesture, the only path from proposal to document (INV-12).
|
||||||
|
**Husk** — a proposal left behind by a crash mid-accept; resolves stale
|
||||||
|
against its own replacement and is rejected by the human.
|
||||||
|
- Epic #1 · Feature #12 (F4) · F3 #6 (PR #7) · F2 #4 (PR #5) · parent specs
|
||||||
|
`coauthoring-inner-loop.md` + `coauthoring-attribution.md` · POC #2 ·
|
||||||
|
rfc-app#48/#46 (lineage).
|
||||||
Reference in New Issue
Block a user