@erdemtuna/doc-review 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
@@ -0,0 +1,130 @@
1
+ # Bounded reads and recovery
2
+
3
+ One agent interface limits final JSON stdout to 16 KiB UTF-8 including escaping
4
+ and newline. No compact/full or raw fallback exists. Durable evidence is not
5
+ truncated for delivery. Large values carry exact scoped references with
6
+ version/field identity, encoding, byte length and SHA-256. Previews are explicitly
7
+ incomplete. Original edit capture truncation is a separate fact.
8
+
9
+ Inventory/history/context/status blocker pages report `totalCount`,
10
+ `returnedCount`, `complete` and `nextCursor`. Continue the same command with
11
+ `--cursor <opaque-token>` until complete where full coverage is necessary.
12
+ Never assume the first page is the complete response obligation. The generated
13
+ template covers all items even when the inventory is paged.
14
+
15
+ ## Deliberate historical context
16
+
17
+ For a follow-up, start with the closest relevant earlier exchange using the
18
+ message item's `contextCommand` (default generated `--limit 1`):
19
+
20
+ ```sh
21
+ npx -y @erdemtuna/doc-review context --review <reviewId> --entry <entryKey> --submission <currentSubmissionId> --thread <threadId> --limit 1
22
+ ```
23
+
24
+ The producer reads immutable submissions, excludes current and later work and
25
+ saved-unsent replies before pagination, and provides historical lifecycle
26
+ evidence. Browser context intentionally still shows pending replies.
27
+ Do not load all threads' history speculatively.
28
+
29
+ For earlier overall notes, results, edits or lost chat context, use the
30
+ submission's `handoff.historyCommand`:
31
+
32
+ ```sh
33
+ npx -y @erdemtuna/doc-review history --review <reviewId> --entry <entryKey> --before <currentSubmissionId> --limit 1
34
+ npx -y @erdemtuna/doc-review submission --review <reviewId> --entry <entryKey> --submission <priorSubmissionId>
35
+ ```
36
+
37
+ History retains note intent, result notes and handled/abandoned evidence.
38
+ Submission reads include exact paged messages/edits, inline responses, edit
39
+ outcomes and receipt. Ended reviews and terminal submissions remain readable.
40
+ Earlier request-change intent is historical evidence, never new permission.
41
+
42
+ ## Exact content
43
+
44
+ Copy the reference's generated command. `field` is a scoped artifact path, not
45
+ a filesystem path. Never transplant a cursor into another scope/field/version.
46
+
47
+ ```sh
48
+ npx -y @erdemtuna/doc-review content --review <reviewId> --entry <entryKey> --submission <submissionId> --field submission/edits/0/content/after_html
49
+ ```
50
+
51
+ A content read gives exact `text`, encoding (`utf8` or serialized `json`),
52
+ UTF-8 byte `offset`, `returnedBytes`, complete-field byte length/hash,
53
+ `complete`, and `nextCursor`. Concatenate chunks in order until complete;
54
+ Unicode characters and line endings are preserved. Parse JSON only after
55
+ reconstruction. A continuation identity/content change is an error, not a splice.
56
+
57
+ For a complete field, append `--output-file <new-path>`. Omit `--field` to export
58
+ the whole submitted artifact (submission, result, receipt and canonical pages).
59
+ The receipt gives path/identity/encoding/bytes/hash. Writing is atomic/exclusive;
60
+ existing files and reviewed source are never overwritten. Read exported content
61
+ before using it. A file receipt alone does not prove inspection.
62
+
63
+ ## Identity, retries and End
64
+
65
+ Keep reviewId, entryKey, submissionId, delivered version, response file and stable
66
+ requestId across compaction. `status` reads lifecycle evidence without starting a
67
+ server or mutating offline state. It deliberately provides only result previews/
68
+ references; explicit content/history reads return requested prose when it fits.
69
+
70
+ ```sh
71
+ npx -y @erdemtuna/doc-review status --review <reviewId> --entry <entryKey>
72
+ npx -y @erdemtuna/doc-review receipt --review <reviewId> --entry <entryKey> --request-id <requestId>
73
+ ```
74
+
75
+ Status reports queued/delivered work, pending counts and predecessor/overlapping
76
+ blockers; `source:"disk"` means validated offline evidence, not agent liveness.
77
+ Corrupt/unsupported state fails explicitly, without legacy fallback.
78
+
79
+ Semantic rejection (exit 1), uncertain acceptance (exit 2), and accepted receipt
80
+ are different. `not-found` from receipt is not proof of rejection. Retry an
81
+ uncertain response with exactly the same file/requestId; do not regenerate the
82
+ template or repeat source edits. Inspect source and receipts before considering
83
+ any further filesystem work. One cooperating handler is assumed; no worker
84
+ leases or filesystem exactly-once guarantees exist.
85
+
86
+ End freezes reviewer content, not accepted work. Complete non-abandoned work
87
+ after End/restart; keep polling that review until `ended`. Saved-unsent content
88
+ does not transfer to a new review. Abandonment releases work exclusion but does
89
+ not cancel the external handler or undo source writes. `SUBMISSION_ABANDONED`
90
+ means stop handling, never retry as a new completion.
91
+
92
+ ## Reviewer interaction
93
+
94
+ The page starts in View. Feedback is nonmodal: it docks on roomy desktops and
95
+ floats on narrow PC windows, without dimming or disabling the document. Show in document
96
+ reveals the exact passage, hiding a floating panel only when it would obscure the
97
+ target. Focus and secondary thread actions are in the conversation menu.
98
+ Resolve/Reopen sits opposite Reply below an expanded conversation and remains in
99
+ Conversation actions when collapsed or a local draft is open. Resolve refuses unfinished drafts,
100
+ unsent messages and outstanding agent work. Resolve/Reopen do not show notifications.
101
+ Review-state transitions, especially receipt of an agent response, appear for five
102
+ seconds at the top left below the toolbar. Hover, notification focus or a hidden
103
+ tab pauses expiry; reading the document iframe does not. Focus includes a visible
104
+ Back to Feedback action; Open/Resolved are separate independent filters.
105
+ Sidebar headers end with Locate, More and Collapse; adjacent headers use Open in
106
+ Feedback (a right-pointing arrow), More and Close. Edit belongs beside each eligible
107
+ message's timestamp. The far-right delivery icon changes from Not sent to Sent,
108
+ then Received when the agent picks up the message or replies.
109
+ Resolved conversations automatically collapse and show a subdued check-circle
110
+ **Resolved** informational icon with a keyboard-visible explanation; they remain expandable
111
+ for reading. Reopen expands them again. Resolution from another tab never
112
+ hides a local draft.
113
+
114
+ Add comment / Add reply / Update comment queue a message;
115
+ **Send to agent (N)** dispatches a deliberate batch.
116
+ Send includes all saved pending messages and edits. **Your edits** contains exact
117
+ human edit evidence. **Note to agent** opens separately with its own unchecked
118
+ change permission. Unsaved comment drafts are not sent.
119
+ Send freezes the selected versions and note at activation; feedback arriving
120
+ during preparation stays pending. A changed selected version requires reviewing
121
+ the selection again.
122
+
123
+ Expanded conversations retain two recent sent exchanges and all unsent replies.
124
+ The preceding answer remains visible while composing a follow-up; **Show earlier
125
+ replies** loads older context. Closing Feedback or changing hosts preserves the
126
+ mounted editor and its local draft, but reloading a tab loses unsaved drafts.
127
+ Long result summaries expand with **Read more / Show less**. **Replies (N)**
128
+ lists source labels and comment excerpts. The reply reader retains the
129
+ Feedback/History tabs, **Previous/Next** and **Back to replies**, restoring the
130
+ originating list, scroll and keyboard focus without discarding drafts.
@@ -0,0 +1,108 @@
1
+ # Complete responses
2
+
3
+ Generate a new response file using the actual submission handoff:
4
+
5
+ ```sh
6
+ npx -y @erdemtuna/doc-review response-template --review <reviewId> --entry <entryKey> --submission <submissionId> --output-file response.json
7
+ ```
8
+
9
+ This reads the authoritative immutable submission, not just the first inventory
10
+ page. It includes every message/edit and exact version, delivered expectedVersion,
11
+ and a stable requestId. The file is exclusive: an existing response is never
12
+ overwritten. Inspect/reuse it for recovery; do not generate a new ID for a retry.
13
+ All blank prose/outcomes deliberately fail validation until filled. The command
14
+ does not edit source or assert that anything was applied.
15
+
16
+ The JSON contract is strict (unknown fields are rejected):
17
+
18
+ ```json
19
+ {
20
+ "operation": "respond",
21
+ "reviewId": "review-id",
22
+ "entryKey": "entry-key",
23
+ "submissionId": "submission-id",
24
+ "expectedVersion": 2,
25
+ "requestId": "stable-request-id",
26
+ "responses": [{
27
+ "threadId": "thread-id",
28
+ "messageId": "message-id",
29
+ "messageVersion": 1,
30
+ "body": "The explanation preserves the original meaning.",
31
+ "outcome": "answered"
32
+ }],
33
+ "editOutcomes": [{
34
+ "editId": "edit-id",
35
+ "editVersion": 1,
36
+ "outcome": "deferred",
37
+ "reason": "The source target is ambiguous; please identify the component."
38
+ }],
39
+ "overallOutcome": "answered",
40
+ "summary": "Answered the questions; one edit needs a clearer target.",
41
+ "resultNote": "Answered the discussion and overall note; deferred the ambiguous edit."
42
+ }
43
+ ```
44
+
45
+ Keep the generated IDs, versions and requestId. `responses` must cover every
46
+ submitted message exactly once, not just visible inventory items. Each response
47
+ has a nonempty body and scalar outcome: `answered`, `applied`,
48
+ `clarification-needed`, or `deferred`. Only that message's request-change permits
49
+ `applied`; another message or overall note cannot grant it permission.
50
+
51
+ `editOutcomes` covers each exact edit version once. Outcome is `applied`,
52
+ `already-saved`, or `deferred`, with nonempty `reason`. `already-saved` requires
53
+ server save evidence. Saved or capture-truncated edits cannot be newly `applied`.
54
+ Use truthful deferrals instead of fabricated success to satisfy coverage.
55
+
56
+ `overallOutcome` is a **scalar string**, not an object. Include it if and only if
57
+ the submission has an overall note. Its enum is `applied`, `answered`,
58
+ `clarification-needed`, `deferred`; `applied` requires that note's request-change.
59
+ The overall answer's prose belongs in `resultNote`. That independent, nonempty
60
+ result note is always required, even without an overall note.
61
+
62
+ `summary` is an authored orientation sentence or two, separate from the full
63
+ `resultNote`. Fill it in new templates. Older responses and records may omit it;
64
+ when present it must be nonempty. It grants no permission and replaces neither
65
+ message coverage nor edit outcomes. Its exact text participates in receipt replay:
66
+ do not revise it under an accepted requestId. Long summaries use the usual scoped
67
+ content references and export, including `--field result/summary`.
68
+
69
+ Write answer/outcome first. Routine replies usually need one or two sentences
70
+ and at most three useful bullets (roughly 40-90 words is guidance, not a limit).
71
+ Lead clarification with the exact question. Use Markdown for readable saved
72
+ prose, but avoid IDs/hashes, repetitive preservation boilerplate, or duplicating
73
+ all replies in the summary. Edit reasons explain the outcome, not the full evidence.
74
+
75
+ A note-only response has empty `responses` and `editOutcomes` arrays, for example:
76
+
77
+ ```json
78
+ {
79
+ "operation": "respond",
80
+ "reviewId": "review-id",
81
+ "entryKey": "entry-key",
82
+ "submissionId": "submission-id",
83
+ "expectedVersion": 2,
84
+ "requestId": "stable-request-id",
85
+ "responses": [],
86
+ "editOutcomes": [],
87
+ "overallOutcome": "answered",
88
+ "summary": "Explained the tradeoff; no source change was requested.",
89
+ "resultNote": "The earlier result explained the tradeoff; no new source change was requested."
90
+ }
91
+ ```
92
+
93
+ Submit the filled file:
94
+
95
+ ```sh
96
+ npx -y @erdemtuna/doc-review respond --review <reviewId> --entry <entryKey> --response-file response.json --timeout 600
97
+ ```
98
+
99
+ `{ok:true,receipt}` means the complete response persisted atomically. Comparison
100
+ capture does not gate acceptance. Reply-only work does not fabricate a version or
101
+ reload; saved human changes can display "What changed" without new agent edits.
102
+ Then poll the original review again.
103
+
104
+ Exit 1 is a semantic/explicit failure. Correct a rejected response deliberately;
105
+ do not turn a rejection into success. Exit 2 (`state:"unknown"`) means acceptance
106
+ is uncertain. The CLI already retries the same logical body within its deadline.
107
+ Retry the identical response file, not source work or a newly minted requestId.
108
+ See [context-and-recovery](context-and-recovery.md) before recovering uncertainty.
@@ -0,0 +1,47 @@
1
+ # Exact source edits
2
+
3
+ Before source work, inspect every necessary inventory/content page and the actual
4
+ authoritative source. A downloaded/exported file is not evidence you inspected it.
5
+ `inline.value` is the complete retained object; a `reference` is only a delivery
6
+ pointer. Follow its command or export and read the artifact. A preview is never an
7
+ `after` replacement. Fields remain exact, including line endings and Unicode.
8
+
9
+ Every submitted message carries independent `discuss` or `request-change` intent.
10
+ Discuss is answered without editing for that message. Request-change permits,
11
+ but does not require, only its specific change. An overall note is independent;
12
+ neither it nor a historical request grants blanket permission.
13
+
14
+ Human direct edits are exact requested content separate from message intent.
15
+ Preserve non-truncated `after` wording verbatim. Translate HTML formatting into
16
+ the true source syntax, retain insertion points/assets, honor moves'
17
+ `moved_after`/`moved_before` boundaries and explicit deletions. Read all
18
+ representations needed to understand the edit, not merely a convenient preview.
19
+
20
+ The edit's `source` data contains `state:"pending"` or `state:"saved"` plus
21
+ server-owned evidence: review, page, edit ID/version, source hash and saved time.
22
+ Never reapply a saved edit. Use `already-saved` only with that exact evidence.
23
+ Report inconsistent source/evidence instead of guessing that a write succeeded.
24
+
25
+ Inventory page entries identify real files/URLs. For source-pending Markdown,
26
+ scripted HTML and localhost pages, find actual Markdown/MDX/TSX/template/component
27
+ source; do not replace source with rendered Markdown, fetched HTTP, or
28
+ script-generated DOM. Plain HTML alone supports the reviewer's direct save.
29
+ Disabling scripts is not permission to write source.
30
+
31
+ Use exact target anchors and authoritative source to identify the intended
32
+ location. Missing/ambiguous targets require safe identification or a
33
+ clarification/deferred outcome; never guess a destructive replacement.
34
+ Copy authoritative staged assets from their retained paths into the correct
35
+ project asset location and replace temporary preview references without losing
36
+ the original insertion point.
37
+
38
+ `captureTruncated` and `truncatedFields` mirror original `content.truncated` and
39
+ `content.truncated_fields`. These mean evidence was clipped during capture
40
+ (currently 200,000 Unicode code points per text/HTML/move field), not during
41
+ delivery. Delivery references do NOT clear those flags. Never apply incomplete
42
+ text/HTML as a complete replacement or invent missing text. Recover only from an
43
+ authoritative source or defer and request the complete edit.
44
+
45
+ Inspect current source and durable evidence after a restart or uncertain
46
+ response. Receipt replay protects durable responses, not filesystem operations;
47
+ never repeat source writes simply because an HTTP connection failed.
@@ -14,7 +14,7 @@ function captureTime(value, now) {
14
14
  return time;
15
15
  }
