@magic-markdown/cli 0.3.12 → 0.3.17

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 (2) hide show
  1. package/dist/index.js +1117 -408
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -270,8 +270,8 @@ var require_punycode = __commonJS({
270
270
  });
271
271
 
272
272
  // src/index.ts
273
- import { readFile as readFile6 } from "node:fs/promises";
274
- import { resolve as resolve5 } from "node:path";
273
+ import { readFile as readFile7 } from "node:fs/promises";
274
+ import { resolve as resolve6 } from "node:path";
275
275
 
276
276
  // ../core/src/types.ts
277
277
  var SIDECAR_SCHEMA_VERSION = 1;
@@ -339,6 +339,7 @@ var POLLUTING_PATTERNS = [
339
339
  { name: "CriticMarkup substitution", pattern: /\{~~[\s\S]*?~~\}/ },
340
340
  { name: "CriticMarkup comment", pattern: /\{>>[\s\S]*?<<\}/ },
341
341
  { name: "mdocs HTML marker", pattern: /<!--\s*mdocs:/i },
342
+ { name: "mdocs suggestion marker", pattern: /<!--\s*\/?\s*mdocs-suggestion\b/i },
342
343
  { name: "mdocs directive", pattern: /:{1,3}(?:comment|suggestion|anchor)\b/i }
343
344
  ];