16
16
  export class RevisionStore {
17
- constructor({ root = path.join(stateDir(), "history"), write = atomicWrite, limits = REVISION_LIMITS } = {}) {
17
+ constructor({ root = path.join(stateDir(), "conversation-history"), write = atomicWrite, limits = REVISION_LIMITS } = {}) {
18
18
  this.root = root;
19
19
  this.write = write;
20
20
  this.limits = normalizeRevisionLimits(limits);
@@ -1,8 +1,8 @@
1
- import { decodePage, record } from "./chrome-api.js";
1
+ import { ApiError, decodePage, record, saveFailureMessage } from "./chrome-api.js";
2
2
  import { createControllerStore } from "./controller-store.js";
3
- export function createSaveController({ sessionId, current, policy, request, flush, send, sourceHash, pageChanged, conflict, failed, diagnostic, sending, clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
3
+ export function createSaveController({ sessionId, current, policy, request, flush, send, sourceHash, pageChanged, conflict, failed, diagnostic, sending, conversation, clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
4
4
  const state = {
5
- status: "idle", savedAt: "", baseHash: null, conflict: false, dirty: false, dynamic: false,
5
+ status: "idle", savedAt: "", baseHash: null, conflict: false, conflictMessage: "", dirty: false, dynamic: false,
6
6
  };
7
7
  const store = createControllerStore(() => state);
8
8
  const pipelines = new Map();
@@ -67,14 +67,14 @@ export function createSaveController({ sessionId, current, policy, request, flus
67
67
  if (disposed)
68
68
  return false;
69
69
  try {
70
- const result = record(await request(`/api/page/${key}/edit`, {
70
+ const result = conversation ? await conversation.record(key, original) : record(await request(`/api/page/${key}/edit`, {
71
71
  method: "POST", body: JSON.stringify(payload),
72
72
  }));
73
73
  if (ownedBacklog.get(id) === payload)
74
74
  ownedBacklog.delete(id);
75
75
  errors.delete(key);
76
- if (matches(identity))
77
- pageChanged(decodePage(result.page));
76
+ if (!conversation && matches(identity))
77
+ pageChanged(decodePage(record(result).page));
78
78
  return true;
79
79
  }
80
80
  catch (error) {
@@ -100,6 +100,8 @@ export function createSaveController({ sessionId, current, policy, request, flus
100
100
  if (disposed)
101
101
  throw new Error("Review ended");
102
102
  if (backlogs.get(key)?.size) {
103
+ if (conversation)
104
+ throw errors.get(key) || new Error("Reconcile the failed edit before continuing.");
103
105
  const retry = [...(backlogs.get(key)?.values() || [])];
104
106
  errors.delete(key);
105
107
  for (const payload of retry)
@@ -122,7 +124,9 @@ export function createSaveController({ sessionId, current, policy, request, flus
122
124
  return false;
123
125
  }
124
126
  try {
125
- const result = record(await request(`/api/page/${identity.key}/save`, {
127
+ if (conversation)
128
+ await settleEdits(identity.key);
129
+ const result = conversation ? await conversation.save(identity.key, html, state.baseHash) : record(await request(`/api/page/${identity.key}/save`, {
126
130
  method: "POST",
127
131
  body: JSON.stringify({
128
132
  html, baseHash: state.baseHash, renderId: identity.renderId,
@@ -149,19 +153,20 @@ export function createSaveController({ sessionId, current, policy, request, flus
149
153
  state.baseHash = null;
150
154
  state.status = "idle";
151
155
  state.conflict = true;
156
+ state.conflictMessage = saveFailureMessage(error instanceof ApiError ? error.saveReason : undefined);
152
157
  publish();
153
- conflict();
158
+ conflict(state.conflictMessage);
154
159
  diagnostic("save-conflict");
155
160
  return false;
156
161
  }
157
162
  state.status = "failed";
158
163
  publish();
159
- if (attempts < 2) {
164
+ if (!conversation && attempts < 2) {
160
165
  await delay(500);
161
166
  return saveHtml(html, identity, attempts + 1);
162
167
  }
163
168
  if (!sending())
164
- failed("Couldn't save. Retry before sending feedback; your edits remain on this page.");
169
+ failed(conversation && error instanceof Error ? error.message : "Couldn't save. Retry before sending feedback; your edits remain on this page.");
165
170
  return false;
166
171
  }
167
172
  }
@@ -192,7 +197,7 @@ export function createSaveController({ sessionId, current, policy, request, flus
192
197
  const identity = current();
193
198
  await flushEdits(true);
194
199
  await activeSave;
195
- if (state.status === "failed" && !state.conflict && lastSave && matches(lastSave.identity)) {
200
+ if (!conversation && state.status === "failed" && !state.conflict && lastSave && matches(lastSave.identity)) {
196
201
  await save(lastSave.html);
197
202
  }
198
203
  await settleEdits(identity.key);
@@ -200,7 +205,7 @@ export function createSaveController({ sessionId, current, policy, request, flus
200
205
  if (!matches(identity))
201
206
  throw new Error("The page changed while saving. Retry on the latest page.");
202
207
  if (state.conflict)
203
- throw new Error("Resolve the save conflict: the source changed outside this review. Reload latest; your comment drafts will be kept.");
208
+ throw new Error(`Resolve the save conflict: ${state.conflictMessage || saveFailureMessage()}`);
204
209
  if (state.status === "failed" || (policy() === "writable" && !state.dynamic && state.dirty)) {
205
210
  throw new Error("Your page edits have not finished saving. They have not been sent.");
206
211
  }
@@ -233,9 +238,18 @@ export function createSaveController({ sessionId, current, policy, request, flus
233
238
  save, persistEdit, settleEdits, flush: flushEdits, barrier, captureStable,
234
239
  applyClean, queued, cancelRetries,
235
240
  async settled() { await activeSave; },
241
+ async discardLocal(key) {
242
+ if (!key)
243
+ return;
244
+ await pipelines.get(key);
245
+ await activeSave;
246
+ backlogs.delete(key);
247
+ errors.delete(key);
248
+ },
236
249
  markEdit() { editSequence++; },
237
250
  markSaving() { state.dirty = true; state.status = "saving"; publish(); },
238
- markDynamic() { state.dynamic = true; publish(); },
251
+ markDynamic() { state.dynamic = true; if (state.status === "saving" && !pending)
252
+ state.status = "idle"; publish(); },
239
253
  observeClean() { clean = { identity: current(), sequence, editSequence }; applyClean(); },
240
254
  baseline(hash) { state.baseHash = hash; publish(); },
241
255
  hold() {
@@ -252,7 +266,7 @@ export function createSaveController({ sessionId, current, policy, request, flus
252
266
  clean = null;
253
267
  lastSave = null;
254
268
  sequence++;
255
- Object.assign(state, { status: "idle", savedAt: "", baseHash: null, conflict: false, dirty: false, dynamic: false });
269
+ Object.assign(state, { status: "idle", savedAt: "", baseHash: null, conflict: false, conflictMessage: "", dirty: false, dynamic: false });
256
270
  publish();
257
271
  },
258
272
  async revert() {