344
345
  function findCleanMarkdownViolations(markdown) {
@@ -405,7 +406,7 @@ function replacementLinesForRange(replacement) {
405
406
  function extractMarkdownImages(markdown, documentPath) {
406
407
  const masked = maskCode(markdown);
407
408
  const referenceDefinitions = collectReferenceDefinitions(markdown, masked);
408
- const lineStarts = getLineStarts(markdown);
409
+ const lineStarts2 = getLineStarts(markdown);
409
410
  const images = [];
410
411
  let index = 0;
411
412
  while (index < masked.length) {
@@ -423,7 +424,7 @@ function extractMarkdownImages(markdown, documentPath) {
423
424
  }
424
425
  const alt = markdown.slice(labelOpen + 1, labelClose);
425
426
  const afterLabel = labelClose + 1;
426
- const position = positionAt(lineStarts, start);
427
+ const position = positionAt(lineStarts2, start);
427
428
  if (masked[afterLabel] === "(") {
428
429
  const destinationClose = findClosingParen(markdown, afterLabel + 1);
429
430
  if (destinationClose === -1) {
@@ -491,7 +492,7 @@ function extractMarkdownLinks(markdown) {
491
492
  const codeMasked = maskCode(markdown);
492
493
  const referenceDefinitions = collectReferenceDefinitions(markdown, codeMasked);
493
494
  const masked = maskReferenceDefinitions(markdown, codeMasked);
494
- const lineStarts = getLineStarts(markdown);
495
+ const lineStarts2 = getLineStarts(markdown);
495
496
  const links = [];
496
497
  let index = 0;
497
498
  while (index < masked.length) {
@@ -508,7 +509,7 @@ function extractMarkdownLinks(markdown) {
508
509
  }
509
510
  const label = markdown.slice(start + 1, labelClose);
510
511
  const afterLabel = labelClose + 1;
511
- const position = positionAt(lineStarts, start);
512
+ const position = positionAt(lineStarts2, start);
512
513
  if (masked[afterLabel] === "(") {
513
514
  const destinationClose = findClosingParen(markdown, afterLabel + 1);
514
515
  if (destinationClose === -1) {
@@ -564,7 +565,7 @@ function extractMarkdownLinks(markdown) {
564
565
  }
565
566
  index = labelClose + 1;
566
567
  }
567
- extractWikilinks(markdown, masked, lineStarts).forEach((link2) => links.push(link2));
568
+ extractWikilinks(markdown, masked, lineStarts2).forEach((link2) => links.push(link2));
568
569
  return links.sort((left, right) => left.line - right.line || left.column - right.column);
569
570
  }
570
571
  function toImageReference(image2) {
@@ -591,7 +592,7 @@ function toLinkReference(link2) {
591
592
  syntax: link2.syntax
592
593
  };
593
594
  }
594
- function extractWikilinks(markdown, masked, lineStarts) {
595
+ function extractWikilinks(markdown, masked, lineStarts2) {
595
596
  const links = [];
596
597
  let index = 0;
597
598
  while (index < masked.length) {
@@ -608,7 +609,7 @@ function extractWikilinks(markdown, masked, lineStarts) {
608
609
  }
609
610
  const parsed = parseWikilinkBody(markdown.slice(start + 2, close2));
610
611
  if (parsed) {
611
- const position = positionAt(lineStarts, start);
612
+ const position = positionAt(lineStarts2, start);
612
613
  links.push({
613
614
  label: parsed.label ?? parsed.target,
614
615
  target: parsed.target,
@@ -888,12 +889,12 @@ function getLineStarts(value) {
888
889
  }
889
890
  return starts;
890
891
  }
891
- function positionAt(lineStarts, index) {
892
+ function positionAt(lineStarts2, index) {
892
893
  let low = 0;
893
- let high = lineStarts.length - 1;
894
+ let high = lineStarts2.length - 1;
894
895
  while (low <= high) {
895
896
  const middle = Math.floor((low + high) / 2);
896
- const start = lineStarts[middle] ?? 0;
897
+ const start = lineStarts2[middle] ?? 0;
897
898
  if (start <= index) {
898
899
  low = middle + 1;
899
900
  } else {
@@ -903,7 +904,7 @@ function positionAt(lineStarts, index) {
903
904
  const lineIndex = Math.max(0, high);
904
905
  return {
905
906
  line: lineIndex + 1,
906
- column: index - (lineStarts[lineIndex] ?? 0) + 1
907
+ column: index - (lineStarts2[lineIndex] ?? 0) + 1
907
908
  };
908
909
  }
909
910
 
@@ -918,7 +919,8 @@ function selectorFromRange(markdown, range) {
918
919
  function remapAnchor(markdown, anchor) {
919
920
  const lines = getLines(markdown);
920
921
  const quoteLines = anchor.selector.quote.split(/\r?\n/);
921
- const best = findQuote(lines, quoteLines);
922
+ const candidates = findQuoteCandidates(lines, quoteLines);
923
+ const best = bestCandidate(lines, candidates, anchor.selector);
922
924
  if (!best) {
923
925
  return {
924
926
  ...anchor,
@@ -939,284 +941,494 @@ function remapAnchor(markdown, anchor) {
939
941
  function remapAnchors(markdown, anchors) {
940
942
  return anchors.map((anchor) => remapAnchor(markdown, anchor));
941
943
  }
942
- function findQuote(lines, quoteLines) {
943
- if (quoteLines.length === 0) return void 0;
944
+ function findQuoteCandidates(lines, quoteLines) {
945
+ if (quoteLines.length === 0) return [];
946
+ const ranges = [];
947
+ const quoteText = quoteLines.join("\n");
944
948
  for (let index = 0; index <= lines.length - quoteLines.length; index += 1) {
945
949
  const candidate = lines.slice(index, index + quoteLines.length);
946
- if (candidate.join("\n") === quoteLines.join("\n")) {
947
- return { startLine: index + 1, endLine: index + quoteLines.length };
948
- }
950
+ if (candidate.join("\n") === quoteText) ranges.push({ startLine: index + 1, endLine: index + quoteLines.length });
949
951
  }
950
- return void 0;
952
+ return ranges;
953
+ }
954
+ function bestCandidate(lines, candidates, selector) {
955
+ if (candidates.length <= 1) return candidates[0];
956
+ const scored = candidates.map((range) => ({ range, score: scoreContext(lines, range.startLine, range.endLine, selector) })).sort((left, right) => right.score - left.score);
957
+ const best = scored[0];
958
+ if (!best) return void 0;
959
+ const tied = scored.filter((candidate) => candidate.score === best.score);
960
+ return tied.length === 1 ? best.range : void 0;
951
961
  }
952
962
  function scoreContext(lines, startLine, endLine, selector) {
953
963
  let score = 0.7;
954
- if (selector.prefix) {
955
- const expectedPrefix = selector.prefix.split(/\r?\n/).filter(Boolean).slice(-3).join("\n");
956
- const actualPrefix = lines.slice(Math.max(0, startLine - 4), startLine - 1).join("\n");
964
+ const expectedPrefix = contextPrefix(selector.prefix);
965
+ if (expectedPrefix) {
966
+ const actualPrefix = contextPrefix(lines.slice(Math.max(0, startLine - 4), startLine - 1).join("\n"));
957
967
  if (expectedPrefix && actualPrefix.endsWith(expectedPrefix)) score += 0.15;
958
968
  }
959
- if (selector.suffix) {
960
- const expectedSuffix = selector.suffix.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n");
961
- const actualSuffix = lines.slice(endLine, endLine + 3).join("\n");
969
+ const expectedSuffix = contextSuffix(selector.suffix);
970
+ if (expectedSuffix) {
971
+ const actualSuffix = contextSuffix(lines.slice(endLine, endLine + 3).join("\n"));
962
972
  if (expectedSuffix && actualSuffix.startsWith(expectedSuffix)) score += 0.15;
963
973
  }
964
974
  return Math.min(1, score);
965
975
  }
976
+ function contextPrefix(value) {
977
+ return value?.split(/\r?\n/).filter(Boolean).slice(-3).join("\n") ?? "";
978
+ }
979
+ function contextSuffix(value) {
980
+ return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") ?? "";
981
+ }
966
982
 
967
- // ../core/src/text-merge.ts
968
- var MAX_DIFF_CELLS = 25e6;
969
- function mergeText(base, ours, theirs) {
970
- if (ours === theirs) return { ok: true, text: ours };
971
- if (base === ours) return { ok: true, text: theirs };
972
- if (base === theirs) return { ok: true, text: ours };
973
- const baseLines = base.split("\n");
974
- const ourLines = ours.split("\n");
975
- const theirLines = theirs.split("\n");
976
- let regionsA;
977
- let regionsB;
978
- try {
979
- regionsA = diffTextRegions(baseLines, ourLines);
980
- regionsB = diffTextRegions(baseLines, theirLines);
981
- } catch {
982
- return { ok: false, reason: "too_large" };
983
- }
984
- const clusters = clusterRegions(regionsA, regionsB);
985
- const merged = [];
986
- let baseCursor = 0;
987
- for (const cluster of clusters) {
988
- for (let line = baseCursor; line < cluster.lo; line += 1) merged.push(baseLines[line]);
989
- const baseText = baseLines.slice(cluster.lo, cluster.hi);
990
- const ourText = sideSlice(ourLines, regionsA, cluster.lo, cluster.hi);
991
- const theirText = sideSlice(theirLines, regionsB, cluster.lo, cluster.hi);
992
- const oursChanged = !linesEqual(ourText, baseText);
993
- const theirsChanged = !linesEqual(theirText, baseText);
994
- if (oursChanged && theirsChanged && !linesEqual(ourText, theirText)) {
995
- return { ok: false, reason: "overlapping_changes" };
996
- }
997
- merged.push(...oursChanged ? ourText : theirText);
998
- baseCursor = cluster.hi;
999
- }
1000
- for (let line = baseCursor; line < baseLines.length; line += 1) merged.push(baseLines[line]);
1001
- return { ok: true, text: merged.join("\n") };
1002
- }
1003
- function diffTextRegions(base, side) {
1004
- let prefix = 0;
1005
- while (prefix < base.length && prefix < side.length && base[prefix] === side[prefix]) prefix += 1;
1006
- let suffix = 0;
1007
- while (suffix < base.length - prefix && suffix < side.length - prefix && base[base.length - 1 - suffix] === side[side.length - 1 - suffix]) {
1008
- suffix += 1;
1009
- }
1010
- const baseCore = base.slice(prefix, base.length - suffix);
1011
- const sideCore = side.slice(prefix, side.length - suffix);
1012
- if (!baseCore.length && !sideCore.length) return [];
1013
- if ((baseCore.length + 1) * (sideCore.length + 1) > MAX_DIFF_CELLS) throw new Error("diff too large");
1014
- const rows = baseCore.length + 1;
1015
- const cols = sideCore.length + 1;
1016
- const lcs = new Uint32Array(rows * cols);
1017
- for (let row2 = baseCore.length - 1; row2 >= 0; row2 -= 1) {
1018
- for (let col2 = sideCore.length - 1; col2 >= 0; col2 -= 1) {
1019
- lcs[row2 * cols + col2] = baseCore[row2] === sideCore[col2] ? lcs[(row2 + 1) * cols + col2 + 1] + 1 : Math.max(lcs[(row2 + 1) * cols + col2], lcs[row2 * cols + col2 + 1]);
1020
- }
1021
- }
1022
- const regions = [];
1023
- let row = 0;
1024
- let col = 0;
1025
- let pendingBase = -1;
1026
- let pendingSide = -1;
1027
- const flush = (baseEnd, sideEnd) => {
1028
- if (pendingBase < 0) return;
1029
- regions.push({
1030
- baseStart: prefix + pendingBase,
1031
- baseLength: baseEnd - pendingBase,
1032
- sideStart: prefix + pendingSide,
1033
- sideLength: sideEnd - pendingSide
1034
- });
1035
- pendingBase = -1;
1036
- pendingSide = -1;
983
+ // ../core/src/review-markdown.ts
984
+ var START_MARKER = "<!--mdocs-suggestion";
985
+ var END_MARKER = "<!--/mdocs-suggestion-->";
986
+ function parseReviewMarkdown(reviewMarkdown) {
987
+ const segments = [];
988
+ const suggestions = [];
989
+ let cursor = 0;
990
+ let canonicalOffset = 0;
991
+ while (cursor < reviewMarkdown.length) {
992
+ const markerStart = reviewMarkdown.indexOf(START_MARKER, cursor);
993
+ if (markerStart === -1) {
994
+ appendTextSegment(reviewMarkdown, segments, cursor, reviewMarkdown.length, canonicalOffset);
995
+ break;
996
+ }
997
+ appendTextSegment(reviewMarkdown, segments, cursor, markerStart, canonicalOffset);
998
+ canonicalOffset += markerStart - cursor;
999
+ const startClose = reviewMarkdown.indexOf("-->", markerStart);
1000
+ if (startClose === -1) throw invalidReviewMarkdown("Unclosed mdocs suggestion marker.");
1001
+ const attrs2 = parseMarkerAttributes(reviewMarkdown.slice(markerStart + START_MARKER.length, startClose));
1002
+ if (!attrs2.id) throw invalidReviewMarkdown("Suggestion marker is missing an id.");
1003
+ if (attrs2.kind !== "insert" && attrs2.kind !== "delete" && attrs2.kind !== "replace") {
1004
+ throw invalidReviewMarkdown(`Suggestion ${attrs2.id} has an invalid kind.`);
1005
+ }
1006
+ const bodyStart = startClose + 3;
1007
+ const markerEnd = reviewMarkdown.indexOf(END_MARKER, bodyStart);
1008
+ if (markerEnd === -1) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} is missing its closing marker.`);
1009
+ const body = reviewMarkdown.slice(bodyStart, markerEnd);
1010
+ if (body.includes(START_MARKER)) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} contains a nested suggestion.`);
1011
+ const parsedBody = parseSuggestionBody(attrs2.kind, body, attrs2.id);
1012
+ const token = {
1013
+ id: attrs2.id,
1014
+ kind: attrs2.kind,
1015
+ form: parsedBody.before.includes("\n") || parsedBody.after.includes("\n") ? "block" : "inline",
1016
+ author: actorFromAttributes(attrs2),
1017
+ message: attrs2.message,
1018
+ createdAt: attrs2.createdAt,
1019
+ updatedAt: attrs2.updatedAt,
1020
+ before: parsedBody.before,
1021
+ after: parsedBody.after,
1022
+ markerStart,
1023
+ markerEnd: markerEnd + END_MARKER.length,
1024
+ canonicalStart: canonicalOffset,
1025
+ canonicalEnd: canonicalOffset + parsedBody.before.length,
1026
+ raw: reviewMarkdown.slice(markerStart, markerEnd + END_MARKER.length)
1027
+ };
1028
+ segments.push({ type: "suggestion", token });
1029
+ suggestions.push(token);
1030
+ canonicalOffset += token.before.length;
1031
+ cursor = token.markerEnd;
1032
+ }
1033
+ return {
1034
+ reviewMarkdown,
1035
+ canonicalMarkdown: canonicalFromSegments(segments, statusLookup(void 0)),
1036
+ segments,
1037
+ suggestions
1037
1038
  };
1038
- while (row < baseCore.length || col < sideCore.length) {
1039
- if (row < baseCore.length && col < sideCore.length && baseCore[row] === sideCore[col]) {
1040
- flush(row, col);
1041
- row += 1;
1042
- col += 1;
1043
- } else {
1044
- if (pendingBase < 0) {
1045
- pendingBase = row;
1046
- pendingSide = col;
1047
- }
1048
- if (col >= sideCore.length || row < baseCore.length && lcs[(row + 1) * cols + col] >= lcs[row * cols + col + 1]) {
1049
- row += 1;
1050
- } else {
1051
- col += 1;
1052
- }
1039
+ }
1040
+ function projectCanonicalMarkdown(reviewMarkdown, sidecarStatuses) {
1041
+ return canonicalFromSegments(parseReviewMarkdown(reviewMarkdown).segments, statusLookup(sidecarStatuses));
1042
+ }
1043
+ function deriveSidecarFromReviewMarkdown(sidecar, reviewMarkdown, options) {
1044
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1045
+ const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
1046
+ const existingSuggestions = new Map(sidecar.suggestions.map((suggestion) => [suggestion.id, suggestion]));
1047
+ const existingAnchors = new Map(sidecar.anchors.map((anchor) => [anchor.id, anchor]));
1048
+ const anchors = sidecar.anchors.filter((anchor) => anchor.kind !== "suggestion");
1049
+ const suggestions = [];
1050
+ const base = { contentHash: contentHashForText(parsed.canonicalMarkdown), ...options.baseHead ? { head: options.baseHead } : {} };
1051
+ for (const token of parsed.suggestions) {
1052
+ const existing = existingSuggestions.get(token.id);
1053
+ const anchorId = existing?.anchorId ?? stableAnchorIdForSuggestion(token.id);
1054
+ const range = lineRangeForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
1055
+ const selector = selectorForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
1056
+ const anchor = {
1057
+ ...existingAnchors.get(anchorId) ?? {},
1058
+ id: anchorId,
1059
+ kind: "suggestion",
1060
+ status: "mapped",
1061
+ selector,
1062
+ range,
1063
+ confidence: 1,
1064
+ updatedAt: now
1065
+ };
1066
+ const author = token.author ?? existing?.author ?? options.author;
1067
+ const message = token.message ?? existing?.message ?? "Suggested edit";
1068
+ const suggestion = {
1069
+ ...existing ?? {},
1070
+ id: token.id,
1071
+ anchorId,
1072
+ status: "open",
1073
+ kind: token.kind,
1074
+ author,
1075
+ message,
1076
+ patch: {
1077
+ type: "replace_lines",
1078
+ range,
1079
+ before: token.before,
1080
+ after: token.after
1081
+ },
1082
+ base: existing?.base ?? base,
1083
+ diff: {
1084
+ before: token.before,
1085
+ after: token.after,
1086
+ summary: diffSummary(token.before, token.after)
1087
+ },
1088
+ projectionStatus: "clean",
1089
+ reviewRange: { start: token.markerStart, end: token.markerEnd },
1090
+ createdAt: token.createdAt ?? existing?.createdAt ?? now,
1091
+ updatedAt: token.updatedAt ?? now
1092
+ };
1093
+ anchors.push(anchor);
1094
+ suggestions.push(suggestion);
1095
+ }
1096
+ return {
1097
+ ...sidecar,
1098
+ reviewMarkdown,
1099
+ anchors,
1100
+ suggestions,
1101
+ updatedAt: now
1102
+ };
1103
+ }
1104
+ function createSuggestionMarkup(reviewMarkdown, patch, metadata) {
1105
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1106
+ assertNoDuplicateSuggestionId(parsed, metadata.suggestionId);
1107
+ const [contentStart, contentEnd] = lineRangeOffsets(parsed.canonicalMarkdown, patch.range);
1108
+ const selected = parsed.canonicalMarkdown.slice(contentStart, contentEnd);
1109
+ if (selected !== patch.before) {
1110
+ throw new MdocsError(
1111
+ "conflict",
1112
+ "Suggestion target does not match the review document projection.",
1113
+ "Re-read the document and create the suggestion against the current canonical text."
1114
+ );
1115
+ }
1116
+ const [canonicalStart, canonicalEnd] = lineRangeReplacementOffsets(parsed.canonicalMarkdown, patch.range);
1117
+ if (selectionTouchesSuggestion(parsed.suggestions, canonicalStart, canonicalEnd)) {
1118
+ throw new MdocsError(
1119
+ "conflict",
1120
+ "Suggestion overlaps an unresolved suggestion.",
1121
+ "Accept or reject the existing suggestion before creating another edit over the same text."
1122
+ );
1123
+ }
1124
+ const rawStart = rawOffsetForCanonicalOffset(parsed.segments, canonicalStart);
1125
+ const rawEnd = rawOffsetForCanonicalOffset(parsed.segments, canonicalEnd);
1126
+ const before = parsed.canonicalMarkdown.slice(canonicalStart, canonicalEnd);
1127
+ const after = replacementTextForSpan(parsed.canonicalMarkdown, patch, canonicalStart, canonicalEnd);
1128
+ const marker = formatSuggestionMarker(metadata, kindForText(before, after), before, after);
1129
+ const nextReviewMarkdown = `${reviewMarkdown.slice(0, rawStart)}${marker}${reviewMarkdown.slice(rawEnd)}`;
1130
+ const token = parseReviewMarkdown(nextReviewMarkdown).suggestions.find((candidate) => candidate.id === metadata.suggestionId);
1131
+ if (!token) throw invalidReviewMarkdown(`Failed to create suggestion ${metadata.suggestionId}.`);
1132
+ return { reviewMarkdown: nextReviewMarkdown, token };
1133
+ }
1134
+ function projectReviewMarkdown(canonicalMarkdown, suggestions) {
1135
+ let reviewMarkdown = canonicalMarkdown;
1136
+ const open = suggestions.filter((suggestion) => suggestion.status === "open" && suggestion.patch?.type === "replace_lines").slice().sort((left, right) => left.patch.range.startLine - right.patch.range.startLine);
1137
+ for (const suggestion of open) {
1138
+ try {
1139
+ reviewMarkdown = createSuggestionMarkup(reviewMarkdown, suggestion.patch, {
1140
+ suggestionId: suggestion.id,
1141
+ author: suggestion.author,
1142
+ message: suggestion.message,
1143
+ createdAt: suggestion.createdAt,
1144
+ updatedAt: suggestion.updatedAt
1145
+ }).reviewMarkdown;
1146
+ } catch {
1053
1147
  }
1054
1148
  }
1055
- flush(row, col);
1056
- return regions;
1149
+ return reviewMarkdown;
1057
1150
  }
1058
- function clusterRegions(regionsA, regionsB) {
1059
- const spans = [...regionsA, ...regionsB].map((region) => ({ lo: region.baseStart, hi: region.baseStart + region.baseLength })).sort((left, right) => left.lo - right.lo || left.hi - right.hi);
1060
- const clusters = [];
1061
- for (const span of spans) {
1062
- const last = clusters.at(-1);
1063
- if (last && span.lo <= last.hi) {
1064
- last.hi = Math.max(last.hi, span.hi);
1065
- } else {
1066
- clusters.push({ lo: span.lo, hi: span.hi });
1151
+ function resolveSuggestionMarkup(reviewMarkdown, suggestionId, resolution) {
1152
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1153
+ const token = parsed.suggestions.find((candidate) => candidate.id === suggestionId);
1154
+ if (!token) {
1155
+ throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "Refresh review state and try again.");
1156
+ }
1157
+ const resolved = resolutionText(token, resolution);
1158
+ return `${reviewMarkdown.slice(0, token.markerStart)}${resolved}${reviewMarkdown.slice(token.markerEnd)}`;
1159
+ }
1160
+ function escapeReviewSuggestionText(value) {
1161
+ return value.replaceAll("\\", "\\\\").replaceAll("{++", "\\{++").replaceAll("++}", "\\++}").replaceAll("{--", "\\{--").replaceAll("--}", "\\--}").replaceAll("{~~", "\\{~~").replaceAll("~>", "\\~>").replaceAll("~~}", "\\~~}");
1162
+ }
1163
+ function lineRangeForCanonicalSpan(markdown, start, end) {
1164
+ assertCanonicalSpan(markdown, start, end);
1165
+ return lineRangeForOffsets(markdown, start, end);
1166
+ }
1167
+ function selectorForCanonicalSpan(markdown, start, end) {
1168
+ assertCanonicalSpan(markdown, start, end);
1169
+ const context = 160;
1170
+ const prefix = markdown.slice(Math.max(0, start - context), start);
1171
+ const suffix = markdown.slice(end, Math.min(markdown.length, end + context));
1172
+ return {
1173
+ quote: markdown.slice(start, end),
1174
+ ...prefix ? { prefix } : {},
1175
+ ...suffix ? { suffix } : {}
1176
+ };
1177
+ }
1178
+ function appendTextSegment(source, segments, start, end, canonicalStart) {
1179
+ if (end <= start) return;
1180
+ segments.push({
1181
+ type: "text",
1182
+ text: source.slice(start, end),
1183
+ start,
1184
+ end,
1185
+ canonicalStart,
1186
+ canonicalEnd: canonicalStart + (end - start)
1187
+ });
1188
+ }
1189
+ function canonicalFromSegments(segments, statuses) {
1190
+ return segments.map((segment) => {
1191
+ if (segment.type === "text") return segment.text;
1192
+ const status = statuses(segment.token.id);
1193
+ if (status === "accepted") return acceptedText(segment.token);
1194
+ return originalText(segment.token);
1195
+ }).join("");
1196
+ }
1197
+ function parseMarkerAttributes(raw) {
1198
+ const attrs2 = {};
1199
+ const pattern = /\s+([a-zA-Z][\w:-]*)="([^"]*)"/g;
1200
+ let match2;
1201
+ while ((match2 = pattern.exec(raw)) !== null) {
1202
+ const key = match2[1];
1203
+ if (key === "id" || key === "kind" || key === "authorId" || key === "authorKind" || key === "authorName" || key === "authorEmail" || key === "message" || key === "createdAt" || key === "updatedAt") {
1204
+ attrs2[key] = decodeAttribute(match2[2] ?? "");
1067
1205
  }
1068
1206
  }
1069
- return clusters;
1207
+ return attrs2;
1070
1208
  }
1071
- function sideSlice(side, regions, lo, hi) {
1072
- return side.slice(lo + sideDeltaBefore(regions, lo, false), hi + sideDeltaBefore(regions, hi, true));
1209
+ function actorFromAttributes(attrs2) {
1210
+ if (!attrs2.authorId || !attrs2.authorName) return void 0;
1211
+ const kind = attrs2.authorKind === "agent" || attrs2.authorKind === "system" || attrs2.authorKind === "human" ? attrs2.authorKind : "human";
1212
+ return {
1213
+ id: attrs2.authorId,
1214
+ kind,
1215
+ name: attrs2.authorName,
1216
+ ...attrs2.authorEmail ? { email: attrs2.authorEmail } : {}
1217
+ };
1073
1218
  }
1074
- function sideDeltaBefore(regions, basePos, includeInsertionAt) {
1075
- let delta = 0;
1076
- for (const region of regions) {
1077
- const end = region.baseStart + region.baseLength;
1078
- if (end > basePos) break;
1079
- if (end === basePos && region.baseLength === 0 && !includeInsertionAt) break;
1080
- delta += region.sideLength - region.baseLength;
1219
+ function parseSuggestionBody(kind, body, id) {
1220
+ if (kind === "insert") {
1221
+ return { before: "", after: parseWrappedBody(body, "{++", "++}", id) };
1222
+ }
1223
+ if (kind === "delete") {
1224
+ return { before: parseWrappedBody(body, "{--", "--}", id), after: "" };
1225
+ }
1226
+ if (!body.startsWith("{~~") || !body.endsWith("~~}")) {
1227
+ throw invalidReviewMarkdown(`Replacement suggestion ${id} has invalid CriticMarkup payload.`);
1081
1228
  }
1082
- return delta;
1229
+ const inner = body.slice(3, -3);
1230
+ const separator = findUnescaped2(inner, "~>");
1231
+ if (separator === -1) throw invalidReviewMarkdown(`Replacement suggestion ${id} is missing a separator.`);
1232
+ return {
1233
+ before: unescapeReviewSuggestionText(inner.slice(0, separator)),
1234
+ after: unescapeReviewSuggestionText(inner.slice(separator + 2))
1235
+ };
1083
1236
  }
1084
- function linesEqual(left, right) {
1085
- return left.length === right.length && left.every((line, index) => line === right[index]);
1237
+ function parseWrappedBody(body, open, close2, id) {
1238
+ if (!body.startsWith(open) || !body.endsWith(close2) || isEscapedAt(body, body.length - close2.length)) {
1239
+ throw invalidReviewMarkdown(`Suggestion ${id} has invalid CriticMarkup payload.`);
1240
+ }
1241
+ return unescapeReviewSuggestionText(body.slice(open.length, -close2.length));
1086
1242
  }
1087
-
1088
- // ../core/src/patches.ts
1089
- function applySuggestion(markdown, suggestion, input) {
1090
- if (suggestion.status !== "open") {
1091
- throw new MdocsError(
1092
- "validation_error",
1093
- `Suggestion ${suggestion.id} is not open (status: ${suggestion.status}).`,
1094
- "Only open suggestions can be applied. List open suggestions with the suggestions command."
1095
- );
1243
+ function formatSuggestionMarker(metadata, kind, before, after) {
1244
+ const attrs2 = [
1245
+ ["id", metadata.suggestionId],
1246
+ ["kind", kind]
1247
+ ];
1248
+ if (metadata.author) {
1249
+ attrs2.push(["authorId", metadata.author.id]);
1250
+ attrs2.push(["authorKind", metadata.author.kind]);
1251
+ attrs2.push(["authorName", metadata.author.name]);
1252
+ if (metadata.author.email) attrs2.push(["authorEmail", metadata.author.email]);
1253
+ }
1254
+ if (metadata.message) attrs2.push(["message", metadata.message]);
1255
+ if (metadata.createdAt) attrs2.push(["createdAt", metadata.createdAt]);
1256
+ if (metadata.updatedAt) attrs2.push(["updatedAt", metadata.updatedAt]);
1257
+ const attrText = attrs2.map(([key, value]) => `${key}="${encodeAttribute(value)}"`).join(" ");
1258
+ const body = kind === "insert" ? `{++${escapeReviewSuggestionText(after)}++}` : kind === "delete" ? `{--${escapeReviewSuggestionText(before)}--}` : `{~~${escapeReviewSuggestionText(before)}~>${escapeReviewSuggestionText(after)}~~}`;
1259
+ return `${START_MARKER} ${attrText} -->${body}${END_MARKER}`;
1260
+ }
1261
+ function stableAnchorIdForSuggestion(suggestionId) {
1262
+ return `anchor_${suggestionId.replace(/[^a-zA-Z0-9_:-]/g, "_")}`;
1263
+ }
1264
+ function diffSummary(before, after) {
1265
+ if (!before && after) return "Inserted text";
1266
+ if (before && !after) return "Deleted text";
1267
+ return "Replaced text";
1268
+ }
1269
+ function unescapeReviewSuggestionText(value) {
1270
+ let result = "";
1271
+ for (let index = 0; index < value.length; index += 1) {
1272
+ if (value[index] !== "\\") {
1273
+ result += value[index];
1274
+ continue;
1275
+ }
1276
+ const next = value[index + 1];
1277
+ if (next === void 0) {
1278
+ result += "\\";
1279
+ continue;
1280
+ }
1281
+ result += next;
1282
+ index += 1;
1096
1283
  }
1097
- const context = applySuggestionContext(input);
1098
- const baseResult = applySuggestionFromBase(markdown, suggestion, context.baseMarkdown);
1099
- if (baseResult.kind === "applied") return baseResult.markdown;
1100
- if (baseResult.kind === "conflict") {
1101
- throw new MdocsError(
1102
- "conflict",
1103
- `Suggestion ${suggestion.id} no longer applies cleanly: the document changed inside the suggested edit.`,
1104
- "Review the current text around the suggestion, then accept it manually or ask the agent for a fresh suggestion."
1105
- );
1284
+ return result;
1285
+ }
1286
+ function findUnescaped2(value, needle, from = 0) {
1287
+ let index = value.indexOf(needle, from);
1288
+ while (index !== -1) {
1289
+ if (!isEscapedAt(value, index)) return index;
1290
+ index = value.indexOf(needle, index + needle.length);
1106
1291
  }
1107
- const range = currentSuggestionRange(markdown, suggestion, context.anchor);
1108
- if (!range) {
1109
- throw new MdocsError(
1110
- "conflict",
1111
- `Suggestion ${suggestion.id} no longer applies cleanly: the document changed since it was created.`,
1112
- "Re-read the document, then create a fresh suggestion against the current content."
1113
- );
1292
+ return -1;
1293
+ }
1294
+ function isEscapedAt(value, index) {
1295
+ let slashes = 0;
1296
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) slashes += 1;
1297
+ return slashes % 2 === 1;
1298
+ }
1299
+ function statusLookup(statuses) {
1300
+ if (!statuses) return () => "open";
1301
+ if (isStatusMap(statuses)) return (suggestionId) => statuses.get(suggestionId) ?? "open";
1302
+ if (Array.isArray(statuses)) {
1303
+ const map = /* @__PURE__ */ new Map();
1304
+ for (const item of statuses) {
1305
+ const id = item.id ?? item.suggestionId;
1306
+ if (id && item.status) map.set(id, item.status);
1307
+ }
1308
+ return (suggestionId) => map.get(suggestionId) ?? "open";
1114
1309
  }
1115
- const next = replaceLineRange(markdown, range, suggestion.patch.after);
1116
- assertCleanMarkdown(next);
1117
- return next;
1310
+ const record = statuses;
1311
+ return (suggestionId) => record[suggestionId] ?? "open";
1118
1312
  }
1119
- function applySuggestionContext(input) {
1120
- if (!input) return {};
1121
- if ("selector" in input) return { anchor: input };
1122
- return input;
1123
- }
1124
- function applySuggestionFromBase(markdown, suggestion, baseMarkdown) {
1125
- if (!baseMarkdown || !suggestion.base?.contentHash) return { kind: "unavailable" };
1126
- if (contentHashForText(baseMarkdown) !== suggestion.base.contentHash) return { kind: "unavailable" };
1127
- if (!rangeIsWithin(baseMarkdown, suggestion.patch.range) || extractLineRange(baseMarkdown, suggestion.patch.range) !== suggestion.patch.before) {
1128
- return { kind: "conflict" };
1129
- }
1130
- const rebased = rebaseSuggestionPatch(baseMarkdown, markdown, suggestion);
1131
- if (rebased !== void 0) return { kind: "applied", markdown: rebased };
1132
- const suggestedMarkdown = replaceLineRange(baseMarkdown, suggestion.patch.range, suggestion.patch.after);
1133
- assertCleanMarkdown(suggestedMarkdown);
1134
- const merged = mergeText(baseMarkdown, markdown, suggestedMarkdown);
1135
- if (!merged.ok) return { kind: "conflict" };
1136
- assertCleanMarkdown(merged.text);
1137
- return { kind: "applied", markdown: merged.text };
1138
- }
1139
- function rebaseSuggestionPatch(baseMarkdown, markdown, suggestion) {
1140
- const baseLines = getLines(baseMarkdown);
1141
- const currentLines = getLines(markdown);
1142
- let regions;
1143
- try {
1144
- regions = diffTextRegions(baseLines, currentLines);
1145
- } catch {
1146
- return void 0;
1313
+ function isStatusMap(value) {
1314
+ return typeof value.get === "function";
1315
+ }
1316
+ function acceptedText(token) {
1317
+ return token.kind === "delete" ? "" : token.after;
1318
+ }
1319
+ function originalText(token) {
1320
+ return token.kind === "insert" ? "" : token.before;
1321
+ }
1322
+ function resolutionText(token, resolution) {
1323
+ return resolution === "accept" ? acceptedText(token) : originalText(token);
1324
+ }
1325
+ function kindForText(before, after) {
1326
+ if (before.length === 0) return "insert";
1327
+ if (after.length === 0) return "delete";
1328
+ return "replace";
1329
+ }
1330
+ function assertNoDuplicateSuggestionId(parsed, suggestionId) {
1331
+ if (parsed.suggestions.some((suggestion) => suggestion.id === suggestionId)) {
1332
+ throw new MdocsError("validation_error", `Duplicate suggestion id: ${suggestionId}`, "Suggestion ids must be stable and unique per document.");
1147
1333
  }
1148
- const baseStart = suggestion.patch.range.startLine - 1;
1149
- const baseEnd = suggestion.patch.range.endLine;
1150
- if (regions.some((region) => regionTouchesSuggestionRange(region, baseStart, baseEnd))) return void 0;
1151
- const currentStart = baseStart + sideDeltaBefore(regions, baseStart, true);
1152
- const currentEnd = baseEnd + sideDeltaBefore(regions, baseEnd, false);
1153
- const currentRange = { startLine: currentStart + 1, endLine: currentEnd };
1154
- if (!rangeIsWithin(markdown, currentRange)) return void 0;
1155
- if (extractLineRange(markdown, currentRange) !== suggestion.patch.before) return void 0;
1156
- const next = replaceLineRange(markdown, currentRange, suggestion.patch.after);
1157
- assertCleanMarkdown(next);
1158
- return next;
1159
1334
  }
1160
- function regionTouchesSuggestionRange(region, baseStart, baseEnd) {
1161
- if (region.baseLength === 0) return region.baseStart > baseStart && region.baseStart < baseEnd;
1162
- const regionEnd = region.baseStart + region.baseLength;
1163
- return region.baseStart < baseEnd && regionEnd > baseStart;
1164
- }
1165
- function currentSuggestionRange(markdown, suggestion, anchor) {
1166
- const hintedRange = suggestion.patch.range;
1167
- if (rangeIsWithin(markdown, hintedRange) && extractLineRange(markdown, hintedRange) === suggestion.patch.before) return hintedRange;
1168
- const candidates = findBeforeCandidates(markdown, suggestion.patch.before);
1169
- if (candidates.length === 0) return void 0;
1170
- if (candidates.length === 1) return candidates[0];
1171
- if (!anchor) return void 0;
1172
- const scored = candidates.map((range) => ({ range, score: scoreContext2(markdown, range, anchor) })).sort((left, right) => right.score - left.score);
1173
- const best = scored[0];
1174
- if (!best) return void 0;
1175
- const tied = scored.filter((candidate) => candidate.score === best.score);
1176
- if (tied.length > 1) return void 0;
1177
- if (best.score < 0.7) return void 0;
1178
- return best.range;
1335
+ function assertCanonicalSpan(markdown, start, end) {
1336
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > markdown.length) {
1337
+ throw new MdocsError("invalid_range", `Invalid canonical span ${start}:${end}.`, "Re-read the document and try again.");
1338
+ }
1179
1339
  }
1180
- function findBeforeCandidates(markdown, before) {
1181
- const lines = getLines(markdown);
1182
- const beforeLines = linePattern(before);
1183
- if (beforeLines.length === 0 || beforeLines.length > lines.length) return [];
1184
- const ranges = [];
1185
- const beforeText = beforeLines.join("\n");
1186
- for (let index = 0; index <= lines.length - beforeLines.length; index += 1) {
1187
- if (lines.slice(index, index + beforeLines.length).join("\n") === beforeText) {
1188
- ranges.push({ startLine: index + 1, endLine: index + beforeLines.length });
1340
+ function selectionTouchesSuggestion(tokens, start, end) {
1341
+ return tokens.some((token) => {
1342
+ if (token.canonicalStart === token.canonicalEnd) return start <= token.canonicalStart && end >= token.canonicalEnd;
1343
+ return start < token.canonicalEnd && end > token.canonicalStart;
1344
+ });
1345
+ }
1346
+ function rawOffsetForCanonicalOffset(segments, offset) {
1347
+ for (const segment of segments) {
1348
+ if (segment.type === "text") {
1349
+ if (offset >= segment.canonicalStart && offset <= segment.canonicalEnd) {
1350
+ return segment.start + (offset - segment.canonicalStart);
1351
+ }
1352
+ continue;
1353
+ }
1354
+ const token = segment.token;
1355
+ if (offset > token.canonicalStart && offset < token.canonicalEnd) {
1356
+ throw new MdocsError(
1357
+ "conflict",
1358
+ "Selection falls inside an unresolved suggestion.",
1359
+ "Accept or reject the existing suggestion before editing inside it."
1360
+ );
1189
1361
  }
1190
1362
  }
1191
- return ranges;
1363
+ if (offset === segmentsEndCanonicalOffset(segments)) return segmentsEndRawOffset(segments);
1364
+ throw new MdocsError("invalid_range", "Could not map canonical offset into review Markdown.", "Re-read the document and try again.");
1192
1365
  }
1193
- function rangeIsWithin(markdown, range) {
1194
- const lineCount = getLines(markdown).length;
1195
- return range.startLine >= 1 && range.endLine >= range.startLine && range.endLine <= lineCount;
1366
+ function segmentsEndCanonicalOffset(segments) {
1367
+ const last = segments[segments.length - 1];
1368
+ if (!last) return 0;
1369
+ return last.type === "text" ? last.canonicalEnd : last.token.canonicalEnd;
1196
1370
  }
1197
- function linePattern(value) {
1198
- return value.replace(/\r\n?/g, "\n").split("\n");
1371
+ function segmentsEndRawOffset(segments) {
1372
+ const last = segments[segments.length - 1];
1373
+ if (!last) return 0;
1374
+ return last.type === "text" ? last.end : last.token.markerEnd;
1199
1375
  }
1200
- function scoreContext2(markdown, range, anchor) {
1376
+ function lineRangeOffsets(markdown, range) {
1201
1377
  const lines = getLines(markdown);
1202
- let score = anchor.selector.quote === extractLineRange(markdown, range) ? 0.7 : 0.65;
1203
- const expectedPrefix = contextPrefix(anchor.selector.prefix);
1204
- if (expectedPrefix) {
1205
- const actualPrefix = contextPrefix(lines.slice(Math.max(0, range.startLine - 4), range.startLine - 1).join("\n"));
1206
- if (actualPrefix?.endsWith(expectedPrefix)) score += 0.15;
1378
+ if (range.startLine < 1 || range.endLine < range.startLine || range.endLine > lines.length) {
1379
+ throw new MdocsError("invalid_range", `Invalid line range ${range.startLine}:${range.endLine}.`, "Re-read the document and try again.");
1207
1380
  }
1208
- const expectedSuffix = contextSuffix(anchor.selector.suffix);
1209
- if (expectedSuffix) {
1210
- const actualSuffix = contextSuffix(lines.slice(range.endLine, range.endLine + 3).join("\n"));
1211
- if (actualSuffix?.startsWith(expectedSuffix)) score += 0.15;
1381
+ let start = 0;
1382
+ for (let index = 0; index < range.startLine - 1; index += 1) start += lines[index].length + 1;
1383
+ let end = start;
1384
+ for (let index = range.startLine - 1; index < range.endLine; index += 1) {
1385
+ if (index > range.startLine - 1) end += 1;
1386
+ end += lines[index].length;
1387
+ }
1388
+ return [start, end];
1389
+ }
1390
+ function lineRangeReplacementOffsets(markdown, range) {
1391
+ const [start, contentEnd] = lineRangeOffsets(markdown, range);
1392
+ if (contentEnd < markdown.length && markdown[contentEnd] === "\n") return [start, contentEnd + 1];
1393
+ return [start, contentEnd];
1394
+ }
1395
+ function replacementTextForSpan(markdown, patch, start, end) {
1396
+ const applied = replaceLineRange(markdown, patch.range, patch.after);
1397
+ const suffixLength = markdown.length - end;
1398
+ const replacementEnd = applied.length - suffixLength;
1399
+ if (replacementEnd < start) return "";
1400
+ return applied.slice(start, replacementEnd);
1401
+ }
1402
+ function lineRangeForOffsets(markdown, start, end) {
1403
+ const starts = lineStarts(markdown);
1404
+ return {
1405
+ startLine: lineNumberAt(starts, start),
1406
+ endLine: lineNumberAt(starts, Math.max(start, end - 1))
1407
+ };
1408
+ }
1409
+ function lineStarts(markdown) {
1410
+ const starts = [0];
1411
+ for (let index = 0; index < markdown.length; index += 1) {
1412
+ if (markdown[index] === "\n") starts.push(index + 1);
1212
1413
  }
1213
- return Math.min(1, score);
1414
+ return starts;
1214
1415
  }
1215
- function contextPrefix(value) {
1216
- return value?.split(/\r?\n/).filter(Boolean).slice(-3).join("\n") || void 0;
1416
+ function lineNumberAt(starts, offset) {
1417
+ let line = 1;
1418
+ for (let index = 0; index < starts.length; index += 1) {
1419
+ if (starts[index] <= offset) line = index + 1;
1420
+ else break;
1421
+ }
1422
+ return line;
1217
1423
  }
1218
- function contextSuffix(value) {
1219
- return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") || void 0;
1424
+ function encodeAttribute(value) {
1425
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1426
+ }
1427
+ function decodeAttribute(value) {
1428
+ return value.replaceAll("&quot;", '"').replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1429
+ }
1430
+ function invalidReviewMarkdown(message) {
1431
+ return new MdocsError("validation_error", `Invalid review Markdown: ${message}`, "Repair or remove the malformed suggestion markup.");
1220
1432
  }
1221
1433
 
1222
1434
  // ../core/src/graph.ts
@@ -1521,6 +1733,7 @@ async function getDocumentState(io, pathOrDocId) {
1521
1733
  path: entry.path,
1522
1734
  title: entry.title,
1523
1735
  markdown,
1736
+ reviewMarkdown: nextSidecar.reviewMarkdown ?? markdown,
1524
1737
  sidecar: nextSidecar,
1525
1738
  anchors: remappedAnchors.map((anchor) => ({
1526
1739
  id: anchor.id,
@@ -1608,6 +1821,7 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
1608
1821
  id: createId("suggestion"),
1609
1822
  anchorId: anchor.id,
1610
1823
  status: "open",
1824
+ kind: replacement.length === 0 ? "delete" : before.length === 0 ? "insert" : "replace",
1611
1825
  author,
1612
1826
  message,
1613
1827
  patch: {
@@ -1624,53 +1838,72 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
1624
1838
  createdAt: now,
1625
1839
  updatedAt: now
1626
1840
  };
1627
- await writeSidecar(io, {
1628
- ...state.sidecar,
1629
- anchors: [...state.sidecar.anchors, anchor],
1630
- suggestions: [...state.sidecar.suggestions, suggestion],
1841
+ const review = createSuggestionMarkup(state.sidecar.reviewMarkdown ?? state.markdown, suggestion.patch, {
1842
+ suggestionId: suggestion.id,
1843
+ author,
1844
+ message,
1845
+ createdAt: now,
1631
1846
  updatedAt: now
1632
1847
  });
1633
- return suggestion;
1848
+ const nextSidecar = deriveSidecarFromReviewMarkdown(
1849
+ { ...state.sidecar, reviewMarkdown: review.reviewMarkdown, anchors: [...state.sidecar.anchors, anchor] },
1850
+ review.reviewMarkdown,
1851
+ { author, baseHead: entry?.currentSha, now }
1852
+ );
1853
+ await writeSidecar(io, nextSidecar);
1854
+ const enriched = nextSidecar.suggestions.find((candidate) => candidate.id === suggestion.id);
1855
+ if (!enriched) throw new MdocsError("validation_error", `Could not derive suggestion ${suggestion.id}.`, "Refresh review state and try again.");
1856
+ return enriched;
1634
1857
  }
1635
- async function updateSuggestionStatus(io, suggestionId, status, actor = defaultActor()) {
1858
+ async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
1636
1859
  const manifest = await indexWorkspace(io);
1637
1860
  for (const entry of manifest.docs) {
1861
+ const markdown = await io.readText(entry.path);
1638
1862
  const sidecar = await readSidecar(io, entry.docId);
1639
1863
  const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
1640
1864
  if (!suggestion) continue;
1641
1865
  const now = nowIso();
1642
- const updated = {
1643
- ...suggestion,
1644
- status,
1645
- updatedAt: now,
1646
- resolvedAt: now,
1647
- resolvedBy: actor
1648
- };
1649
- await writeSidecar(io, {
1650
- ...sidecar,
1651
- suggestions: sidecar.suggestions.map((candidate) => candidate.id === suggestionId ? updated : candidate),
1652
- updatedAt: now
1653
- });
1654
- return updated;
1866
+ const reviewMarkdown = sidecar.reviewMarkdown ?? markdown;
1867
+ const resolvedReviewMarkdown = resolveSuggestionMarkup(reviewMarkdown, suggestionId, "accept");
1868
+ const nextMarkdown = projectCanonicalMarkdown(resolvedReviewMarkdown);
1869
+ assertCleanMarkdown(nextMarkdown);
1870
+ const nextSidecar = deriveSidecarFromReviewMarkdown(
1871
+ { ...sidecar, reviewMarkdown: resolvedReviewMarkdown },
1872
+ resolvedReviewMarkdown,
1873
+ { author: suggestion.author, baseHead: entry.currentSha, now }
1874
+ );
1875
+ await io.writeText(entry.path, nextMarkdown);
1876
+ await writeSidecar(io, nextSidecar);
1877
+ return resolvedSuggestion(suggestion, "accepted", actor, now);
1655
1878
  }
1656
1879
  throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
1657
1880
  }
1658
- async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
1881
+ async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
1659
1882
  const manifest = await indexWorkspace(io);
1660
1883
  for (const entry of manifest.docs) {
1661
- const markdown = await io.readText(entry.path);
1662
1884
  const sidecar = await readSidecar(io, entry.docId);
1663
1885
  const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
1664
1886
  if (!suggestion) continue;
1665
- const anchor = sidecar.anchors.find((candidate) => candidate.id === suggestion.anchorId);
1666
- const nextMarkdown = applySuggestion(markdown, suggestion, anchor);
1667
- await io.writeText(entry.path, nextMarkdown);
1668
- return updateSuggestionStatus(io, suggestionId, "accepted", actor);
1887
+ const now = nowIso();
1888
+ const updated = resolvedSuggestion(suggestion, "rejected", actor, now);
1889
+ const reviewMarkdown = resolveSuggestionMarkup(sidecar.reviewMarkdown ?? "", suggestionId, "reject");
1890
+ const nextSidecar = deriveSidecarFromReviewMarkdown({ ...sidecar, reviewMarkdown }, reviewMarkdown, {
1891
+ author: suggestion.author,
1892
+ now
1893
+ });
1894
+ await writeSidecar(io, nextSidecar);
1895
+ return updated;
1669
1896
  }
1670
1897
  throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
1671
1898
  }
1672
- async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
1673
- return updateSuggestionStatus(io, suggestionId, "rejected", actor);
1899
+ function resolvedSuggestion(suggestion, status, actor, now) {
1900
+ return {
1901
+ ...suggestion,
1902
+ status,
1903
+ updatedAt: now,
1904
+ resolvedAt: now,
1905
+ resolvedBy: actor
1906
+ };
1674
1907
  }
1675
1908
  async function resolveComment(io, commentId, actor = defaultActor()) {
1676
1909
  const manifest = await indexWorkspace(io);
@@ -4606,44 +4839,46 @@ function gatherMarks(schema2, marks) {
4606
4839
  }
4607
4840
 
4608
4841
  // ../core/src/pm-schema.ts
4842
+ var blockAttrs = { blockId: { default: null } };
4609
4843
  var canonicalSchema = new Schema({
4610
4844
  nodes: {
4611
4845
  // The doc allows suggestion marks on its block children so pending
4612
4846
  // block-level suggestions (node marks) survive fromJSON and can be
4613
4847
  // structurally reverted before serialization.
4614
4848
  doc: { content: "block+", marks: "insertion deletion modification" },
4615
- paragraph: { group: "block", content: "inline*" },
4849
+ paragraph: { group: "block", content: "inline*", attrs: blockAttrs },
4616
4850
  heading: {
4617
4851
  group: "block",
4618
4852
  content: "inline*",
4619
- attrs: { level: { default: 1 } }
4853
+ attrs: { ...blockAttrs, level: { default: 1 } }
4620
4854
  },
4621
- blockquote: { group: "block", content: "block+" },
4855
+ blockquote: { group: "block", content: "block+", attrs: blockAttrs },
4622
4856
  codeBlock: {
4623
4857
  group: "block",
4624
4858
  content: "text*",
4625
4859
  marks: "",
4626
4860
  code: true,
4627
- attrs: { language: { default: null } }
4861
+ attrs: { ...blockAttrs, language: { default: null } }
4628
4862
  },
4629
- horizontalRule: { group: "block" },
4630
- bulletList: { group: "block", content: "listItem+" },
4863
+ horizontalRule: { group: "block", attrs: blockAttrs },
4864
+ bulletList: { group: "block", content: "listItem+", attrs: blockAttrs },
4631
4865
  orderedList: {
4632
4866
  group: "block",
4633
4867
  content: "listItem+",
4634
- attrs: { start: { default: 1 } }
4868
+ attrs: { ...blockAttrs, start: { default: 1 } }
4635
4869
  },
4636
- listItem: { content: "paragraph block*" },
4637
- taskList: { group: "block", content: "taskItem+" },
4870
+ listItem: { content: "paragraph block*", attrs: blockAttrs },
4871
+ taskList: { group: "block", content: "taskItem+", attrs: blockAttrs },
4638
4872
  taskItem: {
4639
4873
  content: "paragraph block*",
4640
- attrs: { checked: { default: false } }
4874
+ attrs: { ...blockAttrs, checked: { default: false } }
4641
4875
  },
4642
- table: { group: "block", content: "tableRow+" },
4643
- tableRow: { content: "(tableCell | tableHeader)+" },
4876
+ table: { group: "block", content: "tableRow+", attrs: blockAttrs },
4877
+ tableRow: { content: "(tableCell | tableHeader)+", attrs: blockAttrs },
4644
4878
  tableHeader: {
4645
4879
  content: "block+",
4646
4880
  attrs: {
4881
+ ...blockAttrs,
4647
4882
  colspan: { default: 1 },
4648
4883
  rowspan: { default: 1 },
4649
4884
  colwidth: { default: null }
@@ -4652,6 +4887,7 @@ var canonicalSchema = new Schema({
4652
4887
  tableCell: {
4653
4888
  content: "block+",
4654
4889
  attrs: {
4890
+ ...blockAttrs,
4655
4891
  colspan: { default: 1 },
4656
4892
  rowspan: { default: 1 },
4657
4893
  colwidth: { default: null }
@@ -10950,6 +11186,36 @@ function backticksFor2(node, side) {
10950
11186
  return result;
10951
11187
  }
10952
11188
 
11189
+ // ../core/src/pm-parse.ts
11190
+ var parser = new MarkdownParser(canonicalSchema, new lib_default(), {
11191
+ blockquote: { block: "blockquote" },
11192
+ paragraph: { block: "paragraph" },
11193
+ list_item: { block: "listItem" },
11194
+ bullet_list: { block: "bulletList" },
11195
+ ordered_list: {
11196
+ block: "orderedList",
11197
+ getAttrs: (tok) => ({ start: Number(tok.attrGet("start")) || 1 })
11198
+ },
11199
+ heading: { block: "heading", getAttrs: (tok) => ({ level: Number(tok.tag.slice(1)) || 1 }) },
11200
+ code_block: { block: "codeBlock", noCloseToken: true },
11201
+ fence: { block: "codeBlock", getAttrs: (tok) => ({ language: tok.info || null }), noCloseToken: true },
11202
+ hr: { node: "horizontalRule" },
11203
+ hardbreak: { node: "hardBreak" },
11204
+ em: { mark: "italic" },
11205
+ strong: { mark: "bold" },
11206
+ s: { mark: "strike" },
11207
+ link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href") }) },
11208
+ code_inline: { mark: "code", noCloseToken: true },
11209
+ image: {
11210
+ node: "image",
11211
+ getAttrs: (tok) => ({
11212
+ alt: tok.content,
11213
+ src: tok.attrGet("src"),
11214
+ title: tok.attrGet("title")
11215
+ })
11216
+ }
11217
+ });
11218
+
10953
11219
  // ../core/src/remote-document-io.ts
10954
11220
  var RemoteDocumentIO = class {
10955
11221
  constructor(document) {
@@ -10997,7 +11263,7 @@ var RemoteDocumentIO = class {
10997
11263
  };
10998
11264
 
10999
11265
  // src/agent.ts
11000
- var CLI_VERSION = "0.3.12";
11266
+ var CLI_VERSION = "0.3.17";
11001
11267
  var CLI_PACKAGE_NAME = "@magic-markdown/cli";
11002
11268
  var AGENT_COMMANDS = [
11003
11269
  {
@@ -11058,10 +11324,11 @@ var AGENT_COMMANDS = [
11058
11324
  {
11059
11325
  name: "remote",
11060
11326
  summary: "Read, organize, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
11061
- usage: "mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
11327
+ usage: "mdocs remote status|map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
11062
11328
  output: "json",
11063
11329
  mutates: true,
11064
11330
  examples: [
11331
+ "mdocs remote status --expect-scope file --expect-root root_abc --expect-doc doc_abc --json",
11065
11332
  "mdocs remote context --summary --json",
11066
11333
  "mdocs remote context --start-line 1 --end-line 100 --no-review --json",
11067
11334
  "mdocs remote context --start-line 101 --end-line 200 --no-review --json",
@@ -11081,6 +11348,7 @@ var AGENT_COMMANDS = [
11081
11348
  "mdocs remote invite-folder fold_abc123 --email person@example.com --role edit --json"
11082
11349
  ],
11083
11350
  notes: [
11351
+ "Use remote status --json immediately after joining when the handoff includes expected scope/root/doc values. If it reports wrong_binding, stop and ask for the correct share or connector.",
11084
11352
  "Use remote context --summary --json first on file-scoped joins and before reading large documents. It returns metadata, heading line numbers, current head, review counts, and suggested next commands without dumping Markdown.",
11085
11353
  "remote context accepts --start-line and --end-line (1-based, inclusive) to page through large documents. The response always includes totalLines, startLine, and endLine \u2014 if totalLines > endLine, request the next page with --start-line <endLine+1>.",
11086
11354
  "Use remote context --no-review when reading document content only; use remote review to list open comments, open suggestions, and anchors separately.",
@@ -11172,7 +11440,7 @@ var AGENT_COMMANDS = [
11172
11440
  },
11173
11441
  {
11174
11442
  name: "suggest",
11175
- summary: "Create a sidecar replacement suggestion without modifying clean Markdown.",
11443
+ summary: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown.",
11176
11444
  usage: "mdocs suggest <path|docId> --range <start:end> --with <markdown> --message <text> --json",
11177
11445
  output: "json",
11178
11446
  mutates: true,
@@ -11282,20 +11550,39 @@ var AGENT_COMMANDS = [
11282
11550
  {
11283
11551
  name: "bridge resume",
11284
11552
  summary: "Backfill missed Magic changes after an agent restart, then keep the bridge running.",
11285
- usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--once]",
11553
+ usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--forever] [--once]",
11286
11554
  output: "long-running",
11287
11555
  mutates: true,
11288
11556
  examples: [
11289
- "mdocs bridge resume --root . --request-token",
11557
+ "mdocs bridge resume --root . --forever --request-token",
11290
11558
  "mdocs bridge resume --root . --once --request-token"
11291
11559
  ],
11292
11560
  notes: [
11293
11561
  "Reads non-secret connection defaults from .mdocs/bridge.json written by bridge setup.",
11294
11562
  "Use this after a sandbox/container/agent session restarts. It backfills canonical Magic changes before publishing local edits.",
11563
+ "--forever is the intended startup/sandbox command: keep it running for live collaboration and liveness heartbeats.",
11295
11564
  "--request-token opens a human approval URL when no MDOCS_BRIDGE_TOKEN is available; do not ask users to paste bridge tokens by default.",
11296
11565
  "--once performs only the backfill/reconcile step and exits."
11297
11566
  ]
11298
11567
  },
11568
+ {
11569
+ name: "bridge startup",
11570
+ summary: "Write a bridge runner and optional launchd/systemd startup hook for long-lived bridge environments.",
11571
+ usage: "mdocs bridge startup --root <path> [--mode auto|shell|launchd|systemd] [--install] [--json]",
11572
+ output: "json",
11573
+ mutates: true,
11574
+ examples: [
11575
+ "mdocs bridge startup --root . --json",
11576
+ "mdocs bridge startup --root . --mode shell --json",
11577
+ "mdocs bridge startup --root . --mode systemd --install --json"
11578
+ ],
11579
+ notes: [
11580
+ "Run this after bridge setup has written .mdocs/bridge.json.",
11581
+ "Sandbox providers should put the returned startupCommand in their on-start hook.",
11582
+ "--install installs and starts the generated launchd/systemd user service when the host supports it.",
11583
+ "The generated runner stores no bridge token; provide MDOCS_BRIDGE_TOKEN through platform secrets if you use manual tokens."
11584
+ ]
11585
+ },
11299
11586
  {
11300
11587
  name: "bridge",
11301
11588
  summary: "Sync a local Markdown root with a Magic workspace over WebSocket (long-running).",
@@ -11358,11 +11645,12 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
11358
11645
  ## Start Here
11359
11646
 
11360
11647
  1. If given a Magic Markdown share URL, run \`mdocs join <share-url> --json\` first, and always include \`--name "<your agent name>"\` (for example \`--name "Claude Code"\`) so collaborators can see which agent is connected. Use \`mdocs remote ...\` commands after joining.
11361
- 2. If working in a local workspace, run \`mdocs doctor --json\` to validate the workspace and learn recommended next commands.
11362
- 3. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --summary --json\` instead. Project-scoped joins cover one root; workspace-scoped joins cover Home and can target duplicate paths as \`<rootId>:<path-or-docId>\`.
11363
- 4. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
11364
- 5. For a document, run \`mdocs context <path|docId> --summary --json\` locally or \`mdocs remote context <path|docId> --summary --json\` for a joined share before reading full content. Then page Markdown with \`--start-line\` / \`--end-line\` and \`--no-review\` when you only need document text.
11365
- 6. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
11648
+ 2. If the handoff includes expected binding values, run \`mdocs remote status --expect-scope <file|project> --expect-root <rootId> --expect-doc <docId> --json\` after joining. If it reports \`wrong_binding\`, stop and ask for the correct share or connector.
11649
+ 3. If working in a local workspace, run \`mdocs doctor --json\` to validate the workspace and learn recommended next commands.
11650
+ 4. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --summary --json\` instead. Project-scoped joins cover one root; workspace-scoped joins cover Home and can target duplicate paths as \`<rootId>:<path-or-docId>\`.
11651
+ 5. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
11652
+ 6. For a document, run \`mdocs context <path|docId> --summary --json\` locally or \`mdocs remote context <path|docId> --summary --json\` for a joined share before reading full content. Then page Markdown with \`--start-line\` / \`--end-line\` and \`--no-review\` when you only need document text.
11653
+ 7. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not hand-edit canonical Markdown files or \`.mdocs\` reviewMarkdown syntax directly.
11366
11654
 
11367
11655
  ## Filesystem Bridge / Resume
11368
11656
 
@@ -11383,16 +11671,24 @@ Return the Magic approval link and keep the bridge process running after approva
11383
11671
  If your sandbox, container, terminal, or agent session restarts after setup, do not ask for a raw bridge token. Start the latest CLI again and resume from the same root:
11384
11672
 
11385
11673
  \`\`\`bash
11386
- npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --request-token
11674
+ npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --forever --request-token
11675
+ \`\`\`
11676
+
11677
+ \`bridge resume --forever\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching with liveness heartbeats. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
11678
+
11679
+ For sandboxes, VPSs, or local machines that support startup hooks, generate a durable runner after setup:
11680
+
11681
+ \`\`\`bash
11682
+ npx --yes --package=@magic-markdown/cli@latest mdocs bridge startup --root . --json
11387
11683
  \`\`\`
11388
11684
 
11389
- \`bridge resume\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
11685
+ Put the returned \`startupCommand\` into the provider's on-start hook. On local macOS or systemd Linux hosts, \`mdocs bridge startup --root . --install\` writes and starts a user service. The generated runner stores no bridge token; use platform secrets for \`MDOCS_BRIDGE_TOKEN\` if you manage tokens manually.
11390
11686
 
11391
11687
  If Magic and the local root both changed the same document while you were offline, the bridge keeps the Magic canonical path, saves your local divergent version as a \`.conflict-...\` Markdown copy, and lets the conflict copy sync as a normal file. If Magic reports a server-side conflict, wait for the human to resolve it in Magic Markdown before retrying content pushes.
11392
11688
 
11393
11689
  ## Editing Rules
11394
11690
 
11395
- - Comments and suggestions are sidecar operations. They should not modify clean Markdown until a suggestion is accepted.
11691
+ - Comments are sidecar operations; suggestions are reviewMarkdown operations derived into sidecar state. They should not modify canonical Markdown until a suggestion is accepted.
11396
11692
  - \`--range <start:end>\` is 1-based and inclusive, and validated against the current document: read the document first and quote line numbers from what you just read.
11397
11693
  - Prefer the smallest coherent suggestion range that contains the actual change. If one sentence, list item, table row, or short paragraph changes, suggest that unit rather than replacing surrounding unchanged paragraphs or sections.
11398
11694
  - Broader paragraph, section, or multi-section rewrites are appropriate when the edit genuinely changes structure, ordering, transitions, or multiple interdependent ideas. Do not avoid a long edit when it is the clearest correct revision.
@@ -11407,7 +11703,7 @@ If Magic and the local root both changed the same document while you were offlin
11407
11703
  ## Errors, Conflicts, and Exit Codes
11408
11704
 
11409
11705
  - Errors go to stderr. With \`--json\` they are structured: \`{ "ok": false, "error": { "code", "message", "hint" }, "cliVersion" }\`. Follow the \`hint\`.
11410
- - Exit codes: 0 ok, 1 internal, 2 usage/invalid range, 3 conflict, 4 not found, 5 network, 6 unauthorized (share link revoked or expired).
11706
+ - Exit codes: 0 ok, 1 internal, 2 usage/invalid range, 3 conflict, 4 not found or wrong binding, 5 network, 6 unauthorized (share link revoked or expired).
11411
11707
  - \`remote comment\` and \`remote suggest\` are concurrency-safe: the server merges review additions without clobbering concurrent edits, and the CLI rebases and retries automatically. A surviving \`conflict\` error means the document is changing rapidly \u2014 refetch context and retry.
11412
11708
  - \`remote events --json\` may return \`"truncated": true\` when older events were dropped from the bounded event log. Do not assume nothing happened; refetch with \`mdocs remote context --summary --json\`, then read needed pages and review state.
11413
11709
  - Retries are safe: remote writes carry a changeId and the server deduplicates replays.
@@ -11431,7 +11727,7 @@ Start the local stdio MCP server with:
11431
11727
  mdocs serve-mcp --cwd /path/to/workspace
11432
11728
  \`\`\`
11433
11729
 
11434
- The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt. Use \`mdocs_context\` / \`magic_context\` with \`summary: true\` before dumping Markdown; use \`includeReview: false\` for content-only reads and \`mdocs_review\` / \`magic_review\` for review state.
11730
+ The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt. Use \`magic_status\` first when expected binding values are available; stop if it returns \`wrong_binding\`. Then use \`mdocs_context\` / \`magic_context\` with \`summary: true\` before dumping Markdown; use \`includeReview: false\` for content-only reads and \`mdocs_review\` / \`magic_review\` for review state.
11435
11731
  `;
11436
11732
  }
11437
11733
  function formatCommandReference() {
@@ -11459,6 +11755,7 @@ var CLI_EXIT_CODES = {
11459
11755
  invalid_range: 2,
11460
11756
  conflict: 3,
11461
11757
  not_found: 4,
11758
+ wrong_binding: 4,
11462
11759
  network_error: 5,
11463
11760
  unauthorized: 6
11464
11761
  };
@@ -12022,7 +12319,7 @@ var tools = [
12022
12319
  },
12023
12320
  {
12024
12321
  name: "mdocs_suggest",
12025
- description: "Create a sidecar replacement suggestion without modifying clean Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
12322
+ description: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
12026
12323
  inputSchema: {
12027
12324
  type: "object",
12028
12325
  properties: {
@@ -12511,6 +12808,16 @@ async function postReview(record, docId, additions, actor) {
12511
12808
  );
12512
12809
  return response.document;
12513
12810
  }
12811
+ async function postReviewOperation(record, docId, operation, actor) {
12812
+ return fetchJson(
12813
+ `${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots/${encodeURIComponent(record.rootId)}/documents/${encodeURIComponent(docId)}/review-ops`,
12814
+ {
12815
+ method: "POST",
12816
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
12817
+ body: JSON.stringify({ actor, operation })
12818
+ }
12819
+ );
12820
+ }
12514
12821
  async function pushDocument(record, baseDocument, markdown, sidecar) {
12515
12822
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
12516
12823
  const payload = {
@@ -12769,7 +13076,6 @@ function joinStoreDir(root) {
12769
13076
  }
12770
13077
 
12771
13078
  // src/remote.ts
12772
- var PUSH_REBASE_ATTEMPTS = 3;
12773
13079
  async function runJoinCommand(root, parsed) {
12774
13080
  const target = parsed.command[1];
12775
13081
  const existingId = typeof parsed.flags.id === "string" ? parsed.flags.id : void 0;
@@ -12808,6 +13114,8 @@ async function runJoinsCommand(root) {
12808
13114
  async function runRemoteCommand(root, subcommand, parsed) {
12809
13115
  const record = await normalizedSelectedJoin(root, parsed.flags);
12810
13116
  switch (subcommand) {
13117
+ case "status":
13118
+ return remoteStatus(record, parsed.flags);
12811
13119
  case "map":
12812
13120
  case void 0: {
12813
13121
  if (record.scope === "file") {
@@ -12861,10 +13169,53 @@ async function runRemoteCommand(root, subcommand, parsed) {
12861
13169
  return rejoin(root, record.joinId, parsed.flags);
12862
13170
  default:
12863
13171
  throw new CliError("usage_error", `Unknown remote subcommand: ${subcommand}`, {
12864
- hint: "Use mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
13172
+ hint: "Use mdocs remote status|map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
12865
13173
  });
12866
13174
  }
12867
13175
  }
13176
+ async function remoteStatus(record, flags) {
13177
+ const document = record.rootId && record.docId ? (await fetchState(record)).document : void 0;
13178
+ const expected = expectedBinding(flags);
13179
+ const actual = {
13180
+ joinId: record.joinId,
13181
+ scope: record.scope,
13182
+ workspaceId: record.workspaceId,
13183
+ rootId: record.rootId,
13184
+ docId: document?.docId ?? record.docId,
13185
+ path: document?.path,
13186
+ currentHead: document?.currentSha ?? record.currentHead,
13187
+ agent: { id: record.agentId, name: record.agentName },
13188
+ currentDocument: document ? {
13189
+ docId: document.docId,
13190
+ path: document.path,
13191
+ title: document.title,
13192
+ currentSha: document.currentSha
13193
+ } : void 0
13194
+ };
13195
+ const mismatches = bindingMismatches(expected, actual);
13196
+ if (mismatches.length > 0) {
13197
+ throw new CliError("wrong_binding", "This Magic binding is for a different document or scope.", {
13198
+ hint: "Use the connector URL or CLI join command from the handoff copy.",
13199
+ details: {
13200
+ expected,
13201
+ actual,
13202
+ verification: { matches: false, mismatches }
13203
+ }
13204
+ });
13205
+ }
13206
+ return {
13207
+ ok: true,
13208
+ connected: true,
13209
+ ...actual,
13210
+ expected,
13211
+ verification: { matches: true, mismatches: [] },
13212
+ nextCommands: [
13213
+ record.scope === "workspace" ? "mdocs remote map --json" : void 0,
13214
+ record.scope === "project" ? "mdocs remote map --json" : void 0,
13215
+ record.scope === "workspace" ? void 0 : "mdocs remote context --summary --json"
13216
+ ].filter(Boolean)
13217
+ };
13218
+ }
12868
13219
  async function remoteGraph(record) {
12869
13220
  assertProjectScope(record, "graph");
12870
13221
  const rootRecord = assertRootScoped(record, "graph");
@@ -13054,6 +13405,7 @@ async function remoteCreateFile(root, record, parsed) {
13054
13405
  path: normalizedPath,
13055
13406
  title,
13056
13407
  markdown,
13408
+ reviewMarkdown: markdown,
13057
13409
  sidecar,
13058
13410
  anchors: [],
13059
13411
  images: [],
@@ -13108,13 +13460,24 @@ async function remoteSuggest(root, record, parsed) {
13108
13460
  const replacement = await readRequiredTextFlag(parsed.flags, root, ["with", "replacement"]);
13109
13461
  const message = await readOptionalTextFlag(parsed.flags, root, ["message"]) ?? "Suggested edit";
13110
13462
  const range = parseRange(requiredFlag(parsed.flags, "range"));
13111
- const result = await submitReview(
13112
- root,
13113
- record,
13114
- parsed.command[2] ?? record.docId,
13115
- (io, docId) => addSuggestion(io, docId, range, replacement, message, actorForRecord(record))
13463
+ const pathOrDocId = parsed.command[2] ?? record.docId;
13464
+ if (!pathOrDocId) {
13465
+ throw new CliError("usage_error", "Missing document path or docId.", {
13466
+ hint: "Pass a document path or docId, or join with --doc <docId>."
13467
+ });
13468
+ }
13469
+ const document = await fetchDocument(record, pathOrDocId);
13470
+ const documentRecord = rootScopedRecordFor(record, document.rootId);
13471
+ await refreshPresence(documentRecord, document.docId);
13472
+ const result = await postReviewOperation(
13473
+ documentRecord,
13474
+ document.docId,
13475
+ { kind: "create_suggestion", baseHead: document.currentSha, payload: { ...range, replacement, message } },
13476
+ actorForRecord(record)
13116
13477
  );
13117
- return { suggestion: result.created, document: result.document };
13478
+ if (result.document) await recordHead(root, record, result.document);
13479
+ const placementStatus = result.reviewRecord?.placement?.status;
13480
+ return { suggestion: result.suggestion, reviewRecord: result.reviewRecord, placementStatus, projectionStatus: result.projectionStatus, document: result.document };
13118
13481
  }
13119
13482
  async function remoteCreateFolder(record, parsed) {
13120
13483
  assertProjectScope(record, "create-folder");
@@ -13204,44 +13567,14 @@ async function remoteReject(root, record, parsed) {
13204
13567
  hint: "Only open suggestions can be rejected."
13205
13568
  });
13206
13569
  }
13207
- try {
13208
- const additions = { anchors: [], comments: [], suggestions: [], withdrawSuggestionIds: [suggestionId] };
13209
- const pushed = await postReview(documentRecord, document.docId, additions, actor);
13210
- await recordHead(root, record, pushed);
13211
- const updated = pushed.sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
13212
- return { suggestion: updated ?? suggestion, document: pushed };
13213
- } catch (error) {
13214
- if (!isWithdrawalUnsupported(error)) throw error;
13215
- }
13216
- if (suggestion.author.id !== actor.id) {
13217
- throw new CliError("unauthorized", "You can only withdraw your own suggestions; rejecting another author's requires edit access.", {
13218
- hint: "Ask a human collaborator to reject it from the editor."
13219
- });
13220
- }
13221
- for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
13222
- if (index > 0) document = await fetchDocument(record, document.docId);
13223
- documentRecord = rootScopedRecordFor(record, document.rootId);
13224
- const io = new RemoteDocumentIO(document);
13225
- const rejected = await rejectSuggestion(io, suggestionId, actor);
13226
- const state = await getDocumentState(io, document.docId);
13227
- try {
13228
- const pushed = await pushDocument(documentRecord, document, state.markdown, state.sidecar);
13229
- await recordHead(root, record, pushed);
13230
- return { suggestion: rejected, document: pushed };
13231
- } catch (error) {
13232
- const conflicted = isCliError(error) && error.code === "conflict";
13233
- if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
13234
- }
13235
- }
13236
- throw new CliError("conflict", "Remote change was not accepted after retries.", {
13237
- hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
13238
- });
13239
- }
13240
- function isWithdrawalUnsupported(error) {
13241
- if (isReviewEndpointUnsupported(error)) return true;
13242
- if (!isCliError(error)) return false;
13243
- const details = error.details ?? {};
13244
- return details.error === "empty_review";
13570
+ const result = await postReviewOperation(
13571
+ documentRecord,
13572
+ document.docId,
13573
+ { kind: "reject_suggestion", baseHead: document.currentSha, payload: { suggestionId } },
13574
+ actor
13575
+ );
13576
+ if (result.document) await recordHead(root, record, result.document);
13577
+ return { suggestion: result.suggestion ?? { ...suggestion, status: "rejected" }, reviewRecord: result.reviewRecord, document: result.document };
13245
13578
  }
13246
13579
  async function submitReview(root, record, pathOrDocId, mutate) {
13247
13580
  if (!pathOrDocId) {
@@ -13262,43 +13595,14 @@ async function submitReview(root, record, pathOrDocId, mutate) {
13262
13595
  additions: {
13263
13596
  anchors: state.sidecar.anchors.filter((anchor) => anchor.id === created.anchorId),
13264
13597
  comments: state.sidecar.comments.filter((comment2) => comment2.id === created.id),
13265
- suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.id === created.id)
13598
+ reviewMarkdown: state.sidecar.reviewMarkdown
13266
13599
  }
13267
13600
  };
13268
13601
  };
13269
- let attempt = await build(document);
13270
- try {
13271
- const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
13272
- await recordHead(root, record, pushed);
13273
- return { created: attempt.created, document: pushed };
13274
- } catch (error) {
13275
- if (!isReviewEndpointUnsupported(error)) throw error;
13276
- }
13277
- for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
13278
- if (index > 0) {
13279
- document = await fetchDocument(record, document.docId);
13280
- documentRecord = rootScopedRecordFor(record, document.rootId);
13281
- attempt = await build(document);
13282
- }
13283
- try {
13284
- const pushed = await pushDocument(documentRecord, document, attempt.state.markdown, attempt.state.sidecar);
13285
- await recordHead(root, record, pushed);
13286
- return { created: attempt.created, document: pushed };
13287
- } catch (error) {
13288
- const conflicted = isCliError(error) && error.code === "conflict";
13289
- if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
13290
- }
13291
- }
13292
- throw new CliError("conflict", "Remote change was not accepted after retries.", {
13293
- hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
13294
- });
13295
- }
13296
- function isReviewEndpointUnsupported(error) {
13297
- if (!isCliError(error)) return false;
13298
- if (error.code === "unauthorized") return true;
13299
- if (error.code !== "not_found") return false;
13300
- const details = error.details ?? {};
13301
- return details.error !== "document_not_found";
13602
+ const attempt = await build(document);
13603
+ const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
13604
+ await recordHead(root, record, pushed);
13605
+ return { created: attempt.created, document: pushed };
13302
13606
  }
13303
13607
  async function recordHead(root, record, document) {
13304
13608
  await writeJoinRecord(root, { ...record, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), currentHead: document.currentSha });
@@ -13379,6 +13683,33 @@ function joinSummary(record, document) {
13379
13683
  ].filter(Boolean)
13380
13684
  };
13381
13685
  }
13686
+ function expectedBinding(flags) {
13687
+ const scope = stringFlag(flags, "expect-scope", "expected-scope");
13688
+ const rootId = stringFlag(flags, "expect-root", "expected-root");
13689
+ const docId = stringFlag(flags, "expect-doc", "expected-doc");
13690
+ const path = stringFlag(flags, "expect-path", "expected-path");
13691
+ return {
13692
+ ...scope ? { scope } : {},
13693
+ ...rootId ? { rootId } : {},
13694
+ ...docId ? { docId } : {},
13695
+ ...path ? { path } : {}
13696
+ };
13697
+ }
13698
+ function stringFlag(flags, primary, alias) {
13699
+ const value = flags[primary] ?? flags[alias];
13700
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
13701
+ }
13702
+ function bindingMismatches(expected, actual) {
13703
+ const checks = [
13704
+ ["scope", expected.scope, actual.scope],
13705
+ ["rootId", expected.rootId, actual.rootId],
13706
+ ["docId", expected.docId, actual.docId],
13707
+ ["path", expected.path, actual.path]
13708
+ ];
13709
+ return checks.flatMap(
13710
+ ([field, expectedValue, actualValue]) => expectedValue && expectedValue !== actualValue ? [{ field, expected: expectedValue, actual: actualValue }] : []
13711
+ );
13712
+ }
13382
13713
  function assertProjectScope(record, command) {
13383
13714
  if (record.scope === "project") return;
13384
13715
  throw new CliError("usage_error", `remote ${command} requires a project-scoped join.`, {
@@ -13446,6 +13777,8 @@ function optionalFolderTarget(value) {
13446
13777
  import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
13447
13778
  import { basename as basename2, resolve as resolve4 } from "node:path";
13448
13779
  var BRIDGE_CONFIG_PATH = ".mdocs/bridge.json";
13780
+ var BRIDGE_STATUS_PATH = ".mdocs/bridge-status.json";
13781
+ var BRIDGE_HEARTBEAT_INTERVAL_MS = 1e4;
13449
13782
  function bridgeSetupIdentity(input) {
13450
13783
  const actorName = input.actorName?.trim() || "Agent";
13451
13784
  return {
@@ -13511,9 +13844,11 @@ async function runBridgeResume(options) {
13511
13844
  }
13512
13845
  async function runBridge(options) {
13513
13846
  const root = resolve4(options.root);
13847
+ if (options.once && options.forever) {
13848
+ throw new Error("Use either --once or --forever, not both.");
13849
+ }
13514
13850
  const localManifestSource = await readLocalSource(root);
13515
13851
  const rootId = options.rootId ?? options.sourceId ?? localManifestSource?.sourceId ?? rootIdForPath(root);
13516
- const token = options.token ?? (options.requestToken ? await requestBridgeToken(options, rootId) : void 0);
13517
13852
  const replicaId = `replica_${createHash2("sha256").update(`${root}:${options.actorId}`).digest("hex").slice(0, 12)}`;
13518
13853
  const replicaKind = actorKindForBridge(options.actorId) === "agent" ? "agent_runtime" : "local";
13519
13854
  const mapping = createSourceMapping({
@@ -13536,7 +13871,29 @@ async function runBridge(options) {
13536
13871
  const claimMode = Boolean(options.claimToken || localManifestSource?.canonicalHead);
13537
13872
  let lastAppliedHead = localManifestSource?.canonicalHead;
13538
13873
  let socket;
13539
- const registeredRoot = token ? await fetchScopedRoot({ ...options, token }, rootId) : await registerRoot(options, root, rootId, mapping);
13874
+ let lastSuccessfulSyncAt;
13875
+ let lastHeartbeatAt;
13876
+ let lastHeartbeatSentMs = 0;
13877
+ let lastError;
13878
+ await writeCurrentBridgeStatus("starting");
13879
+ let token = options.token;
13880
+ if (!token && options.requestToken) {
13881
+ try {
13882
+ token = await requestBridgeToken(options, rootId);
13883
+ } catch (error) {
13884
+ lastError = errorMessage(error);
13885
+ await writeCurrentBridgeStatus("error");
13886
+ throw error;
13887
+ }
13888
+ }
13889
+ let registeredRoot;
13890
+ try {
13891
+ registeredRoot = token ? await fetchScopedRoot({ ...options, token }, rootId) : await registerRoot(options, root, rootId, mapping);
13892
+ } catch (error) {
13893
+ lastError = errorMessage(error);
13894
+ await writeCurrentBridgeStatus("error");
13895
+ throw error;
13896
+ }
13540
13897
  lastAppliedHead = lastAppliedHead ?? registeredRoot?.canonical.head;
13541
13898
  await writeSourceState(lastAppliedHead);
13542
13899
  await writeBridgeConfig(root, {
@@ -13554,20 +13911,30 @@ async function runBridge(options) {
13554
13911
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13555
13912
  });
13556
13913
  const backfilled = await resumeFromCanonical();
13557
- if (options.once) return;
13914
+ if (options.once) {
13915
+ lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
13916
+ await writeCurrentBridgeStatus("once_complete");
13917
+ return;
13918
+ }
13558
13919
  connect();
13559
13920
  if (claimMode && !backfilled) await primeLocalSignatures();
13560
13921
  else await publishSnapshot("initial");
13561
13922
  const timer = setInterval(() => {
13562
13923
  void publishSnapshot("poll").catch((error) => {
13563
- process.stderr.write(`bridge poll failed: ${error instanceof Error ? error.message : String(error)}
13924
+ lastError = errorMessage(error);
13925
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
13926
+ sendHeartbeat("error", lastError);
13927
+ process.stderr.write(`bridge poll failed: ${lastError}
13564
13928
  `);
13565
13929
  });
13566
13930
  }, options.intervalMs);
13567
- process.on("SIGINT", () => {
13931
+ process.once("SIGINT", () => {
13568
13932
  clearInterval(timer);
13569
- socket?.close();
13570
- process.exit(0);
13933
+ sendHeartbeat("offline", void 0, false);
13934
+ void writeCurrentBridgeStatus("stopped").catch(() => void 0).finally(() => {
13935
+ socket?.close();
13936
+ process.exit(0);
13937
+ });
13571
13938
  });
13572
13939
  await new Promise(() => void 0);
13573
13940
  function connect() {
@@ -13578,8 +13945,11 @@ async function runBridge(options) {
13578
13945
  if (token) url.searchParams.set("token", token);
13579
13946
  socket = new WebSocket(url);
13580
13947
  socket.addEventListener("open", () => {
13948
+ lastError = void 0;
13581
13949
  process.stdout.write(`mdocs bridge connected ${root} -> ${options.workspaceId}/${rootId}
13582
13950
  `);
13951
+ sendHeartbeat("synced");
13952
+ void writeCurrentBridgeStatus("connected").catch(() => void 0);
13583
13953
  });
13584
13954
  socket.addEventListener("message", (event) => {
13585
13955
  if (typeof event.data !== "string") return;
@@ -13592,9 +13962,12 @@ async function runBridge(options) {
13592
13962
  });
13593
13963
  socket.addEventListener("close", () => {
13594
13964
  socket = void 0;
13965
+ void writeCurrentBridgeStatus("offline").catch(() => void 0);
13595
13966
  setTimeout(connect, 1e3);
13596
13967
  });
13597
13968
  socket.addEventListener("error", () => {
13969
+ lastError = "WebSocket error";
13970
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
13598
13971
  socket?.close();
13599
13972
  });
13600
13973
  }
@@ -13603,7 +13976,7 @@ async function runBridge(options) {
13603
13976
  const currentDocIds = new Set(map.docs.map((doc) => doc.docId));
13604
13977
  const treeSignature = hashJson(map.docs.map((doc) => ({ docId: doc.docId, path: doc.path })));
13605
13978
  if (treeSignature !== lastTreeSignature) {
13606
- if (send("file-tree", { reason, docs: map.docs, mapping, canonicalHead: lastAppliedHead })) {
13979
+ if (send("file-tree", { reason, docs: map.docs, mapping: bridgeReplicaMetadata("synced"), canonicalHead: lastAppliedHead })) {
13607
13980
  lastTreeSignature = treeSignature;
13608
13981
  }
13609
13982
  }
@@ -13631,6 +14004,9 @@ async function runBridge(options) {
13631
14004
  pendingDeletes.add(docId);
13632
14005
  }
13633
14006
  }
14007
+ lastError = void 0;
14008
+ lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
14009
+ maybeSendHeartbeat("synced");
13634
14010
  }
13635
14011
  async function resumeFromCanonical() {
13636
14012
  if (!options.resume && !token) return false;
@@ -13743,7 +14119,9 @@ async function runBridge(options) {
13743
14119
  }
13744
14120
  if (message.type === "sync-error") {
13745
14121
  const payload = message.payload;
13746
- process.stderr.write(`sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}
14122
+ lastError = `sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}`;
14123
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
14124
+ process.stderr.write(`${lastError}
13747
14125
  `);
13748
14126
  if (!payload.docId) return;
13749
14127
  pendingDocs.delete(payload.docId);
@@ -13782,6 +14160,12 @@ async function runBridge(options) {
13782
14160
  await io.writeTextAtomic(payload.path, payload.markdown);
13783
14161
  await io.writeTextAtomic(sidecarPath(payload.docId), `${JSON.stringify(payload.sidecar, null, 2)}
13784
14162
  `);
14163
+ const reviewMarkdown = projectReviewMarkdown(payload.markdown, payload.sidecar.suggestions);
14164
+ if (reviewMarkdown === payload.markdown) {
14165
+ await io.deleteFile(reviewFilePath(payload.path)).catch(() => void 0);
14166
+ } else {
14167
+ await io.writeTextAtomic(reviewFilePath(payload.path), reviewMarkdown);
14168
+ }
13785
14169
  await upsertManifestDocument(payload);
13786
14170
  signatures.set(payload.docId, remoteSignature);
13787
14171
  deniedSignatures.delete(payload.docId);
@@ -13862,6 +14246,63 @@ async function runBridge(options) {
13862
14246
  );
13863
14247
  return true;
13864
14248
  }
14249
+ function maybeSendHeartbeat(status) {
14250
+ if (Date.now() - lastHeartbeatSentMs < BRIDGE_HEARTBEAT_INTERVAL_MS) return;
14251
+ sendHeartbeat(status);
14252
+ }
14253
+ function sendHeartbeat(status, error, writeLocalStatus = true) {
14254
+ const heartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
14255
+ const sent = send("bridge-heartbeat", {
14256
+ reason: "heartbeat",
14257
+ canonicalHead: lastAppliedHead,
14258
+ mapping: bridgeReplicaMetadata(status, error, heartbeatAt)
14259
+ });
14260
+ if (sent) {
14261
+ lastHeartbeatAt = heartbeatAt;
14262
+ lastHeartbeatSentMs = Date.now();
14263
+ if (writeLocalStatus) {
14264
+ void writeCurrentBridgeStatus(status === "error" ? "error" : status === "offline" ? "offline" : "connected").catch(() => void 0);
14265
+ }
14266
+ }
14267
+ return sent;
14268
+ }
14269
+ function bridgeReplicaMetadata(status, error, now = (/* @__PURE__ */ new Date()).toISOString()) {
14270
+ return {
14271
+ ...mapping,
14272
+ lastAppliedHead,
14273
+ status,
14274
+ updatedAt: now,
14275
+ lastSeenAt: now,
14276
+ bridgeVersion: CLI_VERSION,
14277
+ pendingDocuments: pendingDocs.size,
14278
+ pendingDeletes: pendingDeletes.size,
14279
+ ...error ? { lastError: error } : {}
14280
+ };
14281
+ }
14282
+ function currentBridgeStatus(state) {
14283
+ return {
14284
+ schemaVersion: 1,
14285
+ workspaceId: options.workspaceId,
14286
+ rootId,
14287
+ actorId: options.actorId,
14288
+ actorName: options.actorName,
14289
+ sourceName: options.sourceName,
14290
+ baseUrl: options.baseUrl,
14291
+ state,
14292
+ connected: state === "connected",
14293
+ bridgeVersion: CLI_VERSION,
14294
+ lastAppliedHead,
14295
+ lastSuccessfulSyncAt,
14296
+ lastHeartbeatAt,
14297
+ lastError,
14298
+ pendingDocuments: pendingDocs.size,
14299
+ pendingDeletes: pendingDeletes.size,
14300
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
14301
+ };
14302
+ }
14303
+ async function writeCurrentBridgeStatus(state) {
14304
+ await writeBridgeStatus(root, currentBridgeStatus(state));
14305
+ }
13865
14306
  async function primeLocalSignatures() {
13866
14307
  const map = await getWorkspaceMap(io);
13867
14308
  for (const doc of map.docs) {
@@ -13989,6 +14430,12 @@ async function writeBridgeConfig(root, config2) {
13989
14430
  await io.writeTextAtomic(BRIDGE_CONFIG_PATH, `${JSON.stringify(config2, null, 2)}
13990
14431
  `);
13991
14432
  }
14433
+ async function writeBridgeStatus(root, status) {
14434
+ const io = new NodeWorkspaceIO(root);
14435
+ await io.mkdir(".mdocs");
14436
+ await io.writeTextAtomic(BRIDGE_STATUS_PATH, `${JSON.stringify(status, null, 2)}
14437
+ `);
14438
+ }
13992
14439
  async function fetchCanonicalSnapshot(options, rootId) {
13993
14440
  const response = await fetch(new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}/sync`, options.baseUrl), {
13994
14441
  headers: authHeaders(options.token)
@@ -14059,11 +14506,14 @@ function parseBridgeSyncMessage(data) {
14059
14506
  }
14060
14507
  }
14061
14508
  function delay(ms) {
14062
- return new Promise((resolve6) => setTimeout(resolve6, ms));
14509
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
14063
14510
  }
14064
14511
  function authHeaders(token) {
14065
14512
  return token ? { Authorization: `Bearer ${token}` } : {};
14066
14513
  }
14514
+ function errorMessage(error) {
14515
+ return error instanceof Error ? error.message : String(error);
14516
+ }
14067
14517
  async function fetchScopedRoot(options, rootId) {
14068
14518
  const response = await fetch(
14069
14519
  new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}`, options.baseUrl),
@@ -14167,6 +14617,9 @@ function conflictCopyPath(path) {
14167
14617
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "").slice(0, 15);
14168
14618
  return path.replace(/(\.[^./]+)?$/, (extension) => `.conflict-${stamp}${extension || ".md"}`);
14169
14619
  }
14620
+ function reviewFilePath(path) {
14621
+ return path.replace(/(\.md)?$/i, ".review.md");
14622
+ }
14170
14623
  function hashJson(value) {
14171
14624
  return createHash2("sha256").update(JSON.stringify(value)).digest("hex");
14172
14625
  }
@@ -14198,10 +14651,246 @@ function readDocumentPayload(payload) {
14198
14651
  };
14199
14652
  }
14200
14653
 
14654
+ // src/bridge-startup.ts
14655
+ import { execFile as execFile2 } from "node:child_process";
14656
+ import { createHash as createHash3 } from "node:crypto";
14657
+ import { chmod, copyFile, mkdir as mkdir4, readFile as readFile6, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
14658
+ import { homedir, platform } from "node:os";
14659
+ import { dirname as dirname5, join as join4, resolve as resolve5 } from "node:path";
14660
+ import { promisify as promisify2 } from "node:util";
14661
+ var execFileAsync2 = promisify2(execFile2);
14662
+ async function runBridgeStartup(options) {
14663
+ const root = resolve5(options.root);
14664
+ await assertBridgeConfigured(root);
14665
+ const requestedMode = readBridgeStartupMode(options.mode);
14666
+ const mode = requestedMode === "auto" ? await autoBridgeStartupMode() : requestedMode;
14667
+ const startupDir = join4(root, ".mdocs", "startup");
14668
+ await mkdir4(startupDir, { recursive: true });
14669
+ const runnerPath = join4(startupDir, "bridge-runner.sh");
14670
+ const runnerArgs = bridgeResumeArgs(root, options);
14671
+ const runnerScript = bridgeRunnerScript(root, runnerArgs);
14672
+ await writeFile4(runnerPath, runnerScript, "utf8");
14673
+ await chmod(runnerPath, 493);
14674
+ const nativePlan = mode === "launchd" ? await writeLaunchdPlan(root, startupDir, runnerPath) : mode === "systemd" ? await writeSystemdPlan(root, startupDir, runnerPath) : void 0;
14675
+ if (options.install && !nativePlan) {
14676
+ throw new CliError("usage_error", "Shell startup mode does not have a native service to install.", {
14677
+ hint: "Use the printed startupCommand in your sandbox/provider startup hook, or pass --mode launchd/systemd on a supported host."
14678
+ });
14679
+ }
14680
+ if (options.install && nativePlan) await nativePlan.install();
14681
+ const result = {
14682
+ ok: true,
14683
+ root,
14684
+ mode,
14685
+ runnerPath,
14686
+ startupCommand: shellCommand(["sh", runnerPath]),
14687
+ servicePath: nativePlan?.servicePath,
14688
+ installPath: nativePlan?.installPath,
14689
+ installed: Boolean(options.install && nativePlan),
14690
+ installCommands: nativePlan?.installCommands ?? [],
14691
+ notes: [
14692
+ "Sandbox providers such as Daytona, Sprites, and Modal should run startupCommand from their on-start hook.",
14693
+ "The runner stores no bridge token. If you use manual tokens, provide MDOCS_BRIDGE_TOKEN through the platform secret environment.",
14694
+ "If no MDOCS_BRIDGE_TOKEN is present, the runner uses --request-token and logs a Magic approval URL."
14695
+ ]
14696
+ };
14697
+ await writeFile4(join4(startupDir, "bridge-startup.json"), `${JSON.stringify(result, null, 2)}
14698
+ `, "utf8");
14699
+ return result;
14700
+ }
14701
+ function formatBridgeStartupResult(result) {
14702
+ const lines = [
14703
+ `Wrote Magic Markdown bridge runner: ${result.runnerPath}`,
14704
+ `Startup command: ${result.startupCommand}`
14705
+ ];
14706
+ if (result.servicePath) lines.push(`Wrote ${result.mode} service file: ${result.servicePath}`);
14707
+ if (result.installed && result.installPath) {
14708
+ lines.push(`Installed ${result.mode} service: ${result.installPath}`);
14709
+ } else if (result.installCommands.length > 0) {
14710
+ lines.push("", "To install and start it manually:", ...result.installCommands.map((command) => ` ${command}`));
14711
+ }
14712
+ lines.push("", ...result.notes.map((note) => `Note: ${note}`));
14713
+ return lines.join("\n");
14714
+ }
14715
+ function bridgeRunnerScript(root, args) {
14716
+ return `#!/usr/bin/env sh
14717
+ set -eu
14718
+ cd ${shellQuote(root)}
14719
+ exec npx --yes --package=@magic-markdown/cli@latest mdocs ${shellCommand(args)} "$@"
14720
+ `;
14721
+ }
14722
+ function bridgeResumeArgs(root, options) {
14723
+ const args = ["bridge", "resume", "--root", root, "--forever"];
14724
+ if (options.requestToken !== false) args.push("--request-token");
14725
+ if (options.baseUrl) args.push("--url", options.baseUrl);
14726
+ if (options.intervalMs) args.push("--interval", String(options.intervalMs));
14727
+ return args;
14728
+ }
14729
+ function readBridgeStartupMode(value) {
14730
+ if (!value || value === "auto" || value === "shell" || value === "launchd" || value === "systemd") {
14731
+ return value || "auto";
14732
+ }
14733
+ throw new CliError("usage_error", `Unknown bridge startup mode: ${value}`, {
14734
+ hint: "Use --mode auto, shell, launchd, or systemd."
14735
+ });
14736
+ }
14737
+ async function autoBridgeStartupMode() {
14738
+ if (platform() === "darwin") return "launchd";
14739
+ if (platform() === "linux" && await pathExists("/run/systemd/system")) return "systemd";
14740
+ return "shell";
14741
+ }
14742
+ async function assertBridgeConfigured(root) {
14743
+ const configPath = join4(root, ".mdocs", "bridge.json");
14744
+ try {
14745
+ const config2 = JSON.parse(await readFile6(configPath, "utf8"));
14746
+ if (config2.schemaVersion !== 1) throw new Error("invalid schema");
14747
+ } catch {
14748
+ throw new CliError("usage_error", "This root does not have a bridge configuration yet.", {
14749
+ hint: "Run mdocs bridge setup first, then run mdocs bridge startup from the same root."
14750
+ });
14751
+ }
14752
+ }
14753
+ async function writeLaunchdPlan(root, startupDir, runnerPath) {
14754
+ const label = serviceLabel(root);
14755
+ const servicePath = join4(startupDir, `${label}.plist`);
14756
+ const installPath = join4(homedir(), "Library", "LaunchAgents", `${label}.plist`);
14757
+ const outLog = join4(root, ".mdocs", "bridge.log");
14758
+ const errLog = join4(root, ".mdocs", "bridge.err.log");
14759
+ await writeFile4(servicePath, launchdPlist(label, root, runnerPath, outLog, errLog), "utf8");
14760
+ const target = `gui/$(id -u)`;
14761
+ return {
14762
+ servicePath,
14763
+ installPath,
14764
+ installCommands: [
14765
+ shellCommand(["mkdir", "-p", dirname5(installPath)]),
14766
+ shellCommand(["cp", servicePath, installPath]),
14767
+ `launchctl bootout ${target} ${shellQuote(installPath)} 2>/dev/null || true`,
14768
+ `launchctl bootstrap ${target} ${shellQuote(installPath)}`,
14769
+ `launchctl enable ${target}/${label}`,
14770
+ `launchctl kickstart -k ${target}/${label}`
14771
+ ],
14772
+ install: async () => {
14773
+ await mkdir4(dirname5(installPath), { recursive: true });
14774
+ await copyFile(servicePath, installPath);
14775
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
14776
+ if (uid === void 0) return;
14777
+ const launchTarget = `gui/${uid}`;
14778
+ await runOptionalCommand("launchctl", ["bootout", launchTarget, installPath]);
14779
+ await runCommand("launchctl", ["bootstrap", launchTarget, installPath]);
14780
+ await runCommand("launchctl", ["enable", `${launchTarget}/${label}`]);
14781
+ await runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${label}`]);
14782
+ }
14783
+ };
14784
+ }
14785
+ async function writeSystemdPlan(root, startupDir, runnerPath) {
14786
+ const unitName = `${serviceLabel(root)}.service`;
14787
+ const servicePath = join4(startupDir, unitName);
14788
+ const installPath = join4(homedir(), ".config", "systemd", "user", unitName);
14789
+ await writeFile4(servicePath, systemdUnit(unitName, root, runnerPath), "utf8");
14790
+ return {
14791
+ servicePath,
14792
+ installPath,
14793
+ installCommands: [
14794
+ shellCommand(["mkdir", "-p", dirname5(installPath)]),
14795
+ shellCommand(["cp", servicePath, installPath]),
14796
+ "systemctl --user daemon-reload",
14797
+ shellCommand(["systemctl", "--user", "enable", "--now", unitName])
14798
+ ],
14799
+ install: async () => {
14800
+ await mkdir4(dirname5(installPath), { recursive: true });
14801
+ await copyFile(servicePath, installPath);
14802
+ await runCommand("systemctl", ["--user", "daemon-reload"]);
14803
+ await runCommand("systemctl", ["--user", "enable", "--now", unitName]);
14804
+ }
14805
+ };
14806
+ }
14807
+ function launchdPlist(label, root, runnerPath, outLog, errLog) {
14808
+ return `<?xml version="1.0" encoding="UTF-8"?>
14809
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
14810
+ <plist version="1.0">
14811
+ <dict>
14812
+ <key>Label</key>
14813
+ <string>${escapeXml(label)}</string>
14814
+ <key>ProgramArguments</key>
14815
+ <array>
14816
+ <string>/bin/sh</string>
14817
+ <string>${escapeXml(runnerPath)}</string>
14818
+ </array>
14819
+ <key>WorkingDirectory</key>
14820
+ <string>${escapeXml(root)}</string>
14821
+ <key>RunAtLoad</key>
14822
+ <true/>
14823
+ <key>KeepAlive</key>
14824
+ <true/>
14825
+ <key>StandardOutPath</key>
14826
+ <string>${escapeXml(outLog)}</string>
14827
+ <key>StandardErrorPath</key>
14828
+ <string>${escapeXml(errLog)}</string>
14829
+ </dict>
14830
+ </plist>
14831
+ `;
14832
+ }
14833
+ function systemdUnit(unitName, root, runnerPath) {
14834
+ return `[Unit]
14835
+ Description=Magic Markdown bridge (${unitName})
14836
+ After=network-online.target
14837
+ Wants=network-online.target
14838
+
14839
+ [Service]
14840
+ Type=simple
14841
+ WorkingDirectory=${systemdQuote(root)}
14842
+ ExecStart=/bin/sh ${systemdQuote(runnerPath)}
14843
+ Restart=always
14844
+ RestartSec=5
14845
+ Environment=PATH=${systemdQuote(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}
14846
+
14847
+ [Install]
14848
+ WantedBy=default.target
14849
+ `;
14850
+ }
14851
+ function serviceLabel(root) {
14852
+ const digest = createHash3("sha256").update(root).digest("hex").slice(0, 12);
14853
+ return `com.magic-markdown.bridge.${digest}`;
14854
+ }
14855
+ async function runOptionalCommand(command, args) {
14856
+ try {
14857
+ await execFileAsync2(command, args);
14858
+ } catch {
14859
+ }
14860
+ }
14861
+ async function runCommand(command, args) {
14862
+ try {
14863
+ await execFileAsync2(command, args);
14864
+ } catch (error) {
14865
+ const detail = error;
14866
+ throw new CliError("internal_error", `${command} ${args.join(" ")} failed: ${detail.stderr || detail.message || String(error)}`);
14867
+ }
14868
+ }
14869
+ async function pathExists(path) {
14870
+ try {
14871
+ await stat3(path);
14872
+ return true;
14873
+ } catch {
14874
+ return false;
14875
+ }
14876
+ }
14877
+ function shellCommand(args) {
14878
+ return args.map(shellQuote).join(" ");
14879
+ }
14880
+ function shellQuote(value) {
14881
+ return `'${value.replaceAll("'", "'\\''")}'`;
14882
+ }
14883
+ function systemdQuote(value) {
14884
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
14885
+ }
14886
+ function escapeXml(value) {
14887
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
14888
+ }
14889
+
14201
14890
  // src/index.ts
14202
14891
  async function main() {
14203
14892
  const parsed = parseArgs(process.argv.slice(2));
14204
- const cwd = resolve5(String(parsed.flags.cwd ?? process.cwd()));
14893
+ const cwd = resolve6(String(parsed.flags.cwd ?? process.cwd()));
14205
14894
  const io = new NodeWorkspaceIO(cwd);
14206
14895
  const [command, subcommand] = parsed.command;
14207
14896
  if (parsed.flags.version || command === "version") {
@@ -14216,7 +14905,7 @@ async function main() {
14216
14905
  }
14217
14906
  switch (command) {
14218
14907
  case "init": {
14219
- const target = resolve5(parsed.command[1] ?? cwd);
14908
+ const target = resolve6(parsed.command[1] ?? cwd);
14220
14909
  const targetIo = new NodeWorkspaceIO(target);
14221
14910
  const manifest = await indexWorkspace(targetIo);
14222
14911
  print(manifest, parsed.flags);
@@ -14253,7 +14942,7 @@ async function main() {
14253
14942
  markdown,
14254
14943
  images: state.images.map((image2) => ({
14255
14944
  ...image2,
14256
- ...image2.workspacePath ? { absolutePath: resolve5(cwd, image2.workspacePath) } : {}
14945
+ ...image2.workspacePath ? { absolutePath: resolve6(cwd, image2.workspacePath) } : {}
14257
14946
  })),
14258
14947
  links: state.links,
14259
14948
  ...parsed.flags["no-review"] ? {} : {
@@ -14357,7 +15046,7 @@ async function main() {
14357
15046
  return;
14358
15047
  }
14359
15048
  case "bridge": {
14360
- const root = resolve5(String(parsed.flags.root ?? cwd));
15049
+ const root = resolve6(String(parsed.flags.root ?? cwd));
14361
15050
  const baseOptions = {
14362
15051
  root,
14363
15052
  rootId: typeof parsed.flags["root-id"] === "string" ? parsed.flags["root-id"] : void 0,
@@ -14373,8 +15062,21 @@ async function main() {
14373
15062
  token: typeof parsed.flags.token === "string" ? parsed.flags.token : process.env.MDOCS_BRIDGE_TOKEN || void 0,
14374
15063
  requestToken: Boolean(parsed.flags["request-token"] || parsed.flags.pair),
14375
15064
  pairingTimeoutMs: typeof parsed.flags["pairing-timeout-ms"] === "string" ? Number(parsed.flags["pairing-timeout-ms"]) : void 0,
14376
- once: Boolean(parsed.flags.once)
15065
+ once: Boolean(parsed.flags.once),
15066
+ forever: Boolean(parsed.flags.forever)
14377
15067
  };
15068
+ if (subcommand === "startup" || subcommand === "daemon") {
15069
+ const result = await runBridgeStartup({
15070
+ root,
15071
+ mode: typeof parsed.flags.mode === "string" ? parsed.flags.mode : void 0,
15072
+ install: Boolean(parsed.flags.install),
15073
+ requestToken: !parsed.flags["no-request-token"],
15074
+ baseUrl: optionalBridgeBaseUrl(parsed.flags),
15075
+ intervalMs: typeof parsed.flags.interval === "string" ? Number(parsed.flags.interval) : void 0
15076
+ });
15077
+ print(parsed.flags.json ? result : formatBridgeStartupResult(result), parsed.flags);
15078
+ return;
15079
+ }
14378
15080
  if (subcommand === "resume") {
14379
15081
  await runBridgeResume({
14380
15082
  ...baseOptions,
@@ -14513,7 +15215,7 @@ async function readRequiredTextFlag2(flags, cwd, names) {
14513
15215
  async function readOptionalTextFlag2(flags, cwd, names) {
14514
15216
  for (const name of names) {
14515
15217
  const fileValue = flags[`${name}-file`];
14516
- if (typeof fileValue === "string") return readFile6(resolve5(cwd, fileValue), "utf8");
15218
+ if (typeof fileValue === "string") return readFile7(resolve6(cwd, fileValue), "utf8");
14517
15219
  const value = flags[name];
14518
15220
  if (typeof value === "string") return value;
14519
15221
  }
@@ -14561,9 +15263,9 @@ Commands:
14561
15263
  comments <path> --json List comments
14562
15264
  comment <path> --range 3:5 --body Add a sidecar comment
14563
15265
  suggestions <path> --json List suggestions
14564
- suggest <path> --range 3:5 --with Add a sidecar suggestion
15266
+ suggest <path> --range 3:5 --with Add a reviewMarkdown suggestion
14565
15267
  suggest <path> --range 3:5 --with-file replacement.md
14566
- Add a multiline sidecar suggestion
15268
+ Add a multiline reviewMarkdown suggestion
14567
15269
  accept <suggestionId> Apply and accept a suggestion
14568
15270
  reject <suggestionId> Reject a suggestion
14569
15271
  resolve-comment <commentId> Resolve a comment thread
@@ -14575,6 +15277,7 @@ Commands:
14575
15277
  checkpoint create|list|restore Manage local reversible checkpoints
14576
15278
  join <share-url> --json Join a Magic Markdown share through the CLI
14577
15279
  joins --json List saved Magic Markdown remote joins
15280
+ remote status --json Verify the active remote join binding
14578
15281
  remote map|graph|context|review|create-file|move-file
14579
15282
  Work with documents in the active remote join
14580
15283
  remote comment|suggest Add remote review comments and suggestions
@@ -14585,9 +15288,12 @@ Commands:
14585
15288
  bridge setup --workspace <id> --root . --url <base-url> [--folder-id <id>] --request-token
14586
15289
  Initialize, validate, request approval,
14587
15290
  and start an agent filesystem bridge
14588
- bridge resume --root . --request-token [--once]
15291
+ bridge resume --root . --forever --request-token [--once]
14589
15292
  Backfill missed Magic changes, then keep
14590
15293
  the bridge running unless --once is set
15294
+ bridge startup|daemon --root . [--mode auto|shell|launchd|systemd] [--install]
15295
+ Write a bridge runner and startup hook
15296
+ for sandboxes, launchd, or systemd
14591
15297
  bridge --workspace <id> --root . --url <base-url> --request-token
14592
15298
  Request human approval, then sync an
14593
15299
  approved local root with the workspace
@@ -14607,7 +15313,8 @@ Exit codes: 0 ok, 1 internal, 2 usage, 3 conflict, 4 not found, 5 network, 6 una
14607
15313
  `);
14608
15314
  }
14609
15315
  function commandHelp(commandPath) {
14610
- const candidates = [commandPath.slice(0, 2).join(" "), commandPath[0] ?? ""].filter(Boolean);
15316
+ const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
15317
+ const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
14611
15318
  const spec = AGENT_COMMANDS.find((command) => candidates.includes(command.name));
14612
15319
  if (!spec) return false;
14613
15320
  const lines = [
@@ -14628,7 +15335,8 @@ function commandHelp(commandPath) {
14628
15335
  return true;
14629
15336
  }
14630
15337
  function usageFor(commandPath) {
14631
- const candidates = [commandPath.slice(0, 2).join(" "), commandPath[0] ?? ""].filter(Boolean);
15338
+ const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
15339
+ const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
14632
15340
  return AGENT_COMMANDS.find((command) => candidates.includes(command.name))?.usage;
14633
15341
  }
14634
15342
  main().catch((error) => {
@@ -14643,7 +15351,8 @@ main().catch((error) => {
14643
15351
  code: cliError.code,
14644
15352
  message: cliError.message,
14645
15353
  ...hint ? { hint } : {},
14646
- ...usage && cliError.hint ? { usage } : {}
15354
+ ...usage && cliError.hint ? { usage } : {},
15355
+ ...cliError.details !== void 0 ? { details: cliError.details } : {}
14647
15356
  },
14648
15357
  cliVersion: CLI_VERSION
14649
15358
  };