@attentionform/astra-mcp-server 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@ __export(index_exports, {
45
45
  takeDirFlag: () => takeDirFlag
46
46
  });
47
47
  module.exports = __toCommonJS(index_exports);
48
- var path21 = __toESM(require("path"));
48
+ var path24 = __toESM(require("path"));
49
49
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
50
50
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
51
51
  var import_types2 = require("@modelcontextprotocol/sdk/types.js");
@@ -2003,8 +2003,8 @@ function sortChildrenBySchema(element, order) {
2003
2003
  }
2004
2004
  function generateValidParaId(seed, counter = 0) {
2005
2005
  if (seed !== void 0 && seed !== null && seed !== "") {
2006
- const hash = import_crypto2.default.createHash("sha256").update(`${seed}:${counter}`).digest();
2007
- const raw = hash.readUInt32BE(0) & 2147483647;
2006
+ const hash2 = import_crypto2.default.createHash("sha256").update(`${seed}:${counter}`).digest();
2007
+ const raw = hash2.readUInt32BE(0) & 2147483647;
2008
2008
  const val2 = raw === 0 ? 1 : raw;
2009
2009
  return val2.toString(16).toUpperCase().padStart(8, "0");
2010
2010
  }
@@ -2905,8 +2905,8 @@ function parserRandomUnit() {
2905
2905
  if (injectedParserRandom) return injectedParserRandom();
2906
2906
  if (resolveReproducibleMode(void 0)) {
2907
2907
  parserDeterministicSeq += 1;
2908
- const hash = (parserDeterministicSeq * 2654435761 >>> 0) / 4294967296;
2909
- return hash === 0 ? 0.5 : hash;
2908
+ const hash2 = (parserDeterministicSeq * 2654435761 >>> 0) / 4294967296;
2909
+ return hash2 === 0 ? 0.5 : hash2;
2910
2910
  }
2911
2911
  return Math.random();
2912
2912
  }
@@ -4530,9 +4530,9 @@ function ommlToMathmlDisplay(xml) {
4530
4530
  // ../engine/src/ooxml/validator-backend.ts
4531
4531
  var nativeBackend = {
4532
4532
  name: "native",
4533
- async validateFile(path22, opts) {
4533
+ async validateFile(path25, opts) {
4534
4534
  const native = await import("@attentionform/ooxml-validator");
4535
- return native.validateFile(path22, opts ? { officeVersion: opts.officeVersion } : void 0);
4535
+ return native.validateFile(path25, opts ? { officeVersion: opts.officeVersion } : void 0);
4536
4536
  },
4537
4537
  async validateBuffer(buf, opts) {
4538
4538
  const native = await import("@attentionform/ooxml-validator");
@@ -5494,10 +5494,10 @@ function mergeCorePropertiesXml(existingXml, patch, fallback) {
5494
5494
  return { xml: serializer2.serializeToString(doc), touched };
5495
5495
  }
5496
5496
  function ensureContentTypeOverride(adapter) {
5497
- const path22 = "[Content_Types].xml";
5497
+ const path25 = "[Content_Types].xml";
5498
5498
  let raw = null;
5499
5499
  try {
5500
- raw = adapter.readTextOrNull(path22);
5500
+ raw = adapter.readTextOrNull(path25);
5501
5501
  } catch {
5502
5502
  return;
5503
5503
  }
@@ -5514,13 +5514,13 @@ function ensureContentTypeOverride(adapter) {
5514
5514
  created.setAttribute("PartName", "/docProps/core.xml");
5515
5515
  created.setAttribute("ContentType", CORE_PROPERTIES_CONTENT_TYPE);
5516
5516
  root.appendChild(created);
5517
- adapter.writeText(path22, new import_xmldom7.XMLSerializer().serializeToString(doc));
5517
+ adapter.writeText(path25, new import_xmldom7.XMLSerializer().serializeToString(doc));
5518
5518
  }
5519
5519
  function ensurePackageRel(adapter) {
5520
- const path22 = "_rels/.rels";
5520
+ const path25 = "_rels/.rels";
5521
5521
  let raw = null;
5522
5522
  try {
5523
- raw = adapter.readTextOrNull(path22);
5523
+ raw = adapter.readTextOrNull(path25);
5524
5524
  } catch {
5525
5525
  return;
5526
5526
  }
@@ -5553,7 +5553,7 @@ function ensurePackageRel(adapter) {
5553
5553
  created.setAttribute("Type", CORE_PROPERTIES_REL_TYPE);
5554
5554
  created.setAttribute("Target", CORE_PROPERTIES_PART);
5555
5555
  root.appendChild(created);
5556
- adapter.writeText(path22, new import_xmldom7.XMLSerializer().serializeToString(doc));
5556
+ adapter.writeText(path25, new import_xmldom7.XMLSerializer().serializeToString(doc));
5557
5557
  }
5558
5558
  function writeCorePropertiesPart(adapter, xml) {
5559
5559
  adapter.writeText(CORE_PROPERTIES_PART, xml);
@@ -6082,6 +6082,9 @@ var RSIDS_EMPTY_RE = /<w:rsids>\s*<\/w:rsids>/g;
6082
6082
  var RSIDS_SELF_CLOSING_RE = /<w:rsids\s*\/>/g;
6083
6083
  var AUTHOR_ATTR_RE = /\s+(w|w15):author="[^"]*"/g;
6084
6084
  var INITIALS_ATTR_RE = /\s+w:initials="[^"]*"/g;
6085
+ var PRESENCE_INFO_TAG_RE = /<w15:presenceInfo\b(?:[^>"']|"[^"]*"|'[^']*')*>/g;
6086
+ var PRESENCE_PROVIDER_NONE_RE = /\sw15:providerId=(["'])None\1/;
6087
+ var PRESENCE_USERID_RE = /(\sw15:userId=)(?:"[^"]*"|'[^']*')/;
6085
6088
  var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
6086
6089
  var PHONE_RE = /(\+\d[\d\s\-().]{6,}\d|\(\d{3}\)\s*\d{3}[\s\-.]\d{4}|\b\d{3}[\s\-.]\d{3}[\s\-.]\d{4}\b)/g;
6087
6090
  var CLAUSE_ACRONYM_RE = /\b(dfars|far|cfr|nist)\b/gi;
@@ -6853,6 +6856,15 @@ function sanitizeDocumentXml(xml, opts, partName = "word XML part", hooks) {
6853
6856
  o.replacementAuthor.split(/[\s._-]+/).filter(Boolean).map((s) => s[0]).join("").slice(0, 4).toUpperCase() || "A"
6854
6857
  );
6855
6858
  out = out.replace(INITIALS_ATTR_RE, ` w:initials="${initials}"`);
6859
+ if (partName === "word/people.xml") {
6860
+ out = out.replace(PRESENCE_INFO_TAG_RE, (tag) => {
6861
+ if (!PRESENCE_PROVIDER_NONE_RE.test(tag)) return tag;
6862
+ return tag.replace(PRESENCE_USERID_RE, (_m, open) => {
6863
+ purgedAuthors += 1;
6864
+ return `${open}"${replacement === "" ? "Anonymous" : replacement}"`;
6865
+ });
6866
+ });
6867
+ }
6856
6868
  }
6857
6869
  let acceptedRevisions = 0;
6858
6870
  let unresolvedRevisions = [];
@@ -7404,20 +7416,20 @@ function sanitizeEmbeddedWorkbookBytes(buf, outerName) {
7404
7416
  }
7405
7417
  var XML_PART_RE = /^(word\/document\.xml|word\/styles\.xml|word\/stylesWithEffects\.xml|word\/numbering\.xml|word\/comments\.xml|word\/commentsExtended\.xml|word\/footnotes\.xml|word\/endnotes\.xml|word\/settings\.xml|word\/header.*\.xml|word\/footer.*\.xml|word\/people\.xml|word\/customXml\/.*\.xml|word\/charts\/.*\.xml|word\/embeddings\/.*\.xml)$/i;
7406
7418
  function bufferZipAdapter(files, touched) {
7407
- const write = (path22, content) => {
7408
- files.set(path22, content);
7409
- touched?.add(path22);
7419
+ const write = (path25, content) => {
7420
+ files.set(path25, content);
7421
+ touched?.add(path25);
7410
7422
  };
7411
7423
  return {
7412
- hasFile: (path22) => files.has(path22),
7413
- readText: (path22) => {
7414
- const data = files.get(path22);
7415
- if (!data) throw new Error(`E_VALIDATION: Part ${path22} was not found.`);
7424
+ hasFile: (path25) => files.has(path25),
7425
+ readText: (path25) => {
7426
+ const data = files.get(path25);
7427
+ if (!data) throw new Error(`E_VALIDATION: Part ${path25} was not found.`);
7416
7428
  return data.toString("utf8");
7417
7429
  },
7418
- readTextOrNull: (path22) => files.get(path22)?.toString("utf8") ?? null,
7419
- writeText: (path22, content) => write(path22, Buffer.from(content, "utf8")),
7420
- writeBuffer: (path22, content) => write(path22, Buffer.from(content))
7430
+ readTextOrNull: (path25) => files.get(path25)?.toString("utf8") ?? null,
7431
+ writeText: (path25, content) => write(path25, Buffer.from(content, "utf8")),
7432
+ writeBuffer: (path25, content) => write(path25, Buffer.from(content))
7421
7433
  };
7422
7434
  }
7423
7435
  function deleteRemovedComments(files, ids, touched) {
@@ -10402,7 +10414,7 @@ var DocxParser = class _DocxParser {
10402
10414
  * Captures a full package snapshot: a clone of every zip entry buffer.
10403
10415
  * Used by OperationEngine for transactional rollback of failed batches.
10404
10416
  */
10405
- capturePackageSnapshot() {
10417
+ capturePackageSnapshot(options) {
10406
10418
  this.purgeRogueZipEntries();
10407
10419
  const snapshot3 = /* @__PURE__ */ new Map();
10408
10420
  for (const entry of this.zip.getEntries()) {
@@ -10413,7 +10425,7 @@ var DocxParser = class _DocxParser {
10413
10425
  snapshot3.set(entry.entryName, Buffer.from(entry.getData()));
10414
10426
  }
10415
10427
  try {
10416
- snapshot3.set("word/document.xml", Buffer.from(this.readDocumentXml(), "utf8"));
10428
+ snapshot3.set("word/document.xml", Buffer.from(options?.documentXml ?? this.readDocumentXml(), "utf8"));
10417
10429
  } catch {
10418
10430
  }
10419
10431
  return snapshot3;
@@ -10480,9 +10492,10 @@ var DocxParser = class _DocxParser {
10480
10492
  /**
10481
10493
  * Restores the package to a previously captured snapshot: every entry is
10482
10494
  * restored (entries added after the snapshot are removed), then documentDom
10483
- * and stylesDom are reloaded and stable IDs re-assigned.
10495
+ * and stylesDom are reloaded and stable IDs re-assigned. The restored package is exactly the snapshot;
10496
+ * only a restore that mixes a pre-0.4.2 commit with live parts passes `registerCoreProperties`.
10484
10497
  */
10485
- restorePackageSnapshot(snapshot3) {
10498
+ restorePackageSnapshot(snapshot3, options) {
10486
10499
  for (const entry of [...this.zip.getEntries()]) {
10487
10500
  if (!snapshot3.has(entry.entryName)) {
10488
10501
  this.zip.deleteFile(entry);
@@ -10501,8 +10514,15 @@ var DocxParser = class _DocxParser {
10501
10514
  continue;
10502
10515
  }
10503
10516
  const buffer = Buffer.from(data);
10504
- if (this.zip.getEntry(entryName)) {
10505
- this.zip.updateFile(entryName, buffer);
10517
+ const liveEntry = this.zip.getEntry(entryName);
10518
+ if (liveEntry) {
10519
+ let unchanged = false;
10520
+ try {
10521
+ unchanged = !liveEntry.isDirectory && liveEntry.header.size === buffer.length && readPartData(liveEntry, entryName).equals(buffer);
10522
+ } catch {
10523
+ unchanged = false;
10524
+ }
10525
+ if (!unchanged) this.zip.updateFile(entryName, buffer);
10506
10526
  } else {
10507
10527
  this.zip.addFile(entryName, buffer);
10508
10528
  }
@@ -10527,7 +10547,7 @@ var DocxParser = class _DocxParser {
10527
10547
  } else {
10528
10548
  this.stylesDom = null;
10529
10549
  }
10530
- registerExistingCorePropertiesPart(this.getZipAdapter());
10550
+ if (options?.registerCoreProperties) registerExistingCorePropertiesPart(this.getZipAdapter());
10531
10551
  this.invalidateMediaCache();
10532
10552
  this.assignStableIds();
10533
10553
  this._bumpNodePagesRevision();
@@ -11048,20 +11068,20 @@ var DocxParser = class _DocxParser {
11048
11068
  }
11049
11069
  getZipAdapter() {
11050
11070
  return {
11051
- hasFile: (path22) => !!this.zip.getEntry(this.sanitizePath(path22)),
11052
- readText: (path22) => {
11053
- const cleanPath = this.sanitizePath(path22);
11071
+ hasFile: (path25) => !!this.zip.getEntry(this.sanitizePath(path25)),
11072
+ readText: (path25) => {
11073
+ const cleanPath = this.sanitizePath(path25);
11054
11074
  const entry = this.zip.getEntry(cleanPath);
11055
11075
  if (!entry) throw new Error(`File not found: ${cleanPath}`);
11056
11076
  return entry.getData().toString("utf8");
11057
11077
  },
11058
- readTextOrNull: (path22) => {
11059
- const cleanPath = this.sanitizePath(path22);
11078
+ readTextOrNull: (path25) => {
11079
+ const cleanPath = this.sanitizePath(path25);
11060
11080
  const entry = this.zip.getEntry(cleanPath);
11061
11081
  return entry ? entry.getData().toString("utf8") : null;
11062
11082
  },
11063
- writeText: (path22, content) => {
11064
- const cleanPath = this.sanitizePath(path22);
11083
+ writeText: (path25, content) => {
11084
+ const cleanPath = this.sanitizePath(path25);
11065
11085
  assertNoXxe(content, cleanPath);
11066
11086
  assertNoXxeAdjacent(content, cleanPath);
11067
11087
  if (this.zip.getEntry(cleanPath)) {
@@ -11070,8 +11090,8 @@ var DocxParser = class _DocxParser {
11070
11090
  this.zip.addFile(cleanPath, Buffer.from(content, "utf8"));
11071
11091
  }
11072
11092
  },
11073
- writeBuffer: (path22, content) => {
11074
- const cleanPath = this.sanitizePath(path22);
11093
+ writeBuffer: (path25, content) => {
11094
+ const cleanPath = this.sanitizePath(path25);
11075
11095
  assertNoXxe(content.toString("utf8"), cleanPath);
11076
11096
  assertNoXxeAdjacent(content.toString("utf8"), cleanPath);
11077
11097
  if (this.zip.getEntry(cleanPath)) {
@@ -12895,12 +12915,12 @@ ${validation.errors.join("\n")}`, "repair the part XML or re-save the document i
12895
12915
  if (!comments || /<w:comment[\s>]/.test(comments.getData().toString("utf8"))) return;
12896
12916
  const parts = ["comments", "commentsExtended", "commentsIds", "commentsExtensible"].map((n) => `${n}.xml`);
12897
12917
  for (const part of parts) if (this.zip.getEntry(`word/${part}`)) this.zip.deleteFile(`word/${part}`);
12898
- const edit = (path22, tag, attr, drop) => {
12899
- const entry = this.zip.getEntry(path22);
12918
+ const edit = (path25, tag, attr, drop) => {
12919
+ const entry = this.zip.getEntry(path25);
12900
12920
  if (!entry) return;
12901
12921
  const dom = this.parser.parseFromString(entry.getData().toString("utf8"), "text/xml");
12902
12922
  for (const el of Array.from(dom.getElementsByTagName(tag))) if (drop(el.getAttribute(attr) ?? "")) el.parentNode?.removeChild(el);
12903
- this.zip.updateFile(path22, Buffer.from(this.serializer.serializeToString(dom), "utf8"));
12923
+ this.zip.updateFile(path25, Buffer.from(this.serializer.serializeToString(dom), "utf8"));
12904
12924
  };
12905
12925
  edit("word/_rels/document.xml.rels", "Relationship", "Target", (value) => parts.includes(value.replace(/^\/?(word\/)?/, "")));
12906
12926
  edit("[Content_Types].xml", "Override", "PartName", (value) => parts.map((p) => `/word/${p}`).includes(value));
@@ -13638,12 +13658,11 @@ ${validation.errors.join("\n")}`, "repair the part XML or re-save the document i
13638
13658
  }
13639
13659
  };
13640
13660
 
13641
- // ../engine/src/git.ts
13642
- var import_fs4 = __toESM(require("fs"));
13643
- var import_promises2 = __toESM(require("fs/promises"));
13644
- var import_path3 = __toESM(require("path"));
13645
- var import_crypto4 = __toESM(require("crypto"));
13646
- var import_isomorphic_git = __toESM(require("isomorphic-git"));
13661
+ // ../engine/src/original.ts
13662
+ var import_node_fs = __toESM(require("node:fs"));
13663
+ var import_promises2 = __toESM(require("node:fs/promises"));
13664
+ var import_node_path = __toESM(require("node:path"));
13665
+ var import_node_crypto = require("node:crypto");
13647
13666
 
13648
13667
  // ../engine/src/sandbox/paths.ts
13649
13668
  var import_fs3 = __toESM(require("fs"));
@@ -13785,7 +13804,408 @@ function writeFileNoFollowSync(target, data, encoding = "utf8") {
13785
13804
  assertWriteTargetNoSymlink(import_path2.default.resolve(target));
13786
13805
  }
13787
13806
 
13807
+ // ../engine/src/original.ts
13808
+ var SNAPSHOT_NAME = "original.snapshot";
13809
+ var SNAPSHOT_MAGIC = Buffer.from("ASTRA-ORIGINAL\n");
13810
+ var MAX_SNAPSHOT_HEADER = 4096;
13811
+ function originalSha256(bytes) {
13812
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
13813
+ }
13814
+ function coded(message, code = "E_IO") {
13815
+ return Object.assign(new Error(message), { code });
13816
+ }
13817
+ function errno(e) {
13818
+ return typeof e === "object" && e !== null && "code" in e ? String(e.code) : "";
13819
+ }
13820
+ function originalDir(docPath) {
13821
+ return import_node_path.default.join(getRepoDir(docPath), ".git", "astra-original");
13822
+ }
13823
+ async function readSafe(file, maxBytes = resolveHistoryMaxSidecarBytes()) {
13824
+ assertWriteTargetNoSymlink(file);
13825
+ const h = await import_promises2.default.open(file, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
13826
+ try {
13827
+ const stat3 = await h.stat();
13828
+ if (!stat3.isFile() || stat3.size > maxBytes) throw coded("Original recovery refused: expected a regular file within the size limit.");
13829
+ return await h.readFile();
13830
+ } finally {
13831
+ await h.close();
13832
+ }
13833
+ }
13834
+ async function exists(file) {
13835
+ try {
13836
+ await import_promises2.default.lstat(file);
13837
+ return true;
13838
+ } catch (e) {
13839
+ if (errno(e) === "ENOENT") return false;
13840
+ throw e;
13841
+ }
13842
+ }
13843
+ async function syncDirectory(dir) {
13844
+ if (process.platform === "win32") return false;
13845
+ let h;
13846
+ let synced = false;
13847
+ try {
13848
+ h = await import_promises2.default.open(dir, import_node_fs.default.constants.O_RDONLY);
13849
+ await h.sync();
13850
+ synced = true;
13851
+ } catch {
13852
+ }
13853
+ try {
13854
+ await h?.close();
13855
+ } catch {
13856
+ synced = false;
13857
+ }
13858
+ return synced;
13859
+ }
13860
+ async function cleanupTemp(file) {
13861
+ try {
13862
+ await import_promises2.default.unlink(file);
13863
+ } catch (e) {
13864
+ if (errno(e) !== "ENOENT") throw Object.assign(coded(`Temporary file cleanup failed: ${file}. ${e instanceof Error ? e.message : String(e)}`), { tempPath: file });
13865
+ }
13866
+ }
13867
+ async function preparedTempParts(target, parts, mode = 384) {
13868
+ assertWriteTargetNoSymlink(target);
13869
+ const dir = import_node_path.default.dirname(target);
13870
+ await import_promises2.default.mkdir(dir, { recursive: true, mode: 448 });
13871
+ assertWriteTargetNoSymlink(target);
13872
+ const tmp = import_node_path.default.join(dir, `.${import_node_path.default.basename(target).slice(0, 60)}.astra-tmp-${(0, import_node_crypto.randomUUID)()}`);
13873
+ const h = await import_promises2.default.open(tmp, import_node_fs.default.constants.O_WRONLY | import_node_fs.default.constants.O_CREAT | import_node_fs.default.constants.O_EXCL | import_node_fs.default.constants.O_NOFOLLOW, mode);
13874
+ let writeError;
13875
+ try {
13876
+ for (const part of parts) {
13877
+ let offset = 0;
13878
+ while (offset < part.length) {
13879
+ const { bytesWritten } = await h.write(part, offset, part.length - offset, null);
13880
+ if (bytesWritten === 0) throw coded("Original recovery refused: temporary file write made no progress.");
13881
+ offset += bytesWritten;
13882
+ }
13883
+ }
13884
+ await h.sync();
13885
+ } catch (error) {
13886
+ writeError = error;
13887
+ }
13888
+ let closeError;
13889
+ try {
13890
+ await h.close();
13891
+ } catch (error) {
13892
+ closeError = error;
13893
+ }
13894
+ if (writeError || closeError) {
13895
+ const primary = writeError ?? closeError;
13896
+ let message = primary instanceof Error ? primary.message : String(primary);
13897
+ if (writeError && closeError) message += `; temporary file close also failed: ${closeError instanceof Error ? closeError.message : String(closeError)}`;
13898
+ let cleanupFailed = false;
13899
+ try {
13900
+ await cleanupTemp(tmp);
13901
+ } catch (cleanup) {
13902
+ cleanupFailed = true;
13903
+ message += `; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
13904
+ }
13905
+ throw Object.assign(coded(message, errno(primary) || "E_IO"), { ...cleanupFailed ? { tempPath: tmp } : {}, cause: primary });
13906
+ }
13907
+ try {
13908
+ const expectedBytes = parts.reduce((total, part) => total + part.length, 0);
13909
+ const expectedHash = (0, import_node_crypto.createHash)("sha256");
13910
+ for (const part of parts) expectedHash.update(part);
13911
+ const check = await import_promises2.default.open(tmp, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
13912
+ try {
13913
+ const stat3 = await check.stat();
13914
+ if (!stat3.isFile() || stat3.size !== expectedBytes) throw coded("Original recovery refused: temporary file integrity check failed.");
13915
+ const actualHash = (0, import_node_crypto.createHash)("sha256");
13916
+ const chunk = Buffer.allocUnsafe(64 * 1024);
13917
+ let position2 = 0;
13918
+ while (position2 < expectedBytes) {
13919
+ const { bytesRead } = await check.read(chunk, 0, Math.min(chunk.length, expectedBytes - position2), position2);
13920
+ if (bytesRead === 0) throw coded("Original recovery refused: temporary file integrity check failed.");
13921
+ actualHash.update(chunk.subarray(0, bytesRead));
13922
+ position2 += bytesRead;
13923
+ }
13924
+ if (actualHash.digest("hex") !== expectedHash.digest("hex")) throw coded("Original recovery refused: temporary file integrity check failed.");
13925
+ } finally {
13926
+ await check.close();
13927
+ }
13928
+ } catch (e) {
13929
+ try {
13930
+ await cleanupTemp(tmp);
13931
+ } catch (cleanup) {
13932
+ throw coded(`${e instanceof Error ? e.message : String(e)}; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`);
13933
+ }
13934
+ throw e;
13935
+ }
13936
+ return tmp;
13937
+ }
13938
+ async function preparedTemp(target, data, mode = 384) {
13939
+ return preparedTempParts(target, [data], mode);
13940
+ }
13941
+ async function writePartsExclusive(target, parts, mode = 384) {
13942
+ const tmp = await preparedTempParts(target, parts, mode);
13943
+ let failure;
13944
+ try {
13945
+ assertWriteTargetNoSymlink(target);
13946
+ try {
13947
+ await import_promises2.default.link(tmp, target);
13948
+ } catch (e) {
13949
+ if (errno(e) === "EEXIST") throw coded(`Destination exists: ${target}`, "E_CONFLICT");
13950
+ if (["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "ENOSYS"].includes(errno(e))) {
13951
+ throw coded("This location does not support safe exclusive publication; export to another directory.");
13952
+ }
13953
+ throw e;
13954
+ }
13955
+ await syncDirectory(import_node_path.default.dirname(target));
13956
+ } catch (error) {
13957
+ failure = error;
13958
+ }
13959
+ try {
13960
+ await cleanupTemp(tmp);
13961
+ } catch (cleanup) {
13962
+ const detail = cleanup instanceof Error ? cleanup.message : String(cleanup);
13963
+ if (failure) {
13964
+ throw Object.assign(coded(`${failure instanceof Error ? failure.message : String(failure)}; ${detail}`, errno(failure) || "E_IO"), { tempPath: tmp });
13965
+ }
13966
+ throw Object.assign(coded(`Destination was published at ${target}, but ${detail}`), { tempPath: tmp, published: true });
13967
+ }
13968
+ if (failure) throw failure;
13969
+ }
13970
+ async function writeFileExclusive(target, data, mode = 384) {
13971
+ return writePartsExclusive(target, [data], mode);
13972
+ }
13973
+ async function createVerifiedBackup(target, bytes) {
13974
+ let ownsTarget = false;
13975
+ try {
13976
+ await writeFileExclusive(target, bytes);
13977
+ ownsTarget = true;
13978
+ assertWriteTargetNoSymlink(target);
13979
+ const written = await import_promises2.default.readFile(target);
13980
+ if (written.length !== bytes.length || originalSha256(written) !== originalSha256(bytes)) {
13981
+ throw coded(`Restore backup verification failed: ${target}`, "E_IO");
13982
+ }
13983
+ } catch (error) {
13984
+ if (error?.published === true) ownsTarget = true;
13985
+ if (ownsTarget) {
13986
+ try {
13987
+ await import_promises2.default.unlink(target);
13988
+ } catch (cleanupError) {
13989
+ if (errno(cleanupError) !== "ENOENT") {
13990
+ throw Object.assign(coded(`${error instanceof Error ? error.message : String(error)}; invalid backup could not be removed: ${target} (${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)})`, "E_IO"), { backupPath: target });
13991
+ }
13992
+ }
13993
+ }
13994
+ throw error;
13995
+ }
13996
+ }
13997
+ function newRestoreBackupPath(target) {
13998
+ return import_node_path.default.join(import_node_path.default.dirname(target), `.astra-restore-backup-${(0, import_node_crypto.randomUUID)()}`);
13999
+ }
14000
+ async function replaceFileAtomic(target, data, newFileMode = 384) {
14001
+ let tmp;
14002
+ let replaced = false;
14003
+ let renameStarted = false;
14004
+ try {
14005
+ assertWriteTargetNoSymlink(target);
14006
+ let mode = newFileMode;
14007
+ try {
14008
+ const st = await import_promises2.default.lstat(target);
14009
+ if (!st.isFile()) throw coded("Atomic replace requires a regular target file.");
14010
+ mode = st.mode & 511;
14011
+ if ((mode & 146) === 0) throw coded("Atomic replace refused: target is read-only.");
14012
+ await import_promises2.default.access(target, import_node_fs.default.constants.W_OK);
14013
+ } catch (e) {
14014
+ if (errno(e) !== "ENOENT") throw e;
14015
+ }
14016
+ tmp = await preparedTemp(target, data, mode);
14017
+ assertWriteTargetNoSymlink(target);
14018
+ renameStarted = true;
14019
+ for (let attempt = 0; ; attempt++) {
14020
+ try {
14021
+ await import_promises2.default.rename(tmp, target);
14022
+ replaced = true;
14023
+ break;
14024
+ } catch (e) {
14025
+ if (process.platform !== "win32" || !["EBUSY", "EPERM", "EACCES"].includes(errno(e)) || attempt >= 3) throw e;
14026
+ await new Promise((resolve10) => setTimeout(resolve10, 25 * (attempt + 1)));
14027
+ }
14028
+ }
14029
+ const durable = await syncDirectory(import_node_path.default.dirname(target));
14030
+ await cleanupTemp(tmp);
14031
+ tmp = void 0;
14032
+ return { outcome: "replaced", ...!durable ? { durabilityUnconfirmed: true } : {} };
14033
+ } catch (e) {
14034
+ let message = e instanceof Error ? e.message : String(e);
14035
+ let outcome = replaced ? "replaced" : renameStarted ? "uncertain" : "not-replaced";
14036
+ if (!replaced && renameStarted && tmp) {
14037
+ try {
14038
+ const tempStat = await import_promises2.default.lstat(tmp);
14039
+ if (tempStat.isFile() && !tempStat.isSymbolicLink()) outcome = "not-replaced";
14040
+ } catch (tempError) {
14041
+ if (errno(tempError) === "ENOENT") {
14042
+ try {
14043
+ assertWriteTargetNoSymlink(target);
14044
+ const targetStat = await import_promises2.default.lstat(target);
14045
+ if (targetStat.isFile() && targetStat.size === data.length && (await import_promises2.default.readFile(target)).equals(data)) {
14046
+ replaced = true;
14047
+ outcome = "replaced";
14048
+ }
14049
+ } catch {
14050
+ }
14051
+ }
14052
+ }
14053
+ }
14054
+ if (tmp) {
14055
+ try {
14056
+ await cleanupTemp(tmp);
14057
+ } catch (cleanup) {
14058
+ message += `; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
14059
+ }
14060
+ }
14061
+ throw Object.assign(coded(message, errno(e) || "E_IO"), {
14062
+ outcome
14063
+ });
14064
+ }
14065
+ }
14066
+ function encodeSnapshot(bytes, sourceName) {
14067
+ const meta = { magic: "ASTRA-ORIGINAL", version: 1, sha256: originalSha256(bytes), bytes: bytes.length, capturedAt: (/* @__PURE__ */ new Date()).toISOString(), sourceName };
14068
+ const json = Buffer.from(JSON.stringify(meta));
14069
+ if (json.length > MAX_SNAPSHOT_HEADER) throw coded("Original snapshot metadata exceeds its format limit.", "E_LIMIT");
14070
+ const prefix = Buffer.alloc(SNAPSHOT_MAGIC.length + 4);
14071
+ SNAPSHOT_MAGIC.copy(prefix);
14072
+ prefix.writeUInt32BE(json.length, SNAPSHOT_MAGIC.length);
14073
+ return [prefix, json, bytes];
14074
+ }
14075
+ function decodeSnapshot(all) {
14076
+ const offset = SNAPSHOT_MAGIC.length;
14077
+ if (all.length < offset + 4 || !all.subarray(0, offset).equals(SNAPSHOT_MAGIC)) throw coded("Original snapshot has an invalid format header.", "E_VALIDATION");
14078
+ const headerBytes = all.readUInt32BE(offset);
14079
+ if (headerBytes < 2 || headerBytes > MAX_SNAPSHOT_HEADER || all.length < offset + 4 + headerBytes) throw coded("Original snapshot has an invalid metadata length.", "E_VALIDATION");
14080
+ let meta;
14081
+ try {
14082
+ meta = JSON.parse(all.subarray(offset + 4, offset + 4 + headerBytes).toString("utf8"));
14083
+ } catch {
14084
+ throw coded("Original snapshot metadata is not valid JSON.", "E_VALIDATION");
14085
+ }
14086
+ const bytes = all.subarray(offset + 4 + headerBytes);
14087
+ if (meta.magic !== "ASTRA-ORIGINAL" || meta.version !== 1 || !/^[a-f0-9]{64}$/.test(meta.sha256) || !Number.isSafeInteger(meta.bytes) || meta.bytes < 1 || meta.bytes !== bytes.length || typeof meta.capturedAt !== "string" || typeof meta.sourceName !== "string") {
14088
+ throw coded("Original snapshot metadata is invalid.", "E_VALIDATION");
14089
+ }
14090
+ if (originalSha256(bytes) !== meta.sha256) throw coded("Original snapshot integrity verification failed.", "E_VALIDATION");
14091
+ return { meta, bytes };
14092
+ }
14093
+ async function verifiedSnapshot(docPath) {
14094
+ const file = import_node_path.default.join(originalDir(docPath), SNAPSHOT_NAME);
14095
+ const raw = await readSafe(file);
14096
+ return decodeSnapshot(raw);
14097
+ }
14098
+ async function getOriginalInfo(docPath) {
14099
+ if (!import_node_path.default.isAbsolute(docPath)) return { available: false, reason: "not-applicable" };
14100
+ const dir = originalDir(docPath);
14101
+ try {
14102
+ if (!await exists(dir)) return { available: false, reason: "predates-feature" };
14103
+ const snapshot3 = import_node_path.default.join(dir, SNAPSHOT_NAME);
14104
+ assertWriteTargetNoSymlink(snapshot3);
14105
+ if (await exists(snapshot3)) {
14106
+ const { meta } = await verifiedSnapshot(docPath);
14107
+ return { available: true, sha256: meta.sha256, bytes: meta.bytes, capturedAt: meta.capturedAt };
14108
+ }
14109
+ try {
14110
+ const status = JSON.parse((await readSafe(import_node_path.default.join(dir, "status.json"), 16 * 1024)).toString("utf8"));
14111
+ if (["capture-failed", "too-large", "not-applicable"].includes(status.reason)) return { available: false, reason: status.reason, ...typeof status.detail === "string" ? { detail: status.detail.slice(0, 1024) } : {} };
14112
+ } catch {
14113
+ }
14114
+ return { available: false, reason: "incomplete" };
14115
+ } catch (e) {
14116
+ return { available: false, reason: "corrupt", detail: e instanceof Error ? e.message : String(e) };
14117
+ }
14118
+ }
14119
+ async function captureOriginal(docPath, bytes) {
14120
+ const dir = originalDir(docPath);
14121
+ let result;
14122
+ try {
14123
+ assertWriteTargetNoSymlink(import_node_path.default.join(dir, SNAPSHOT_NAME));
14124
+ await import_promises2.default.mkdir(dir, { recursive: true, mode: 448 });
14125
+ assertWriteTargetNoSymlink(import_node_path.default.join(dir, SNAPSHOT_NAME));
14126
+ if (await exists(import_node_path.default.join(dir, SNAPSHOT_NAME))) {
14127
+ const info = await getOriginalInfo(docPath);
14128
+ if (info.available && bytes && originalSha256(bytes) !== info.sha256) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14129
+ return info;
14130
+ }
14131
+ if (!bytes) result = { available: false, reason: "not-applicable" };
14132
+ else if (!Buffer.isBuffer(bytes) || bytes.length === 0) result = { available: false, reason: "capture-failed", detail: "Original input is empty." };
14133
+ else if (await getSidecarBytes(getRepoDir(docPath)) + bytes.length + 4096 + SNAPSHOT_MAGIC.length > resolveHistoryMaxSidecarBytes()) result = { available: false, reason: "too-large" };
14134
+ else {
14135
+ const digest = originalSha256(bytes);
14136
+ const snapshot3 = import_node_path.default.join(dir, SNAPSHOT_NAME);
14137
+ try {
14138
+ await writePartsExclusive(snapshot3, encodeSnapshot(bytes, import_node_path.default.basename(docPath)));
14139
+ } catch (e) {
14140
+ if (e?.published === true) {
14141
+ const published = await getOriginalInfo(docPath);
14142
+ if (published.available && published.sha256 === digest) return published;
14143
+ throw e;
14144
+ }
14145
+ if (errno(e) !== "E_CONFLICT") throw e;
14146
+ const winner = await verifiedSnapshot(docPath);
14147
+ if (!winner.bytes.equals(bytes)) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14148
+ }
14149
+ const info = await getOriginalInfo(docPath);
14150
+ if (info.available && info.sha256 !== digest) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14151
+ return info;
14152
+ }
14153
+ } catch (e) {
14154
+ result = { available: false, reason: "capture-failed", detail: e instanceof Error ? e.message : String(e) };
14155
+ }
14156
+ try {
14157
+ await writeFileExclusive(import_node_path.default.join(dir, "status.json"), Buffer.from(JSON.stringify(result)));
14158
+ } catch {
14159
+ }
14160
+ return result;
14161
+ }
14162
+ async function readOriginal(docPath) {
14163
+ const info = await getOriginalInfo(docPath);
14164
+ if (!info.available) {
14165
+ throw Object.assign(coded(`Original unavailable: ${info.reason}${info.detail ? ` (${info.detail})` : ""}`, "E_STATE"), {
14166
+ remedy: "The retained original cannot be trusted, so no substitute is returned. Keep the working file and any separate backup, and check the history status for the reason."
14167
+ });
14168
+ }
14169
+ return (await verifiedSnapshot(docPath)).bytes;
14170
+ }
14171
+ async function exportOriginal(docPath, outPath, opts) {
14172
+ const bytes = await readOriginal(docPath);
14173
+ const source = await import_promises2.default.realpath(docPath).catch((e) => {
14174
+ if (errno(e) === "ENOENT") return import_node_path.default.resolve(docPath);
14175
+ throw e;
14176
+ });
14177
+ const parent = import_node_path.default.dirname(import_node_path.default.resolve(outPath));
14178
+ const destination = import_node_path.default.join(await import_promises2.default.realpath(parent).catch((e) => {
14179
+ if (errno(e) === "ENOENT") return parent;
14180
+ throw e;
14181
+ }), import_node_path.default.basename(outPath));
14182
+ const samePath = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
14183
+ let sameFile = samePath(source, destination);
14184
+ try {
14185
+ const [a, b] = await Promise.all([import_promises2.default.stat(source), import_promises2.default.stat(destination)]);
14186
+ sameFile ||= a.ino !== 0 && b.ino !== 0 && a.dev === b.dev && a.ino === b.ino;
14187
+ } catch (e) {
14188
+ if (errno(e) !== "ENOENT") throw e;
14189
+ }
14190
+ if (sameFile) throw coded("Use restore_original_document to replace the working file.", "E_CONFLICT");
14191
+ const repo = await import_promises2.default.realpath(getRepoDir(docPath));
14192
+ const normalizedDest = process.platform === "win32" ? destination.toLowerCase() : destination;
14193
+ const normalizedRepo = process.platform === "win32" ? repo.toLowerCase() : repo;
14194
+ if (normalizedDest === normalizedRepo || normalizedDest.startsWith(`${normalizedRepo}${import_node_path.default.sep}`)) {
14195
+ throw coded("Original export cannot write inside the document history store.", "E_CONFLICT");
14196
+ }
14197
+ if (opts?.overwrite) await replaceFileAtomic(outPath, bytes, 438);
14198
+ else await writeFileExclusive(outPath, bytes, 438);
14199
+ return getOriginalInfo(docPath);
14200
+ }
14201
+
13788
14202
  // ../engine/src/git.ts
14203
+ var import_fs4 = __toESM(require("fs"));
14204
+ var import_os = __toESM(require("os"));
14205
+ var import_promises3 = __toESM(require("fs/promises"));
14206
+ var import_path3 = __toESM(require("path"));
14207
+ var import_crypto4 = __toESM(require("crypto"));
14208
+ var import_isomorphic_git = __toESM(require("isomorphic-git"));
13789
14209
  var HISTORY_DIR_ENV = "ASTRA_HISTORY_DIR";
13790
14210
  function historyRoot() {
13791
14211
  const raw = process.env[HISTORY_DIR_ENV];
@@ -13818,7 +14238,55 @@ function getRepoDir(docPath) {
13818
14238
  if (root !== null) return import_path3.default.join(root, historyKey(import_path3.default.dirname(docPath)), import_path3.default.basename(docPath));
13819
14239
  return import_path3.default.join(import_path3.default.dirname(docPath), ".astra", import_path3.default.basename(docPath));
13820
14240
  }
14241
+ var SNAPSHOT_FORMAT_MARKER = ".astra-snapshot-format";
14242
+ var SNAPSHOT_FORMAT_VERSION = "2";
14243
+ function isCompleteSnapshotMarker(content) {
14244
+ if (content === void 0) return false;
14245
+ return Buffer.from(content).toString("utf8").trim() === SNAPSHOT_FORMAT_VERSION;
14246
+ }
14247
+ var ESCAPED_DIR = "__astra_escaped__";
14248
+ var ESCAPED_NAME_MAX = 255;
14249
+ function needsHistoryEscape(entryName) {
14250
+ const name2 = entryName.replace(/\\/g, "/");
14251
+ if (name2 === SNAPSHOT_FORMAT_MARKER || name2 === "document.xml") return true;
14252
+ if (name2 === ESCAPED_DIR || name2.startsWith(`${ESCAPED_DIR}/`)) return true;
14253
+ return name2.split("/").some((segment) => segment.toLowerCase().startsWith(".git"));
14254
+ }
14255
+ function isUnrecordableName(entryName) {
14256
+ const name2 = entryName.replace(/\\/g, "/");
14257
+ if (name2 === "" || name2.endsWith("/") || name2.startsWith("/") || /^[a-zA-Z]:/.test(name2)) return true;
14258
+ return name2.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
14259
+ }
13821
14260
  function isTrackedPackageFile(entryName) {
14261
+ const name2 = entryName.replace(/\\/g, "/");
14262
+ if (name2 === "word/document.xml") return false;
14263
+ return !isUnrecordableName(name2);
14264
+ }
14265
+ function historyPathFor(entryName) {
14266
+ const name2 = entryName.replace(/\\/g, "/");
14267
+ if (!needsHistoryEscape(name2)) return name2;
14268
+ const encoded = Buffer.from(name2, "utf8").toString("base64url");
14269
+ if (encoded.length > ESCAPED_NAME_MAX) {
14270
+ throw Object.assign(
14271
+ new Error(`E_VALIDATION: the package entry name is too long to record in history (${name2.length} characters).`),
14272
+ {
14273
+ code: "E_VALIDATION",
14274
+ remedy: "Rename or remove that package part (for example with sanitize_document), then save again."
14275
+ }
14276
+ );
14277
+ }
14278
+ return `${ESCAPED_DIR}/${encoded}`;
14279
+ }
14280
+ function entryNameFor(repoPath) {
14281
+ const rel = repoPath.replace(/\\/g, "/");
14282
+ if (!rel.startsWith(`${ESCAPED_DIR}/`)) return rel;
14283
+ return Buffer.from(rel.slice(ESCAPED_DIR.length + 1), "base64url").toString("utf8");
14284
+ }
14285
+ function isRepoPartPath(repoPath) {
14286
+ const rel = repoPath.replace(/\\/g, "/");
14287
+ return rel !== "document.xml" && rel !== SNAPSHOT_FORMAT_MARKER && rel !== "" && !rel.startsWith(".git/");
14288
+ }
14289
+ function isLegacyTrackedPackageFile(entryName) {
13822
14290
  const name2 = entryName.replace(/\\/g, "/");
13823
14291
  if (name2 === "word/document.xml") return false;
13824
14292
  if (name2 === "word/styles.xml") return true;
@@ -13842,22 +14310,109 @@ function toRepoRelative(entryName) {
13842
14310
  }
13843
14311
  async function writeAndAdd(repoDir, relPath, content) {
13844
14312
  const safeRel = toRepoRelative(relPath);
13845
- if (safeRel.includes("..") || import_path3.default.isAbsolute(safeRel) || /^[a-zA-Z]:/.test(safeRel)) {
14313
+ if (isTraversalName(safeRel)) {
13846
14314
  throw new Error(`Security Violation: refusing to track package path '${relPath}'`);
13847
14315
  }
13848
14316
  const fullPath = import_path3.default.join(repoDir, safeRel);
13849
14317
  const dirName = import_path3.default.dirname(fullPath);
13850
14318
  assertWriteTargetNoSymlink(fullPath);
13851
- await import_promises2.default.mkdir(dirName, { recursive: true, mode: 448 });
14319
+ await import_promises3.default.mkdir(dirName, { recursive: true, mode: 448 });
13852
14320
  await secureDirChain0700(repoDir, dirName);
13853
14321
  assertWriteTargetNoSymlink(fullPath);
13854
14322
  await writeFileNoFollow(fullPath, content);
13855
14323
  await import_isomorphic_git.default.add({ fs: import_fs4.default, dir: repoDir, filepath: safeRel });
13856
14324
  }
14325
+ function isTraversalName(entryName) {
14326
+ const name2 = toRepoRelative(entryName);
14327
+ return name2.split("/").includes("..") || import_path3.default.isAbsolute(name2) || /^[a-zA-Z]:/.test(name2);
14328
+ }
14329
+ async function writeSnapshotParts(repoDir, extraFiles) {
14330
+ const written = /* @__PURE__ */ new Set();
14331
+ if (!extraFiles) return written;
14332
+ for (const [name2, content] of Object.entries(extraFiles)) {
14333
+ if (isTraversalName(name2)) {
14334
+ await writeAndAdd(repoDir, name2, content);
14335
+ continue;
14336
+ }
14337
+ if (!isTrackedPackageFile(name2)) continue;
14338
+ const repoPath = historyPathFor(name2);
14339
+ await writeAndAdd(repoDir, repoPath, content);
14340
+ written.add(repoPath);
14341
+ }
14342
+ return written;
14343
+ }
14344
+ async function indexedPartPaths(repoDir) {
14345
+ const tracked = await import_isomorphic_git.default.listFiles({ fs: import_fs4.default, dir: repoDir });
14346
+ return new Set(tracked.map((t) => toRepoRelative(t)).filter(isRepoPartPath));
14347
+ }
14348
+ async function clearUncommittedPackageParts(repoDir) {
14349
+ const repoRoot = import_path3.default.resolve(repoDir);
14350
+ for (const rel of await indexedPartPaths(repoDir)) {
14351
+ if (isTraversalName(rel)) {
14352
+ throw Object.assign(new Error(`History repair refused an unsafe indexed path: ${rel}`), { code: "E_SANDBOX" });
14353
+ }
14354
+ const fullPath = import_path3.default.resolve(repoRoot, rel);
14355
+ const relative3 = import_path3.default.relative(repoRoot, fullPath);
14356
+ if (relative3 === "" || relative3 === ".." || relative3.startsWith(`..${import_path3.default.sep}`) || import_path3.default.isAbsolute(relative3)) {
14357
+ throw Object.assign(new Error(`History repair refused an indexed path outside its repository: ${rel}`), { code: "E_SANDBOX" });
14358
+ }
14359
+ assertWriteTargetNoSymlink(fullPath);
14360
+ try {
14361
+ await import_promises3.default.unlink(fullPath);
14362
+ } catch (error) {
14363
+ if (error?.code !== "ENOENT") throw error;
14364
+ }
14365
+ await import_isomorphic_git.default.remove({ fs: import_fs4.default, dir: repoDir, filepath: rel });
14366
+ }
14367
+ }
14368
+ async function isUnbornHistory(repoDir) {
14369
+ let head;
14370
+ try {
14371
+ head = await import_promises3.default.readFile(import_path3.default.join(repoDir, ".git", "HEAD"), "utf8");
14372
+ } catch {
14373
+ return false;
14374
+ }
14375
+ if (!/^ref:\s*refs\/heads\/[^\s]+\s*$/.test(head)) return false;
14376
+ const [branches, tags] = await Promise.all([
14377
+ import_isomorphic_git.default.listBranches({ fs: import_fs4.default, dir: repoDir }),
14378
+ import_isomorphic_git.default.listTags({ fs: import_fs4.default, dir: repoDir })
14379
+ ]);
14380
+ return branches.length === 0 && tags.length === 0;
14381
+ }
14382
+ async function snapshotIsProvenComplete(repoDir, extraFiles, written) {
14383
+ if (!extraFiles) return false;
14384
+ try {
14385
+ const indexed = await indexedPartPaths(repoDir);
14386
+ if (indexed.size !== written.size) return false;
14387
+ for (const p of written) if (!indexed.has(p)) return false;
14388
+ return true;
14389
+ } catch {
14390
+ return false;
14391
+ }
14392
+ }
14393
+ async function applySnapshotMarker(repoDir, complete) {
14394
+ if (complete) {
14395
+ await writeAndAdd(repoDir, SNAPSHOT_FORMAT_MARKER, `${SNAPSHOT_FORMAT_VERSION}
14396
+ `);
14397
+ return;
14398
+ }
14399
+ try {
14400
+ await import_promises3.default.unlink(import_path3.default.join(repoDir, SNAPSHOT_FORMAT_MARKER));
14401
+ } catch {
14402
+ }
14403
+ try {
14404
+ await import_isomorphic_git.default.remove({ fs: import_fs4.default, dir: repoDir, filepath: SNAPSHOT_FORMAT_MARKER });
14405
+ } catch {
14406
+ }
14407
+ const stillIndexed = (await import_isomorphic_git.default.listFiles({ fs: import_fs4.default, dir: repoDir })).some((t) => toRepoRelative(t) === SNAPSHOT_FORMAT_MARKER);
14408
+ if (stillIndexed) {
14409
+ throw new Error("History commit refused: could not clear the snapshot marker of an earlier commit.");
14410
+ }
14411
+ }
13857
14412
  async function chmod0700BestEffort(dir) {
13858
14413
  if (process.platform === "win32") return;
13859
14414
  try {
13860
- await import_promises2.default.chmod(dir, 448);
14415
+ await import_promises3.default.chmod(dir, 448);
13861
14416
  } catch {
13862
14417
  }
13863
14418
  }
@@ -13890,7 +14445,7 @@ async function migrateExistingTree0700(repoDir) {
13890
14445
  await chmod0700BestEffort(dir);
13891
14446
  let entries = [];
13892
14447
  try {
13893
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
14448
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
13894
14449
  } catch {
13895
14450
  continue;
13896
14451
  }
@@ -13940,7 +14495,7 @@ async function getSidecarBytes(repoDir) {
13940
14495
  const dir = stack.pop();
13941
14496
  let entries = [];
13942
14497
  try {
13943
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
14498
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
13944
14499
  } catch {
13945
14500
  continue;
13946
14501
  }
@@ -13953,7 +14508,7 @@ async function getSidecarBytes(repoDir) {
13953
14508
  continue;
13954
14509
  }
13955
14510
  if (typeof entry?.isFile === "function" && !entry.isFile()) continue;
13956
- const st = await import_promises2.default.stat(full);
14511
+ const st = await import_promises3.default.stat(full);
13957
14512
  if (st.isDirectory()) stack.push(full);
13958
14513
  else if (st.isFile()) total += st.size;
13959
14514
  } catch {
@@ -13971,6 +14526,116 @@ async function getHistoryCommitCount(repoDir) {
13971
14526
  return 0;
13972
14527
  }
13973
14528
  }
14529
+ var GIT_INITIALIZATION_LOCK = ".astra-init.lock";
14530
+ function gitInitializationLockPath(repoDir) {
14531
+ return import_path3.default.join(repoDir, GIT_INITIALIZATION_LOCK);
14532
+ }
14533
+ function gitInitializationIncompleteError(repoDir) {
14534
+ return Object.assign(new Error(`History initialization is incomplete at ${repoDir}.`), {
14535
+ code: "E_STATE",
14536
+ remedy: "Stop other ASTra processes using this document, inspect the history directory, then run `astra history <file> --init` to record a baseline."
14537
+ });
14538
+ }
14539
+ async function gitInitializationLockExists(repoDir) {
14540
+ const file = gitInitializationLockPath(repoDir);
14541
+ try {
14542
+ const st = await import_promises3.default.lstat(file);
14543
+ if (!st.isFile() || st.isSymbolicLink()) throw Object.assign(new Error(`Unsafe history initialization lock: ${file}`), { code: "E_SANDBOX" });
14544
+ return true;
14545
+ } catch (error) {
14546
+ if (error?.code === "ENOENT") return false;
14547
+ throw error;
14548
+ }
14549
+ }
14550
+ async function describeInitializationLock(file) {
14551
+ let record = {};
14552
+ try {
14553
+ const st = await import_promises3.default.lstat(file);
14554
+ if (st.isFile() && !st.isSymbolicLink() && st.size <= 4096) record = JSON.parse(await import_promises3.default.readFile(file, "utf8"));
14555
+ } catch {
14556
+ }
14557
+ const pid = typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0 ? record.pid : void 0;
14558
+ const hostname = typeof record.hostname === "string" ? record.hostname.slice(0, 255) : void 0;
14559
+ const started = typeof record.startedAt === "string" ? ` since ${record.startedAt.slice(0, 40)}` : "";
14560
+ if (pid === void 0) return `the lock does not record its owner${started}; cannot tell whether an initializer is still running`;
14561
+ if (hostname === void 0) return `owner pid ${pid}, host not recorded${started}; cannot tell whether it is still running`;
14562
+ if (hostname !== import_os.default.hostname()) return `owner pid ${pid} on host ${hostname}${started}; this host is ${import_os.default.hostname()}, cannot tell whether it is still running`;
14563
+ try {
14564
+ process.kill(pid, 0);
14565
+ return `owner pid ${pid} on this host${started}; that process is running`;
14566
+ } catch (error) {
14567
+ if (error?.code === "EPERM") return `owner pid ${pid} on this host${started}; that process is running`;
14568
+ if (error?.code === "ESRCH") return `owner pid ${pid} on this host${started}; that process was not found (this alone does not prove the lock is stale)`;
14569
+ return `owner pid ${pid} on this host${started}; cannot tell whether it is still running`;
14570
+ }
14571
+ }
14572
+ async function gitInitializationConflictError(file) {
14573
+ return Object.assign(new Error(`Another or interrupted ASTra history initialization is marked at ${file} (${await describeInitializationLock(file)}).`), {
14574
+ code: "E_CONFLICT",
14575
+ remedy: `Do not delete the lock while any ASTra process (an agent, a pipeline or a CLI run) may be using this document. Once you have made sure none is, delete ${file}; if the history then has no commits, run \`astra history <file> --init\`. ASTra never removes the lock itself.`
14576
+ });
14577
+ }
14578
+ async function countCommitsStrict(repoDir) {
14579
+ await assertUsableSidecarGit(repoDir);
14580
+ try {
14581
+ return (await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir })).length;
14582
+ } catch (error) {
14583
+ if (await isUnbornHistory(repoDir)) return 0;
14584
+ throw error;
14585
+ }
14586
+ }
14587
+ async function assertGitHistoryReady(repoDir) {
14588
+ await assertNoGitInitializationInProgress(repoDir);
14589
+ if (await countCommitsStrict(repoDir) === 0) throw gitInitializationIncompleteError(repoDir);
14590
+ }
14591
+ async function assertNoGitInitializationInProgress(repoDir) {
14592
+ if (await gitInitializationLockExists(repoDir)) throw await gitInitializationConflictError(gitInitializationLockPath(repoDir));
14593
+ }
14594
+ async function assertHistoryReadyForWrite(docPath) {
14595
+ const repoDir = getRepoDir(docPath);
14596
+ await assertNoGitInitializationInProgress(repoDir);
14597
+ if (import_fs4.default.existsSync(import_path3.default.join(repoDir, ".git"))) await assertGitHistoryReady(repoDir);
14598
+ }
14599
+ async function withGitInitializationLock(repoDir, run) {
14600
+ const file = gitInitializationLockPath(repoDir);
14601
+ assertWriteTargetNoSymlink(file);
14602
+ const token = import_crypto4.default.randomUUID();
14603
+ let handle;
14604
+ try {
14605
+ handle = await import_promises3.default.open(file, import_fs4.default.constants.O_WRONLY | import_fs4.default.constants.O_CREAT | import_fs4.default.constants.O_EXCL | import_fs4.default.constants.O_NOFOLLOW, 384);
14606
+ } catch (error) {
14607
+ if (error?.code === "EEXIST") throw await gitInitializationConflictError(file);
14608
+ throw error;
14609
+ }
14610
+ let primaryError;
14611
+ let result;
14612
+ try {
14613
+ await handle.writeFile(JSON.stringify({ version: 2, pid: process.pid, hostname: import_os.default.hostname(), token, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
14614
+ await handle.sync();
14615
+ result = await run();
14616
+ } catch (error) {
14617
+ primaryError = error;
14618
+ }
14619
+ await handle.close().catch(() => void 0);
14620
+ let cleanupError;
14621
+ try {
14622
+ const current = JSON.parse(await import_promises3.default.readFile(file, "utf8"));
14623
+ if (current.token !== token) throw Object.assign(new Error(`History initialization lock ownership changed; left in place: ${file}`), { code: "E_CONFLICT" });
14624
+ await import_promises3.default.unlink(file);
14625
+ } catch (error) {
14626
+ cleanupError = error;
14627
+ }
14628
+ if (cleanupError && cleanupError?.code !== "ENOENT") {
14629
+ if (primaryError) {
14630
+ const cause = primaryError instanceof Error ? primaryError.message : String(primaryError);
14631
+ throw Object.assign(new Error(`${cause}; initialization lock cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`), { code: "E_IO", lockPath: file });
14632
+ }
14633
+ throw Object.assign(new Error(`History initialization lock cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`), { code: "E_IO", lockPath: file });
14634
+ }
14635
+ if (cleanupError && !primaryError) throw Object.assign(new Error(`History initialization finished, but its lock file disappeared while it ran: ${file}. Another process or a cleaner may have removed it, so exclusivity is not guaranteed for this run. Inspect the history with \`astra history <file>\` before relying on it.`), { code: "E_CONFLICT" });
14636
+ if (primaryError) throw primaryError;
14637
+ return result;
14638
+ }
13974
14639
  var SIDECAR_TOMBSTONE_FILENAME = ".astra-tombstones.json";
13975
14640
  var sidecarRegenWarnings = /* @__PURE__ */ new Map();
13976
14641
  function resolveDocIdentity(target) {
@@ -14090,7 +14755,7 @@ async function checkSidecarTombstone(target, repoDir) {
14090
14755
  const key = import_path3.default.resolve(repoDir);
14091
14756
  let hasGit = false;
14092
14757
  try {
14093
- hasGit = (await import_promises2.default.stat(import_path3.default.join(repoDir, ".git"))).isDirectory();
14758
+ hasGit = (await import_promises3.default.stat(import_path3.default.join(repoDir, ".git"))).isDirectory();
14094
14759
  } catch {
14095
14760
  hasGit = false;
14096
14761
  }
@@ -14184,14 +14849,14 @@ async function assertUsableSidecarGit(repoDir) {
14184
14849
  const gitPath = import_path3.default.join(repoDir, ".git");
14185
14850
  let entry;
14186
14851
  try {
14187
- entry = await import_promises2.default.lstat(gitPath);
14852
+ entry = await import_promises3.default.lstat(gitPath);
14188
14853
  } catch {
14189
14854
  return;
14190
14855
  }
14191
14856
  let usable = false;
14192
14857
  if (entry.isSymbolicLink()) {
14193
14858
  try {
14194
- usable = (await import_promises2.default.stat(gitPath)).isDirectory();
14859
+ usable = (await import_promises3.default.stat(gitPath)).isDirectory();
14195
14860
  } catch {
14196
14861
  usable = false;
14197
14862
  }
@@ -14209,7 +14874,7 @@ async function assertUsableSidecarGit(repoDir) {
14209
14874
  async function ensureAstraGitRepo(target) {
14210
14875
  const repoDir = isRepoDirTarget(target) ? target : getRepoDir(target);
14211
14876
  await assertUsableSidecarGit(repoDir);
14212
- await import_promises2.default.mkdir(repoDir, { recursive: true, mode: 448 });
14877
+ await import_promises3.default.mkdir(repoDir, { recursive: true, mode: 448 });
14213
14878
  if (process.platform !== "win32") {
14214
14879
  await chmod0700BestEffort(repoDir);
14215
14880
  const parent = import_path3.default.dirname(repoDir);
@@ -14225,20 +14890,44 @@ async function ensureAstraGitRepo(target) {
14225
14890
  }
14226
14891
  return repoDir;
14227
14892
  }
14228
- async function initAstraRepo(docPath, initialXml, extraFiles) {
14893
+ async function initAstraRepo(docPath, initialXml, extraFiles, originalBytes) {
14229
14894
  const repoDir = await ensureAstraGitRepo(docPath);
14230
- await import_isomorphic_git.default.init({ fs: import_fs4.default, dir: repoDir });
14231
- await writeAndAdd(repoDir, "document.xml", initialXml);
14232
- if (extraFiles) {
14233
- for (const [name2, content] of Object.entries(extraFiles)) {
14234
- await writeAndAdd(repoDir, name2, content);
14895
+ await withGitInitializationLock(repoDir, async () => {
14896
+ await import_isomorphic_git.default.init({ fs: import_fs4.default, dir: repoDir });
14897
+ let commits;
14898
+ try {
14899
+ commits = await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir });
14900
+ } catch (error) {
14901
+ if (!await isUnbornHistory(repoDir)) throw error;
14902
+ commits = [];
14903
+ }
14904
+ if (commits.length > 0) {
14905
+ throw Object.assign(new Error(`History is already initialized at ${repoDir}.`), { code: "E_CONFLICT" });
14906
+ }
14907
+ await clearUncommittedPackageParts(repoDir);
14908
+ if (originalBytes) {
14909
+ const existing = await getOriginalInfo(import_path3.default.resolve(docPath));
14910
+ if (existing.available && existing.sha256 !== originalSha256(originalBytes)) {
14911
+ const snapshotPath = import_path3.default.join(repoDir, ".git", "astra-original", "original.snapshot");
14912
+ throw Object.assign(new Error(
14913
+ `History initialization refused: an interrupted earlier initialization left a retained original (sha256 ${existing.sha256}) that differs from this file (sha256 ${originalSha256(originalBytes)}), and no history was recorded.`
14914
+ ), {
14915
+ code: "E_CONFLICT",
14916
+ reason: "original-mismatch",
14917
+ remedy: `Nothing was changed. The retained original may be the only copy of the earlier document: keep ${snapshotPath} (copy or rename it, do not delete it; \`astra history <file> --export-original <out>\` can still write its bytes to a new file), then run \`astra history <file> --init\` again once it is out of the way.`
14918
+ });
14919
+ }
14235
14920
  }
14236
- }
14237
- await import_isomorphic_git.default.commit({
14238
- fs: import_fs4.default,
14239
- dir: repoDir,
14240
- author: { name: "ASTra", email: "astra@attentionform.com" },
14241
- message: "Initial state"
14921
+ await captureOriginal(import_path3.default.resolve(docPath), originalBytes);
14922
+ await writeAndAdd(repoDir, "document.xml", initialXml);
14923
+ const writtenParts = await writeSnapshotParts(repoDir, extraFiles);
14924
+ await applySnapshotMarker(repoDir, await snapshotIsProvenComplete(repoDir, extraFiles, writtenParts));
14925
+ await import_isomorphic_git.default.commit({
14926
+ fs: import_fs4.default,
14927
+ dir: repoDir,
14928
+ author: { name: "ASTra", email: "astra@attentionform.com" },
14929
+ message: "Initial state"
14930
+ });
14242
14931
  });
14243
14932
  try {
14244
14933
  await checkSidecarTombstone(docPath, repoDir);
@@ -14250,6 +14939,7 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14250
14939
  const maxCommits = resolveHistoryMaxCommits(limits?.maxCommits);
14251
14940
  const maxSidecarBytes = resolveHistoryMaxSidecarBytes(limits?.maxSidecarBytes);
14252
14941
  const repoDir = await ensureAstraGitRepo(docPath);
14942
+ await assertGitHistoryReady(repoDir);
14253
14943
  const existingCommits = await getHistoryCommitCount(repoDir);
14254
14944
  if (existingCommits + 1 > maxCommits) {
14255
14945
  throw Object.assign(
@@ -14289,41 +14979,35 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14289
14979
  } catch (err) {
14290
14980
  if (err?.code === HISTORY_QUOTA_CODE) throw err;
14291
14981
  }
14292
- await writeAndAdd(repoDir, "document.xml", xmlContent);
14293
14982
  if (extraFiles) {
14294
- for (const [name2, content] of Object.entries(extraFiles)) {
14295
- await writeAndAdd(repoDir, name2, content);
14296
- }
14983
+ for (const name2 of Object.keys(extraFiles)) if (!isTraversalName(name2) && isTrackedPackageFile(name2)) historyPathFor(name2);
14297
14984
  }
14985
+ await writeAndAdd(repoDir, "document.xml", xmlContent);
14986
+ const writtenParts = await writeSnapshotParts(repoDir, extraFiles);
14298
14987
  try {
14299
14988
  const toDelete = /* @__PURE__ */ new Set();
14300
14989
  if (deletedFiles) {
14301
14990
  for (const name2 of deletedFiles) {
14302
- const rel = toRepoRelative(name2);
14303
- if (rel === "document.xml") continue;
14304
- toDelete.add(rel);
14991
+ if (!isTrackedPackageFile(name2) || isTraversalName(name2)) continue;
14992
+ toDelete.add(historyPathFor(name2));
14305
14993
  }
14306
14994
  }
14307
14995
  if (extraFiles) {
14308
- const newTracked = new Set(
14309
- Object.keys(extraFiles).map((k) => toRepoRelative(k))
14310
- );
14996
+ const newTracked = writtenParts;
14311
14997
  try {
14312
14998
  const tracked = await import_isomorphic_git.default.listFiles({ fs: import_fs4.default, dir: repoDir });
14313
14999
  for (const t of tracked) {
14314
15000
  const rel = toRepoRelative(t);
14315
- if (rel === "document.xml") continue;
14316
- if (isTrackedPackageFile(rel) && !newTracked.has(rel)) {
15001
+ if (isRepoPartPath(rel) && !newTracked.has(rel)) {
14317
15002
  toDelete.add(rel);
14318
15003
  }
14319
15004
  }
14320
15005
  } catch {
14321
15006
  }
14322
15007
  try {
14323
- const onDisk = await collectRepoFiles(repoDir);
14324
- for (const rel of onDisk.keys()) {
14325
- if (rel === "document.xml") continue;
14326
- if (isTrackedPackageFile(rel) && !newTracked.has(rel)) {
15008
+ const onDisk = await listRepoPaths(repoDir);
15009
+ for (const rel of onDisk) {
15010
+ if (isRepoPartPath(rel) && !newTracked.has(rel)) {
14327
15011
  toDelete.add(rel);
14328
15012
  }
14329
15013
  }
@@ -14331,10 +15015,10 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14331
15015
  }
14332
15016
  }
14333
15017
  for (const rel of toDelete) {
14334
- if (rel.includes("..") || import_path3.default.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) continue;
15018
+ if (isTraversalName(rel)) continue;
14335
15019
  const fullPath = import_path3.default.join(repoDir, rel);
14336
15020
  try {
14337
- await import_promises2.default.unlink(fullPath);
15021
+ await import_promises3.default.unlink(fullPath);
14338
15022
  } catch {
14339
15023
  }
14340
15024
  try {
@@ -14344,6 +15028,7 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14344
15028
  }
14345
15029
  } catch {
14346
15030
  }
15031
+ await applySnapshotMarker(repoDir, await snapshotIsProvenComplete(repoDir, extraFiles, writtenParts));
14347
15032
  const sha = await import_isomorphic_git.default.commit({
14348
15033
  fs: import_fs4.default,
14349
15034
  dir: repoDir,
@@ -14356,8 +15041,27 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14356
15041
  }
14357
15042
  return sha;
14358
15043
  }
15044
+ async function getHistoryInitializationState(docPath) {
15045
+ const repoDir = getRepoDir(docPath);
15046
+ try {
15047
+ if (await gitInitializationLockExists(repoDir)) {
15048
+ return { state: "in-progress", detail: "Another ASTra process holds the history initialization lock (or it was interrupted)." };
15049
+ }
15050
+ if (!import_fs4.default.existsSync(import_path3.default.join(repoDir, ".git"))) return { state: "absent" };
15051
+ await assertUsableSidecarGit(repoDir);
15052
+ const commits = await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir });
15053
+ if (commits.length === 0) return { state: "incomplete", detail: "History initialization is incomplete: no commit was recorded." };
15054
+ const root = commits[commits.length - 1];
15055
+ const initial = await checkoutCommit(docPath, root.oid);
15056
+ if (!initial.complete) return { state: "incomplete", detail: "The first history commit is not a complete package snapshot." };
15057
+ return { state: "complete" };
15058
+ } catch (error) {
15059
+ return { state: "incomplete", detail: error instanceof Error ? error.message : String(error) };
15060
+ }
15061
+ }
14359
15062
  async function getHistory(docPath) {
14360
15063
  const repoDir = getRepoDir(docPath);
15064
+ await assertNoGitInitializationInProgress(repoDir);
14361
15065
  await assertUsableSidecarGit(repoDir);
14362
15066
  let commits;
14363
15067
  try {
@@ -14395,32 +15099,29 @@ async function getHistory(docPath) {
14395
15099
  });
14396
15100
  return entries;
14397
15101
  }
14398
- async function collectRepoFiles(repoDir) {
14399
- const out = /* @__PURE__ */ new Map();
15102
+ async function listRepoPaths(repoDir) {
15103
+ const out = [];
14400
15104
  async function walk(dir, relBase) {
14401
15105
  let entries = [];
14402
15106
  try {
14403
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
15107
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
14404
15108
  } catch {
14405
15109
  return;
14406
15110
  }
14407
15111
  for (const entry of entries) {
14408
15112
  if (entry.name === ".git") continue;
14409
- const full = import_path3.default.join(dir, entry.name);
14410
15113
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
14411
- if (entry.isDirectory()) {
14412
- await walk(full, rel);
14413
- } else if (entry.isFile()) {
14414
- try {
14415
- out.set(rel, await import_promises2.default.readFile(full));
14416
- } catch {
14417
- }
14418
- }
15114
+ if (entry.isDirectory()) await walk(import_path3.default.join(dir, entry.name), rel);
15115
+ else if (entry.isFile()) out.push(rel);
14419
15116
  }
14420
15117
  }
14421
15118
  await walk(repoDir, "");
14422
15119
  return out;
14423
15120
  }
15121
+ function trackedPredicateFor(target) {
15122
+ return target.complete ? isTrackedPackageFile : isLegacyTrackedPackageFile;
15123
+ }
15124
+ var INCOMPLETE_SNAPSHOT_WARNING = "This commit was recorded by an older ASTra version and predates complete snapshots: parts it never recorded (for example comment metadata, document properties, custom XML) keep their current content in the file, so the restore is not exact: those parts can be left without the content-type or relationship entries they had before. Restores to versions saved from now on are exact.";
14424
15125
  async function checkoutCommit(docPath, commitOid) {
14425
15126
  if (typeof commitOid !== "string" || commitOid.length === 0) {
14426
15127
  throw new Error(`Cannot checkout: commit ${commitOid} not found in history.`);
@@ -14484,8 +15185,18 @@ async function checkoutCommit(docPath, commitOid) {
14484
15185
  );
14485
15186
  }
14486
15187
  const xml = Buffer.from(docBlob.blob).toString("utf8");
15188
+ let complete = false;
15189
+ const markerOid = blobs.get(SNAPSHOT_FORMAT_MARKER);
15190
+ if (markerOid) {
15191
+ try {
15192
+ const marker = await import_isomorphic_git.default.readBlob({ fs: import_fs4.default, dir: repoDir, oid: markerOid });
15193
+ complete = isCompleteSnapshotMarker(marker.blob);
15194
+ } catch {
15195
+ complete = false;
15196
+ }
15197
+ }
14487
15198
  const trackedEntries = [...blobs.entries()].filter(
14488
- ([rel]) => rel !== "document.xml" && isTrackedPackageFile(rel)
15199
+ ([rel]) => isRepoPartPath(rel) && (complete || isLegacyTrackedPackageFile(entryNameFor(rel)))
14489
15200
  );
14490
15201
  const extraBlobs = await Promise.all(
14491
15202
  trackedEntries.map(async ([rel, blobOid]) => {
@@ -14498,11 +15209,11 @@ async function checkoutCommit(docPath, commitOid) {
14498
15209
  `Checkout failed: cannot read blob '${rel}' (${blobOid}) at commit ${commitOid}: ${cause}`
14499
15210
  );
14500
15211
  }
14501
- return [rel, Buffer.from(blobResult.blob)];
15212
+ return [entryNameFor(rel), Buffer.from(blobResult.blob)];
14502
15213
  })
14503
15214
  );
14504
15215
  const extra = new Map(extraBlobs);
14505
- return { xml, extra };
15216
+ return { xml, extra, complete };
14506
15217
  }
14507
15218
  async function commitStateIfDiffersFromHead(docPath, xmlContent, message, extraFiles = {}, author) {
14508
15219
  try {
@@ -35075,7 +35786,7 @@ var ToonGenerator = class _ToonGenerator {
35075
35786
  // ../engine/src/tmp.ts
35076
35787
  var import_crypto5 = __toESM(require("crypto"));
35077
35788
  var import_fs7 = __toESM(require("fs"));
35078
- var import_os = __toESM(require("os"));
35789
+ var import_os2 = __toESM(require("os"));
35079
35790
  var import_path6 = __toESM(require("path"));
35080
35791
  var ASTRA_TMPDIR_ENV = "ASTRA_TMPDIR";
35081
35792
  var TMP_SYMLINK_REMEDY = "Point $ASTRA_TMPDIR at a real directory (not a symlink), or unset it and retry.";
@@ -35131,7 +35842,7 @@ function resolveAstraTmpBase() {
35131
35842
  try {
35132
35843
  import_fs7.default.mkdirSync(dir, { recursive: true });
35133
35844
  } catch {
35134
- return import_os.default.tmpdir();
35845
+ return import_os2.default.tmpdir();
35135
35846
  }
35136
35847
  refuseSymlinkBase(dir);
35137
35848
  refuseForeignOwner(dir);
@@ -35141,7 +35852,7 @@ function resolveAstraTmpBase() {
35141
35852
  }
35142
35853
  return dir;
35143
35854
  }
35144
- const fallback = import_os.default.tmpdir();
35855
+ const fallback = import_os2.default.tmpdir();
35145
35856
  const injected = [process.env.TMPDIR, process.env.TEMP, process.env.TMP].filter((v) => typeof v === "string" && v.trim() !== "").map((v) => import_path6.default.resolve(v.trim()));
35146
35857
  if (injected.includes(import_path6.default.resolve(fallback))) {
35147
35858
  const dir = import_path6.default.resolve(fallback);
@@ -35181,12 +35892,12 @@ function logForensics(p, reason) {
35181
35892
  try {
35182
35893
  const rawName = import_path6.default.basename(p);
35183
35894
  const name2 = rawName !== "" ? rawName : "artifact";
35184
- let hash = "unknown";
35895
+ let hash2 = "unknown";
35185
35896
  try {
35186
- hash = import_crypto5.default.createHash("sha256").update(p).digest("hex").slice(0, 16);
35897
+ hash2 = import_crypto5.default.createHash("sha256").update(p).digest("hex").slice(0, 16);
35187
35898
  } catch {
35188
35899
  }
35189
- process.stderr.write(`[ASTra] tmp artifact kept for forensics: name="${name2}" sha256="${hash}" (${reason}). Base honors $ASTRA_TMPDIR; remove manually after inspection.
35900
+ process.stderr.write(`[ASTra] tmp artifact kept for forensics: name="${name2}" sha256="${hash2}" (${reason}). Base honors $ASTRA_TMPDIR; remove manually after inspection.
35190
35901
  `);
35191
35902
  } catch {
35192
35903
  }
@@ -35250,12 +35961,12 @@ function logForensicPrune(target, reason) {
35250
35961
  try {
35251
35962
  const rawName = import_path6.default.basename(target);
35252
35963
  const name2 = rawName !== "" ? rawName : "artifact";
35253
- let hash = "unknown";
35964
+ let hash2 = "unknown";
35254
35965
  try {
35255
- hash = import_crypto5.default.createHash("sha256").update(target).digest("hex").slice(0, 16);
35966
+ hash2 = import_crypto5.default.createHash("sha256").update(target).digest("hex").slice(0, 16);
35256
35967
  } catch {
35257
35968
  }
35258
- process.stderr.write(`[ASTra] tmp forensics prune: name="${name2}" sha256="${hash}" (${reason}).
35969
+ process.stderr.write(`[ASTra] tmp forensics prune: name="${name2}" sha256="${hash2}" (${reason}).
35259
35970
  `);
35260
35971
  } catch {
35261
35972
  }
@@ -37491,9 +38202,9 @@ function escapeHtml(str) {
37491
38202
 
37492
38203
  // ../engine/src/export/pdf.ts
37493
38204
  var import_child_process = require("child_process");
37494
- var fs8 = __toESM(require("fs"));
37495
- var fsp3 = __toESM(require("fs/promises"));
37496
- var path7 = __toESM(require("path"));
38205
+ var fs9 = __toESM(require("fs"));
38206
+ var fsp4 = __toESM(require("fs/promises"));
38207
+ var path8 = __toESM(require("path"));
37497
38208
  var import_url = require("url");
37498
38209
 
37499
38210
  // ../engine/src/export/preflight.ts
@@ -37952,7 +38663,7 @@ var MAX_CONVERT_ATTEMPTS = 3;
37952
38663
  async function removeStagedDir(dir) {
37953
38664
  for (let attempt = 1; attempt <= 2; attempt++) {
37954
38665
  try {
37955
- await fsp3.rm(dir, { recursive: true, force: true });
38666
+ await fsp4.rm(dir, { recursive: true, force: true });
37956
38667
  return;
37957
38668
  } catch {
37958
38669
  if (attempt < 2) {
@@ -37964,10 +38675,10 @@ async function removeStagedDir(dir) {
37964
38675
  }
37965
38676
  async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, binary, stagedForensics) {
37966
38677
  const tmpDir = secureMkdtemp("astra-pdf-");
37967
- const tmpDocxPath = path7.join(tmpDir, `doc_${Date.now()}.docx`);
38678
+ const tmpDocxPath = path8.join(tmpDir, `doc_${Date.now()}.docx`);
37968
38679
  let succeeded = false;
37969
38680
  try {
37970
- await fsp3.writeFile(tmpDocxPath, docxBuffer);
38681
+ await fsp4.writeFile(tmpDocxPath, docxBuffer);
37971
38682
  secureFile(tmpDocxPath);
37972
38683
  let converted = false;
37973
38684
  let lastError = null;
@@ -37976,7 +38687,7 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
37976
38687
  await new Promise((resolve10, reject) => {
37977
38688
  const child = (0, import_child_process.spawn)(
37978
38689
  binary,
37979
- [`-env:UserInstallation=${(0, import_url.pathToFileURL)(path7.join(tmpDir, "lo_profile")).href}`, "--headless", "--convert-to", filterSpec, tmpDocxPath, "--outdir", tmpDir],
38690
+ [`-env:UserInstallation=${(0, import_url.pathToFileURL)(path8.join(tmpDir, "lo_profile")).href}`, "--headless", "--convert-to", filterSpec, tmpDocxPath, "--outdir", tmpDir],
37980
38691
  { stdio: ["ignore", "pipe", "pipe"] }
37981
38692
  );
37982
38693
  let stderr = "";
@@ -38012,23 +38723,23 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38012
38723
  if (!converted && lastError) {
38013
38724
  throw lastError;
38014
38725
  }
38015
- const expectedPdfName = path7.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
38016
- const generatedPdfPath = path7.join(tmpDir, expectedPdfName);
38017
- if (!fs8.existsSync(generatedPdfPath)) {
38726
+ const expectedPdfName = path8.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
38727
+ const generatedPdfPath = path8.join(tmpDir, expectedPdfName);
38728
+ if (!fs9.existsSync(generatedPdfPath)) {
38018
38729
  throw new Error(`PDF conversion failed: output file ${generatedPdfPath} was not created`);
38019
38730
  }
38020
- const targetDir = path7.dirname(path7.resolve(outPdfPath));
38021
- assertWriteTargetNoSymlink(path7.resolve(outPdfPath));
38022
- await fsp3.mkdir(targetDir, { recursive: true });
38023
- assertWriteTargetNoSymlink(path7.resolve(outPdfPath));
38024
- await writeFileNoFollow(path7.resolve(outPdfPath), await fsp3.readFile(generatedPdfPath));
38025
- fs8.chmodSync(path7.resolve(outPdfPath), 420);
38731
+ const targetDir = path8.dirname(path8.resolve(outPdfPath));
38732
+ assertWriteTargetNoSymlink(path8.resolve(outPdfPath));
38733
+ await fsp4.mkdir(targetDir, { recursive: true });
38734
+ assertWriteTargetNoSymlink(path8.resolve(outPdfPath));
38735
+ await writeFileNoFollow(path8.resolve(outPdfPath), await fsp4.readFile(generatedPdfPath));
38736
+ fs9.chmodSync(path8.resolve(outPdfPath), 420);
38026
38737
  succeeded = true;
38027
- return path7.resolve(outPdfPath);
38738
+ return path8.resolve(outPdfPath);
38028
38739
  } finally {
38029
38740
  if (succeeded) {
38030
38741
  try {
38031
- await fsp3.rm(tmpDir, { recursive: true, force: true });
38742
+ await fsp4.rm(tmpDir, { recursive: true, force: true });
38032
38743
  } catch {
38033
38744
  keepForForensics(tmpDir, "could not remove tmp dir after success");
38034
38745
  }
@@ -38041,8 +38752,8 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38041
38752
  }
38042
38753
 
38043
38754
  // ../engine/src/visual/backend.ts
38044
- var fs9 = __toESM(require("fs"));
38045
- var path8 = __toESM(require("path"));
38755
+ var fs10 = __toESM(require("fs"));
38756
+ var path9 = __toESM(require("path"));
38046
38757
  var VISUAL_DEPENDENCY_REMEDY = "PDF render backend missing. Install LibreOffice and poppler-utils (pdftoppm): sudo apt install libreoffice poppler-utils, or brew install libreoffice poppler. Alternatively inspect the XML/DOM without images (telemetry-only mode).";
38047
38758
  function createDependencyError(missing) {
38048
38759
  const which = missing.length > 0 ? missing.join(", ") : "soffice, pdftoppm";
@@ -38057,9 +38768,9 @@ function findOnPath(name2, pathEnv) {
38057
38768
  const trimmed = name2.trim();
38058
38769
  if (trimmed.includes("/") || trimmed.includes("\\")) {
38059
38770
  try {
38060
- const abs = path8.resolve(trimmed);
38061
- fs9.accessSync(abs, fs9.constants.X_OK);
38062
- const stat3 = fs9.statSync(abs);
38771
+ const abs = path9.resolve(trimmed);
38772
+ fs10.accessSync(abs, fs10.constants.X_OK);
38773
+ const stat3 = fs10.statSync(abs);
38063
38774
  if (stat3.isFile()) return abs;
38064
38775
  return null;
38065
38776
  } catch {
@@ -38068,12 +38779,12 @@ function findOnPath(name2, pathEnv) {
38068
38779
  }
38069
38780
  const pathValue = pathEnv !== void 0 ? pathEnv : process.env.PATH ?? "";
38070
38781
  if (!pathValue) return null;
38071
- const dirs = pathValue.split(path8.delimiter).filter((d) => d.length > 0);
38782
+ const dirs = pathValue.split(path9.delimiter).filter((d) => d.length > 0);
38072
38783
  for (const dir of dirs) {
38073
38784
  try {
38074
- const candidate = path8.join(dir, trimmed);
38075
- fs9.accessSync(candidate, fs9.constants.X_OK);
38076
- const stat3 = fs9.statSync(candidate);
38785
+ const candidate = path9.join(dir, trimmed);
38786
+ fs10.accessSync(candidate, fs10.constants.X_OK);
38787
+ const stat3 = fs10.statSync(candidate);
38077
38788
  if (stat3.isFile()) return candidate;
38078
38789
  } catch {
38079
38790
  continue;
@@ -38118,8 +38829,8 @@ function assertPdfBackend(override) {
38118
38829
  }
38119
38830
 
38120
38831
  // ../engine/src/visual/renderer.ts
38121
- var fs10 = __toESM(require("fs"));
38122
- var path9 = __toESM(require("path"));
38832
+ var fs11 = __toESM(require("fs"));
38833
+ var path10 = __toESM(require("path"));
38123
38834
  var import_url2 = require("url");
38124
38835
  var import_child_process2 = require("child_process");
38125
38836
  var import_util = require("util");
@@ -38130,13 +38841,13 @@ var VISUAL_DEFAULT_MAX_PAGES = 4;
38130
38841
  var VISUAL_MIN_DPI = 72;
38131
38842
  var VISUAL_MAX_DPI = 300;
38132
38843
  function getVisualArtifactsBaseDir() {
38133
- const dir = path9.join(resolveAstraTmpBase(), "ASTra", "visual");
38844
+ const dir = path10.join(resolveAstraTmpBase(), "ASTra", "visual");
38134
38845
  try {
38135
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
38846
+ fs11.mkdirSync(dir, { recursive: true, mode: 448 });
38136
38847
  } catch {
38137
38848
  }
38138
38849
  try {
38139
- secureDir(path9.join(resolveAstraTmpBase(), "ASTra"));
38850
+ secureDir(path10.join(resolveAstraTmpBase(), "ASTra"));
38140
38851
  } catch {
38141
38852
  }
38142
38853
  secureDir(dir);
@@ -38146,9 +38857,9 @@ function getVisualArtifactsDir(subDir) {
38146
38857
  const base = getVisualArtifactsBaseDir();
38147
38858
  if (!subDir || subDir.trim() === "") return base;
38148
38859
  const safe = subDir.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 64) || "run";
38149
- const dir = path9.join(base, safe);
38860
+ const dir = path10.join(base, safe);
38150
38861
  try {
38151
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
38862
+ fs11.mkdirSync(dir, { recursive: true, mode: 448 });
38152
38863
  } catch {
38153
38864
  }
38154
38865
  secureDir(dir);
@@ -38182,7 +38893,7 @@ function parsePageNumber(fileName) {
38182
38893
  function listRenderedPages(workDir, prefixBase) {
38183
38894
  let entries = [];
38184
38895
  try {
38185
- entries = fs10.readdirSync(workDir);
38896
+ entries = fs11.readdirSync(workDir);
38186
38897
  } catch {
38187
38898
  return [];
38188
38899
  }
@@ -38191,7 +38902,7 @@ function listRenderedPages(workDir, prefixBase) {
38191
38902
  if (!name2.startsWith(prefixBase)) continue;
38192
38903
  const page = parsePageNumber(name2);
38193
38904
  if (page === null) continue;
38194
- out.push({ page, file: path9.join(workDir, name2) });
38905
+ out.push({ page, file: path10.join(workDir, name2) });
38195
38906
  }
38196
38907
  out.sort((a, b) => a.page - b.page);
38197
38908
  return out;
@@ -38205,7 +38916,7 @@ function sandboxError(message, remedy) {
38205
38916
  function canonicalVisualBaseDir() {
38206
38917
  const base = getVisualArtifactsBaseDir();
38207
38918
  try {
38208
- return fs10.realpathSync(base);
38919
+ return fs11.realpathSync(base);
38209
38920
  } catch {
38210
38921
  return base;
38211
38922
  }
@@ -38229,7 +38940,7 @@ function resolveJailedVisualOutDir(rawOutDir) {
38229
38940
  if (trimmed.includes("\n") || trimmed.includes("\r")) {
38230
38941
  throw sandboxError("E_SANDBOX: visual outDir contains a forbidden line break.", "Remove line breaks from the path and retry.");
38231
38942
  }
38232
- const resolved = path9.resolve(trimmed);
38943
+ const resolved = path10.resolve(trimmed);
38233
38944
  const canonical = canonicalizeForAsset(resolved);
38234
38945
  const canonicalBase = canonicalVisualBaseDir();
38235
38946
  assertVisualInsideBase(canonical, trimmed, canonicalBase);
@@ -38261,7 +38972,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38261
38972
  outDir = getVisualArtifactsDir(runId);
38262
38973
  }
38263
38974
  try {
38264
- fs10.mkdirSync(outDir, { recursive: true });
38975
+ fs11.mkdirSync(outDir, { recursive: true });
38265
38976
  } catch (err) {
38266
38977
  const msg = err instanceof Error ? err.message : String(err);
38267
38978
  throw validationError(`E_VALIDATION: cannot create artifacts dir "${outDir}": ${msg}`, "Pass a writable outDir and retry.");
@@ -38272,16 +38983,16 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38272
38983
  outDir = live;
38273
38984
  }
38274
38985
  const workDir = secureMkdtemp("astra-visual-");
38275
- const inputDocx = path9.join(workDir, "input.docx");
38986
+ const inputDocx = path10.join(workDir, "input.docx");
38276
38987
  let succeeded = false;
38277
38988
  try {
38278
- fs10.writeFileSync(inputDocx, docxBuffer);
38989
+ fs11.writeFileSync(inputDocx, docxBuffer);
38279
38990
  secureFile(inputDocx);
38280
38991
  let lastErr;
38281
38992
  let converted = false;
38282
38993
  for (let attempt = 1; attempt <= 3; attempt++) {
38283
38994
  try {
38284
- await execFileAsync(sofficePath, [`-env:UserInstallation=${(0, import_url2.pathToFileURL)(path9.join(workDir, "lo_profile")).href}`, "--headless", "--convert-to", "pdf", "--outdir", workDir, inputDocx], {
38995
+ await execFileAsync(sofficePath, [`-env:UserInstallation=${(0, import_url2.pathToFileURL)(path10.join(workDir, "lo_profile")).href}`, "--headless", "--convert-to", "pdf", "--outdir", workDir, inputDocx], {
38285
38996
  timeout: timeoutMs
38286
38997
  });
38287
38998
  converted = true;
@@ -38299,17 +39010,17 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38299
39010
  }
38300
39011
  let pdfPath = null;
38301
39012
  try {
38302
- const names = fs10.readdirSync(workDir).filter((n) => n.toLowerCase().endsWith(".pdf"));
39013
+ const names = fs11.readdirSync(workDir).filter((n) => n.toLowerCase().endsWith(".pdf"));
38303
39014
  names.sort((a, b) => a === "input.pdf" ? -1 : b === "input.pdf" ? 1 : a.localeCompare(b));
38304
- if (names.length > 0) pdfPath = path9.join(workDir, names[0]);
39015
+ if (names.length > 0) pdfPath = path10.join(workDir, names[0]);
38305
39016
  } catch {
38306
39017
  pdfPath = null;
38307
39018
  }
38308
- if (!pdfPath || !fs10.existsSync(pdfPath)) {
39019
+ if (!pdfPath || !fs11.existsSync(pdfPath)) {
38309
39020
  throw validationError("E_VALIDATION: libreoffice produced no PDF output.", "Verify the .docx opens in Word/LibreOffice and retry.");
38310
39021
  }
38311
39022
  const prefixBase = "page";
38312
- const prefix = path9.join(workDir, prefixBase);
39023
+ const prefix = path10.join(workDir, prefixBase);
38313
39024
  const ppmArgs = format === "png" ? ["-png", "-r", String(dpi), pdfPath, prefix] : ["-jpeg", "-jpegopt", `quality=${jpegQuality}`, "-r", String(dpi), pdfPath, prefix];
38314
39025
  try {
38315
39026
  await execFileAsync(pdftoppmPath, ppmArgs, { timeout: Math.min(timeoutMs, 6e4) });
@@ -38328,15 +39039,15 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38328
39039
  const ext = format === "png" ? "png" : "jpg";
38329
39040
  for (const entry of selected) {
38330
39041
  const destName = `page-${entry.page}.${ext}`;
38331
- const dest = path9.join(outDir, destName);
38332
- if (!isPathInsideDir(path9.resolve(dest), path9.resolve(outDir))) {
39042
+ const dest = path10.join(outDir, destName);
39043
+ if (!isPathInsideDir(path10.resolve(dest), path10.resolve(outDir))) {
38333
39044
  throw sandboxError(
38334
39045
  `E_SANDBOX: visual artifact destination escapes outDir: "${dest}".`,
38335
39046
  "Pass an outDir inside the visual artifacts dir (use getVisualArtifactsDir(subDir) for a run-scoped dir) and retry."
38336
39047
  );
38337
39048
  }
38338
39049
  try {
38339
- fs10.copyFileSync(entry.file, dest);
39050
+ fs11.copyFileSync(entry.file, dest);
38340
39051
  secureFile(dest);
38341
39052
  } catch (err) {
38342
39053
  const msg = err instanceof Error ? err.message : String(err);
@@ -38345,7 +39056,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38345
39056
  const canonicalDest = canonicalizeForAsset(dest);
38346
39057
  if (!isPathInsideDir(canonicalDest, canonicalizeForAsset(outDir))) {
38347
39058
  try {
38348
- fs10.unlinkSync(dest);
39059
+ fs11.unlinkSync(dest);
38349
39060
  } catch {
38350
39061
  }
38351
39062
  throw sandboxError(
@@ -38355,7 +39066,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38355
39066
  }
38356
39067
  let bytes = 0;
38357
39068
  try {
38358
- bytes = fs10.statSync(dest).size;
39069
+ bytes = fs11.statSync(dest).size;
38359
39070
  } catch {
38360
39071
  bytes = 0;
38361
39072
  }
@@ -40671,9 +41382,39 @@ function summarizeStyleGuide(guide) {
40671
41382
  return out;
40672
41383
  }
40673
41384
 
41385
+ // ../engine/src/restore-original.ts
41386
+ var import_node_fs2 = __toESM(require("node:fs"));
41387
+ var import_promises4 = __toESM(require("node:fs/promises"));
41388
+ var import_node_path2 = __toESM(require("node:path"));
41389
+ var import_isomorphic_git2 = __toESM(require("isomorphic-git"));
41390
+ async function captureHistoryState(docPath) {
41391
+ const target = import_node_path2.default.resolve(docPath);
41392
+ const [head, index] = await Promise.all([
41393
+ getHistory(target).then((entries) => entries[0]?.oid, () => void 0),
41394
+ import_isomorphic_git2.default.statusMatrix({ fs: import_node_fs2.default, dir: getRepoDir(target) }).then((matrix) => JSON.stringify(matrix), () => void 0)
41395
+ ]);
41396
+ return { head, index };
41397
+ }
41398
+ async function historyChangedSince(docPath, before) {
41399
+ const after = await captureHistoryState(docPath);
41400
+ if (before.head === void 0 || after.head === void 0 || before.index === void 0 || after.index === void 0) return true;
41401
+ return after.head !== before.head || after.index !== before.index;
41402
+ }
41403
+ async function rollbackReplacedFile(docPath, previous, failure) {
41404
+ const outcome = failure.error?.outcome;
41405
+ if (!failure.replaced && outcome !== "replaced" && outcome !== "uncertain") return { recovered: true };
41406
+ try {
41407
+ await replaceFileAtomic(docPath, previous);
41408
+ if (originalSha256(await import_promises4.default.readFile(docPath)) === originalSha256(previous)) return { recovered: true };
41409
+ return { recovered: false, failure: "the file does not match the previous bytes after rollback" };
41410
+ } catch (error) {
41411
+ return { recovered: false, failure: error instanceof Error ? error.message : String(error) };
41412
+ }
41413
+ }
41414
+
40674
41415
  // src/lib/mcp/baseline-gate.ts
40675
41416
  var crypto13 = __toESM(require("crypto"));
40676
- var fsp4 = __toESM(require("fs/promises"));
41417
+ var fsp6 = __toESM(require("fs/promises"));
40677
41418
 
40678
41419
  // src/lib/mcp/errors.ts
40679
41420
  function mcpError(code, message, remedy) {
@@ -40753,8 +41494,8 @@ function invalidInput(message) {
40753
41494
  }
40754
41495
 
40755
41496
  // src/lib/mcp/diff.ts
40756
- var fs12 = __toESM(require("fs"));
40757
- var path11 = __toESM(require("path"));
41497
+ var fs14 = __toESM(require("fs"));
41498
+ var path13 = __toESM(require("path"));
40758
41499
  var DISCARDED_NODE_IDS_MAX = 20;
40759
41500
  var AUDIT_NODE_IDS_MAX = 100;
40760
41501
  var AUDIT_FILES_MAX = 100;
@@ -41121,7 +41862,7 @@ function pathDirname(p) {
41121
41862
  }
41122
41863
  function sidecarRepoExists(docPath) {
41123
41864
  try {
41124
- return fs12.existsSync(path11.join(getRepoDir(docPath), ".git"));
41865
+ return fs14.existsSync(path13.join(getRepoDir(docPath), ".git"));
41125
41866
  } catch {
41126
41867
  return false;
41127
41868
  }
@@ -41160,7 +41901,7 @@ async function resolveBaseline(handle) {
41160
41901
  if (handle.validationBaseline !== void 0) return handle.validationBaseline;
41161
41902
  if (!handle.openSha256) return null;
41162
41903
  try {
41163
- const bytes = await fsp4.readFile(handle.filePath);
41904
+ const bytes = await fsp6.readFile(handle.filePath);
41164
41905
  if (sha256Hex(bytes) !== handle.openSha256) return null;
41165
41906
  const baseline = buildBaseline(await validateForBaseline(bytes));
41166
41907
  handle.validationBaseline = baseline;
@@ -41244,7 +41985,7 @@ function withToleratedNote(message, preExisting) {
41244
41985
  async function validateAtOpen(handle, timeoutMs) {
41245
41986
  let bytes;
41246
41987
  try {
41247
- bytes = await fsp4.readFile(handle.filePath);
41988
+ bytes = await fsp6.readFile(handle.filePath);
41248
41989
  } catch (err) {
41249
41990
  return { status: "skipped", note: `the file could not be read for validation: ${err instanceof Error ? err.message : String(err)}` };
41250
41991
  }
@@ -41288,10 +42029,10 @@ async function validateAtOpen(handle, timeoutMs) {
41288
42029
  }
41289
42030
 
41290
42031
  // src/lib/session-store.ts
41291
- var fs13 = __toESM(require("fs"));
41292
- var fsp5 = __toESM(require("fs/promises"));
41293
- var os2 = __toESM(require("os"));
41294
- var path12 = __toESM(require("path"));
42032
+ var fs15 = __toESM(require("fs"));
42033
+ var fsp7 = __toESM(require("fs/promises"));
42034
+ var os3 = __toESM(require("os"));
42035
+ var path14 = __toESM(require("path"));
41295
42036
  var import_crypto6 = require("crypto");
41296
42037
  var MCP_SESSION_MAX_DOCS = 10;
41297
42038
  var MCP_SESSION_MAX_BYTES = 200 * 1024 * 1024;
@@ -41489,19 +42230,19 @@ function getMcpAllowedRoots() {
41489
42230
  if (named.length > 0) return [...new Set(named)];
41490
42231
  const roots = [];
41491
42232
  try {
41492
- roots.push(path12.resolve(process.cwd(), "documents"));
42233
+ roots.push(path14.resolve(process.cwd(), "documents"));
41493
42234
  } catch {
41494
42235
  }
41495
42236
  try {
41496
- roots.push(path12.resolve(os2.tmpdir()));
42237
+ roots.push(path14.resolve(os3.tmpdir()));
41497
42238
  } catch {
41498
42239
  }
41499
42240
  try {
41500
- roots.push(path12.resolve(process.cwd(), "documents", "Drafts"));
42241
+ roots.push(path14.resolve(process.cwd(), "documents", "Drafts"));
41501
42242
  } catch {
41502
42243
  }
41503
42244
  try {
41504
- roots.push(path12.resolve(os2.tmpdir(), "ASTra"));
42245
+ roots.push(path14.resolve(os3.tmpdir(), "ASTra"));
41505
42246
  } catch {
41506
42247
  }
41507
42248
  return [...new Set(roots)];
@@ -41518,12 +42259,12 @@ function getExistingExtraRoots() {
41518
42259
  const raw = (process.env.ASTRA_EXTRA_DOC_ROOTS || "").trim();
41519
42260
  if (!raw) return [];
41520
42261
  const out = [];
41521
- for (const part of raw.split(path12.delimiter)) {
42262
+ for (const part of raw.split(path14.delimiter)) {
41522
42263
  const p = part.trim();
41523
42264
  if (!p) continue;
41524
42265
  try {
41525
- const resolved = path12.resolve(p);
41526
- const stat3 = fs13.statSync(resolved);
42266
+ const resolved = path14.resolve(p);
42267
+ const stat3 = fs15.statSync(resolved);
41527
42268
  if (!stat3.isDirectory()) continue;
41528
42269
  out.push(resolved);
41529
42270
  } catch {
@@ -41535,7 +42276,7 @@ function findMcpContainingRoot(canonical) {
41535
42276
  for (const root of getMcpAllowedRoots()) {
41536
42277
  let canonicalRoot = root;
41537
42278
  try {
41538
- canonicalRoot = fs13.realpathSync(root);
42279
+ canonicalRoot = fs15.realpathSync(root);
41539
42280
  } catch {
41540
42281
  }
41541
42282
  if (isPathInsideDir(canonical, canonicalRoot) || isPathInsideDir(canonical, root)) {
@@ -41545,8 +42286,8 @@ function findMcpContainingRoot(canonical) {
41545
42286
  return void 0;
41546
42287
  }
41547
42288
  function assertNoDotSegments(canonical, root) {
41548
- const rel = path12.relative(root, canonical);
41549
- const segs = rel.split(path12.sep).filter((s) => s && s !== "." && s !== "..");
42289
+ const rel = path14.relative(root, canonical);
42290
+ const segs = rel.split(path14.sep).filter((s) => s && s !== "." && s !== "..");
41550
42291
  for (const seg of segs) {
41551
42292
  if (seg.startsWith(".")) {
41552
42293
  throw Object.assign(new Error(`Document path traverses a hidden directory "${seg}"`), {
@@ -41555,7 +42296,7 @@ function assertNoDotSegments(canonical, root) {
41555
42296
  });
41556
42297
  }
41557
42298
  }
41558
- const base = path12.basename(canonical);
42299
+ const base = path14.basename(canonical);
41559
42300
  if (base.startsWith(".")) {
41560
42301
  throw Object.assign(new Error("Document path must not use a hidden (dot-prefixed) file name"), {
41561
42302
  code: "E_SANDBOX",
@@ -41596,8 +42337,8 @@ function validateDocPath(rawPath) {
41596
42337
  remedy: `Use a path ${describeAllowedFolders()}.`
41597
42338
  });
41598
42339
  }
41599
- const resolved = path12.resolve(trimmed);
41600
- const resolvedSegments = resolved.split(path12.sep);
42340
+ const resolved = path14.resolve(trimmed);
42341
+ const resolvedSegments = resolved.split(path14.sep);
41601
42342
  if (resolvedSegments.includes("..")) {
41602
42343
  throw Object.assign(new Error("Document path resolves outside its directory"), {
41603
42344
  code: "E_SANDBOX",
@@ -41616,7 +42357,7 @@ function validateDocPath(rawPath) {
41616
42357
  );
41617
42358
  }
41618
42359
  assertNoDotSegments(canonical, containingRoot);
41619
- const ext = path12.extname(trimmed).toLowerCase();
42360
+ const ext = path14.extname(trimmed).toLowerCase();
41620
42361
  if (ext !== ".docx") {
41621
42362
  throw Object.assign(new Error(`Document path must use a .docx extension (got "${ext || "(none)"}")`), {
41622
42363
  code: "E_VALIDATION",
@@ -41638,9 +42379,9 @@ function validateOpenBufferLabel(rawLabel) {
41638
42379
  remedy: "Pass a plain memory label such as memory.docx."
41639
42380
  });
41640
42381
  }
41641
- if (path12.isAbsolute(trimmed)) return validateDocPath(trimmed);
42382
+ if (path14.isAbsolute(trimmed)) return validateDocPath(trimmed);
41642
42383
  if (trimmed.includes("\n") || trimmed.includes("\r")) return fallback;
41643
- const base = path12.basename(trimmed.replace(/\\/g, "/"));
42384
+ const base = path14.basename(trimmed.replace(/\\/g, "/"));
41644
42385
  if (!base || base === "." || base === "..") return fallback;
41645
42386
  const clean = base.slice(0, 128);
41646
42387
  return clean.toLowerCase().endsWith(".docx") ? clean : fallback;
@@ -41654,29 +42395,141 @@ function collectTrackedExtrasForGit(parser2) {
41654
42395
  }
41655
42396
  return extras;
41656
42397
  } catch (err) {
41657
- const msg = err instanceof Error ? err.message : String(err);
41658
- console.error(`[collectTrackedExtrasForGit] snapshot failed: ${msg}`);
41659
- return {};
42398
+ throw snapshotCollectionError(err);
41660
42399
  }
41661
42400
  }
41662
- async function ensureGitRepo(docPath, xml, extras) {
42401
+ function snapshotCollectionError(cause) {
42402
+ const detail = cause instanceof Error ? cause.message : String(cause);
42403
+ return Object.assign(new Error(`The document's package parts could not be collected for history (${detail}). Nothing was recorded.`), {
42404
+ code: "E_STATE",
42405
+ remedy: "Nothing was recorded in history. Retry; if it keeps failing, the document package may be damaged."
42406
+ });
42407
+ }
42408
+ async function ensureGitRepo(docPath, xml, extras, originalBytes) {
42409
+ return (await ensureGitRepoWith(docPath, () => ({ xml, extras }), originalBytes)).existed;
42410
+ }
42411
+ async function ensureGitRepoWith(docPath, baseline, originalBytes, options) {
41663
42412
  const repoDir = getRepoDir(docPath);
41664
42413
  let hasGit = false;
41665
42414
  try {
41666
- hasGit = fs13.existsSync(path12.join(repoDir, ".git"));
42415
+ hasGit = fs15.existsSync(path14.join(repoDir, ".git"));
41667
42416
  } catch {
41668
42417
  hasGit = false;
41669
42418
  }
41670
- if (hasGit) return true;
41671
- await initAstraRepo(docPath, xml, extras);
41672
- return false;
42419
+ if (hasGit) {
42420
+ if (!options?.alreadyReady) await assertGitHistoryReady(repoDir);
42421
+ return { existed: true };
42422
+ }
42423
+ try {
42424
+ const initial = baseline();
42425
+ await initAstraRepo(docPath, initial.xml, initial.extras, originalBytes);
42426
+ return { existed: false, baseline: initial };
42427
+ } catch (error) {
42428
+ if (fs15.existsSync(path14.join(repoDir, ".git"))) {
42429
+ await assertGitHistoryReady(repoDir);
42430
+ return { existed: true };
42431
+ }
42432
+ throw error;
42433
+ }
41673
42434
  }
41674
- async function initGitForHandleBestEffort(handle) {
42435
+ function collectBaselineForGit(parser2) {
42436
+ const xml = parser2.readDocumentXml();
41675
42437
  try {
41676
- if (!path12.isAbsolute(handle.filePath)) return;
41677
- const xml = handle.parser.readDocumentXml();
41678
- await ensureGitRepo(handle.filePath, xml, collectTrackedExtrasForGit(handle.parser));
41679
- } catch {
42438
+ const snapshot3 = parser2.capturePackageSnapshot({ documentXml: xml });
42439
+ const extras = {};
42440
+ for (const [name2, buf] of snapshot3) if (isTrackedPackageFile(name2)) extras[name2] = buf;
42441
+ return { xml, extras };
42442
+ } catch (err) {
42443
+ throw snapshotCollectionError(err);
42444
+ }
42445
+ }
42446
+ var sha256Of = (bytes) => (0, import_crypto6.createHash)("sha256").update(bytes).digest("hex");
42447
+ async function resolveSessionOriginal(handle) {
42448
+ const fallback = handle.originalInfo ?? { available: false, reason: "not-applicable" };
42449
+ const pin = handle.originalPin;
42450
+ if (!pin || !path14.isAbsolute(handle.filePath)) return fallback;
42451
+ const current = await getOriginalInfo(handle.filePath);
42452
+ if (pin.kind === "retained") {
42453
+ if (!current.available) return current;
42454
+ if (current.sha256 !== pin.sha256) {
42455
+ return { available: false, reason: "capture-failed", detail: "The retained original no longer matches the one this session opened." };
42456
+ }
42457
+ return current;
42458
+ }
42459
+ if (current.available && current.sha256 !== pin.sha256) {
42460
+ return { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42461
+ }
42462
+ const initialization = await getHistoryInitializationState(handle.filePath);
42463
+ if (initialization.state !== "complete") {
42464
+ return { available: false, reason: "initializing", detail: initialization.detail ?? "History initialization has not completed. Check get_document_history again later." };
42465
+ }
42466
+ if (!current.available) return current;
42467
+ handle.originalInfo = current;
42468
+ handle.originalPin = { kind: "retained", sha256: current.sha256 };
42469
+ handle.originalWarning = void 0;
42470
+ return current;
42471
+ }
42472
+ var INITIALIZING_WARNING = "History initialization was not complete when this document opened (another ASTra process may be creating it). Editing is available. Exact-original recovery becomes available if that initialization finishes for this same file; check get_document_history.";
42473
+ async function initGitForHandleBestEffort(handle, originalBytes) {
42474
+ try {
42475
+ if (!path14.isAbsolute(handle.filePath)) return;
42476
+ const repoDir = getRepoDir(handle.filePath);
42477
+ let completeBeforeRead = false;
42478
+ if (fs15.existsSync(path14.join(repoDir, ".git"))) {
42479
+ try {
42480
+ await assertGitHistoryReady(repoDir);
42481
+ completeBeforeRead = true;
42482
+ } catch {
42483
+ }
42484
+ }
42485
+ const availabilityAtOpen = await getOriginalInfo(handle.filePath);
42486
+ const ensured = await ensureGitRepoWith(handle.filePath, () => collectBaselineForGit(handle.parser), originalBytes, { alreadyReady: completeBeforeRead });
42487
+ if (ensured.existed) {
42488
+ const preExisting = completeBeforeRead && availabilityAtOpen.available;
42489
+ const now = preExisting ? availabilityAtOpen : await getOriginalInfo(handle.filePath);
42490
+ handle.originalInfo = now;
42491
+ if (now.available) {
42492
+ if (preExisting || originalBytes && sha256Of(originalBytes) === now.sha256) {
42493
+ handle.originalPin = { kind: "retained", sha256: now.sha256 };
42494
+ } else {
42495
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42496
+ }
42497
+ } else if (originalBytes && now.reason === "incomplete") {
42498
+ handle.originalInfo = { available: false, reason: "initializing", detail: "The history was still being initialized when this session opened." };
42499
+ handle.originalPin = { kind: "input", sha256: sha256Of(originalBytes) };
42500
+ handle.originalWarning = INITIALIZING_WARNING;
42501
+ }
42502
+ } else {
42503
+ handle.originalInfo = await getOriginalInfo(handle.filePath);
42504
+ const recorded = ensured.baseline;
42505
+ const history = await getHistory(handle.filePath);
42506
+ const head = history[0] ? await checkoutCommit(handle.filePath, history[0].oid) : void 0;
42507
+ const names = Object.keys(recorded.extras).sort();
42508
+ const headNames = head ? [...head.extra.keys()].sort() : [];
42509
+ const initialStateMatches = !!head?.complete && recorded.xml === head.xml && JSON.stringify(names) === JSON.stringify(headNames) && names.every((name2) => recorded.extras[name2].equals(head.extra.get(name2)));
42510
+ if (!initialStateMatches && handle.originalInfo.available) {
42511
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Concurrent history initialization produced a different initial document state. Close and reopen after inspecting history." };
42512
+ }
42513
+ if (originalBytes && handle.originalInfo.available && handle.originalInfo.sha256 !== sha256Of(originalBytes)) {
42514
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42515
+ }
42516
+ if (handle.originalInfo.available) handle.originalPin = { kind: "retained", sha256: handle.originalInfo.sha256 };
42517
+ }
42518
+ if (!handle.originalInfo.available && !["predates-feature", "not-applicable"].includes(handle.originalInfo.reason)) {
42519
+ handle.originalWarning = handle.originalInfo.reason === "initializing" ? INITIALIZING_WARNING : `Exact-original recovery is unavailable: ${handle.originalInfo.reason}. Keep a separate copy of the input file.`;
42520
+ }
42521
+ } catch (error) {
42522
+ const detail = error instanceof Error ? error.message : String(error);
42523
+ const mismatch = error?.reason === "original-mismatch";
42524
+ const initialization = originalBytes && !mismatch && path14.isAbsolute(handle.filePath) ? await getHistoryInitializationState(handle.filePath) : void 0;
42525
+ if (initialization && originalBytes && initialization.state !== "absent") {
42526
+ handle.originalInfo = { available: false, reason: "initializing", detail: initialization.detail ?? detail };
42527
+ handle.originalPin = { kind: "input", sha256: sha256Of(originalBytes) };
42528
+ handle.originalWarning = INITIALIZING_WARNING;
42529
+ } else {
42530
+ handle.originalInfo = mismatch ? { available: false, reason: "capture-failed", detail } : { available: false, reason: "incomplete", detail };
42531
+ handle.originalWarning = `History initialization or original-file capture did not complete. Editing is available, but exact-original recovery is not. ${detail} Keep a separate copy of the input file.`;
42532
+ }
41680
42533
  }
41681
42534
  }
41682
42535
  var McpSessionStore = class {
@@ -41926,12 +42779,12 @@ var McpSessionStore = class {
41926
42779
  * Macro/active content refuses by default (E_VALIDATION naming the parts);
41927
42780
  * pass {allowMacros:true} to preserve or {stripMacros:true} to strip.
41928
42781
  */
41929
- async openDocument(filePath, sessionId, macroOpts) {
42782
+ async openDocument(filePath, sessionId, macroOpts, captureInput = true) {
41930
42783
  const cleanPath = validateDocPath(filePath);
41931
42784
  const expiredEvicted = this.evictExpired();
41932
42785
  let buf;
41933
42786
  try {
41934
- buf = await fsp5.readFile(cleanPath);
42787
+ buf = await fsp7.readFile(cleanPath);
41935
42788
  } catch (err) {
41936
42789
  const msg = err instanceof Error ? err.message : String(err);
41937
42790
  throw Object.assign(new Error(`open_document: cannot read "${cleanPath}": ${msg}`), {
@@ -41966,10 +42819,12 @@ var McpSessionStore = class {
41966
42819
  lastAccess: Date.now(),
41967
42820
  mutex: new SessionMutex(),
41968
42821
  redoStack: this.createRedoStack(),
41969
- pendingAudit: { ops: 0, nodeIds: [] }
42822
+ pendingAudit: { ops: 0, nodeIds: [] },
42823
+ lastWrittenSha256: (0, import_crypto6.createHash)("sha256").update(buf).digest("hex"),
42824
+ macroPolicy: macroOpts
41970
42825
  };
41971
42826
  this.sessions.set(id, handle);
41972
- await initGitForHandleBestEffort(handle);
42827
+ await initGitForHandleBestEffort(handle, captureInput ? buf : void 0);
41973
42828
  this.lastOpenEvicted = [...expiredEvicted, ...this.evictToFit(id)];
41974
42829
  const live = this.sessions.get(id);
41975
42830
  if (!live) {
@@ -42036,10 +42891,13 @@ var McpSessionStore = class {
42036
42891
  lastAccess: Date.now(),
42037
42892
  mutex: new SessionMutex(),
42038
42893
  redoStack: this.createRedoStack(),
42039
- pendingAudit: { ops: 0, nodeIds: [] }
42894
+ pendingAudit: { ops: 0, nodeIds: [] },
42895
+ lastWrittenSha256: (0, import_crypto6.createHash)("sha256").update(buf).digest("hex"),
42896
+ macroPolicy: macroOpts
42040
42897
  };
42041
42898
  this.sessions.set(id, handle);
42042
- await initGitForHandleBestEffort(handle);
42899
+ await initGitForHandleBestEffort(handle, buf);
42900
+ if (!path14.isAbsolute(handle.filePath)) handle.originalInfo = { available: false, reason: "not-applicable" };
42043
42901
  this.lastOpenEvicted = [...expiredEvicted, ...this.evictToFit(id)];
42044
42902
  const live = this.sessions.get(id);
42045
42903
  if (!live) {
@@ -42214,6 +43072,8 @@ var LEAN6_TOOL_NAMES = [
42214
43072
  ];
42215
43073
  var GIT_HISTORY_TOOL_NAMES = [
42216
43074
  "get_document_history",
43075
+ "export_original_document",
43076
+ "restore_original_document",
42217
43077
  "preview_revert",
42218
43078
  "revert_document",
42219
43079
  "redo_document"
@@ -42261,8 +43121,10 @@ var LEAN6_DESCRIPTIONS = {
42261
43121
  )
42262
43122
  };
42263
43123
  var GIT_HISTORY_DESCRIPTIONS = {
43124
+ export_original_document: "Export the exact original DOCX captured when this history was created. Requires sessionId and outPath; overwrite defaults to false. Verifies the retained SHA-256 and never serializes the original. Does not change the live session. Refuses overwriting the active document: use restore_original_document instead. Old histories and memory-only sessions may have no original. The original can contain content removed by later sanitization; history retains it until the history store is deleted.",
43125
+ restore_original_document: "Replace the working file with the exact captured original, verified by SHA-256, and record a new history commit. Requires sessionId. Unsaved edits refuse unless discard:true explicitly discards them. Refuses an externally changed file or history that does not match the saved state. Close other programs editing this file before restoring; an external write racing the final replacement cannot be excluded. Reloads the session under its opening policy. Normal revert/redo restore document state, not necessarily the original ZIP bytes. The next normal save may normalize the file again. Use export_original_document for recovery to a different path.",
42264
43126
  get_document_history: withSharedDocs(
42265
- 'List the saved git history for a session (newest first, read-only: never mutates, never flips dirty). Paginate with limit (1-100, default 20) and offset (default 0). Returns {ok:true,sessionId,documentPath,currentCommit,totalCommits,history:[{oid,message,author,timestamp,timestampMs,opCount,affectedNodeIds,resultingHeadOid}]} (timestampMs = ms-precision epoch millis; commit-time clock for tracked commits, git seconds for older ones; opCount is always present (0 for pre-tracking commits and heals-only saves: it folds operations, not normalizer rewrites); affectedNodeIds is always present ([] when untracked); resultingHeadOid always equals oid (append-only; refs never move). Example {"sessionId":"sess_abc","limit":10} -> history slice. Use the oids with revert_document (or preview_revert first) to restore an earlier saved state. '
43127
+ 'List the saved git history and original-file availability for a session (newest first, read-only: never mutates, never flips dirty). Paginate with limit (1-100, default 20) and offset (default 0). Returns {ok:true,sessionId,documentPath,currentCommit,totalCommits,history:[{oid,message,author,timestamp,timestampMs,opCount,affectedNodeIds,resultingHeadOid}]} (timestampMs = ms-precision epoch millis; commit-time clock for tracked commits, git seconds for older ones; opCount is always present (0 for pre-tracking commits and heals-only saves: it folds operations, not normalizer rewrites); affectedNodeIds is always present ([] when untracked); resultingHeadOid always equals oid (append-only; refs never move). Example {"sessionId":"sess_abc","limit":10} -> history slice. Use the oids with revert_document (or preview_revert first) to restore an earlier saved state. '
42266
43128
  ),
42267
43129
  preview_revert: withSharedDocs(
42268
43130
  `Read-only WOULD-restore preview for revert_document (never mutates: no revision bump, no disk write, no redo-stack change). Target one commit: {steps:N} walks back N saved commits from the head (1 = the previous saved commit, must be < totalCommits) OR {commitOid:"<40-hex>"} jumps to that commit (mutually exclusive). Returns {ok:true,sessionId,targetOid,targetMessage,targetAuthor,stepsBack,affectedFiles,changedValues,changedValuesTruncated,changedValuesPositional,dirty,dirtyHandling,currentHeadOid,totalCommits} where affectedFiles lists package paths that differ from the target (word/document.xml plus tracked extras). The preview also carries advisory changedValues [{nodeId,before,after}] (live current text -> target restore text, bounded: at most ${CHANGED_VALUES_MAX} entries, each side at most ${CHANGED_VALUE_TEXT_MAX} chars, truncated flag honest; changedValuesPositional:true means either side held orphan ids (no shared id across the boundary, common under duplicate native ids) so before/after pairing is positional, not identity; empty + changedValuesReason when the target range is not computable). Example {"sessionId":"sess_abc","steps":1} -> preview summary. Preview first when a revert would discard unsaved edits.`
@@ -42451,6 +43313,15 @@ var ImportDocumentSchema = import_zod4.z.object({
42451
43313
  // Read $...$ as inline math (off by default: prices and shell text can look like math).
42452
43314
  dollarMath: import_zod4.z.boolean().optional()
42453
43315
  }).strict();
43316
+ var ExportOriginalDocumentSchema = import_zod4.z.object({
43317
+ sessionId: import_zod4.z.string().min(1).max(128),
43318
+ outPath: import_zod4.z.string().min(1).max(1024),
43319
+ overwrite: import_zod4.z.boolean().optional()
43320
+ }).strict();
43321
+ var RestoreOriginalDocumentSchema = import_zod4.z.object({
43322
+ sessionId: import_zod4.z.string().min(1).max(128),
43323
+ discard: import_zod4.z.boolean().optional()
43324
+ }).strict();
42454
43325
 
42455
43326
  // src/lib/mcp/handlers/open-inspect.ts
42456
43327
  function extractStylePalette2(parser2) {
@@ -42544,7 +43415,7 @@ async function handleOpenDocument(store, input) {
42544
43415
  const evicted = store.drainLastOpenEvicted();
42545
43416
  const pendingNormalizations = readPendingNormalizations(handle);
42546
43417
  const validation = await validateAtOpen(handle, parsed.data.validationTimeoutMs ?? openValidationTimeoutMs());
42547
- return { ok: true, sessionId: handle.sessionId, revision: handle.revision, summary, evicted, pendingNormalizations, validation };
43418
+ return { ok: true, sessionId: handle.sessionId, revision: handle.revision, summary, evicted, pendingNormalizations, validation, original: handle.originalInfo, ...handle.originalWarning ? { originalWarning: handle.originalWarning } : {} };
42548
43419
  } catch (err) {
42549
43420
  return mcpErrorFrom(err, "E_IO", "Check the .docx path is readable and retry open_document.");
42550
43421
  }
@@ -42769,11 +43640,11 @@ async function handleInspectDocument(store, input) {
42769
43640
  }
42770
43641
 
42771
43642
  // src/lib/mcp/handlers/search-batch.ts
42772
- var path14 = __toESM(require("path"));
43643
+ var path16 = __toESM(require("path"));
42773
43644
 
42774
43645
  // src/tools/compose-document.ts
42775
- var fs14 = __toESM(require("fs"));
42776
- var path13 = __toESM(require("path"));
43646
+ var fs16 = __toESM(require("fs"));
43647
+ var path15 = __toESM(require("path"));
42777
43648
  var import_zod5 = require("zod");
42778
43649
  var COMPOSE_MARKDOWN_MAX = 5e5;
42779
43650
  var COMPOSE_VALIDATION_ERRORS_MAX = 3;
@@ -42788,8 +43659,8 @@ function resolveMcpStyleGuide(ref, jail) {
42788
43659
  let anchor;
42789
43660
  if (jail?.allowedDir !== void 0) {
42790
43661
  anchor = jail.allowedDir;
42791
- } else if (jail?.sessionFilePath !== void 0 && path13.isAbsolute(jail.sessionFilePath)) {
42792
- anchor = canonicalizeForAsset(path13.resolve(path13.dirname(jail.sessionFilePath)));
43662
+ } else if (jail?.sessionFilePath !== void 0 && path15.isAbsolute(jail.sessionFilePath)) {
43663
+ anchor = canonicalizeForAsset(path15.resolve(path15.dirname(jail.sessionFilePath)));
42793
43664
  }
42794
43665
  if (anchor === void 0 && !hasGuideScheme(trimmed) && isFileGuideRef(trimmed)) {
42795
43666
  throw guideSandboxError(
@@ -42888,22 +43759,22 @@ function assertPathAllowed(allowedDir, roots) {
42888
43759
  remedy: `Use a folder ${describeAllowedFolders()}.`
42889
43760
  });
42890
43761
  }
42891
- const resolved = path13.resolve(trimmed);
43762
+ const resolved = path15.resolve(trimmed);
42892
43763
  const canonical = canonicalizeForAsset(resolved);
42893
43764
  const inside = roots.some((root) => {
42894
43765
  let canonicalRoot = root;
42895
43766
  try {
42896
- canonicalRoot = fs14.realpathSync(root);
43767
+ canonicalRoot = fs16.realpathSync(root);
42897
43768
  } catch {
42898
43769
  }
42899
- const normC = path13.normalize(canonical);
42900
- const normR = path13.normalize(canonicalRoot);
43770
+ const normC = path15.normalize(canonical);
43771
+ const normR = path15.normalize(canonicalRoot);
42901
43772
  if (process.platform === "win32") {
42902
43773
  const lc = normC.toLowerCase();
42903
43774
  const lr = normR.toLowerCase();
42904
- return lc === lr || lc.startsWith(lr + path13.sep.toLowerCase());
43775
+ return lc === lr || lc.startsWith(lr + path15.sep.toLowerCase());
42905
43776
  }
42906
- return normC === normR || normC.startsWith(normR + path13.sep);
43777
+ return normC === normR || normC.startsWith(normR + path15.sep);
42907
43778
  });
42908
43779
  if (!inside) {
42909
43780
  throw Object.assign(
@@ -43281,8 +44152,8 @@ async function handleExecuteBatch(store, input) {
43281
44152
  const explicit = parsed.data.allowedDir;
43282
44153
  if (explicit !== void 0) {
43283
44154
  batchAllowedDir = assertPathAllowed(explicit, getMcpAllowedRoots());
43284
- } else if (path14.isAbsolute(handle.filePath)) {
43285
- batchAllowedDir = canonicalizeForAsset(path14.resolve(path14.dirname(handle.filePath)));
44155
+ } else if (path16.isAbsolute(handle.filePath)) {
44156
+ batchAllowedDir = canonicalizeForAsset(path16.resolve(path16.dirname(handle.filePath)));
43286
44157
  } else {
43287
44158
  throw Object.assign(
43288
44159
  new Error(
@@ -43431,8 +44302,9 @@ async function handleExecuteBatch(store, input) {
43431
44302
  }
43432
44303
 
43433
44304
  // src/lib/mcp/handlers/save-close.ts
43434
- var fsp6 = __toESM(require("fs/promises"));
43435
- var path15 = __toESM(require("path"));
44305
+ var import_crypto7 = require("crypto");
44306
+ var fsp8 = __toESM(require("fs/promises"));
44307
+ var path17 = __toESM(require("path"));
43436
44308
  async function executeSaveDocument(store, input) {
43437
44309
  const parsed = SaveDocumentSchema.safeParse(input);
43438
44310
  if (!parsed.success) {
@@ -43478,18 +44350,18 @@ async function executeSaveDocument(store, input) {
43478
44350
  ),
43479
44351
  {
43480
44352
  code: "E_VALIDATION",
43481
- remedy: `name the output "${path15.basename(target)}" with a .docm extension, or add stripMacros: true to remove the macro parts`
44353
+ remedy: `name the output "${path17.basename(target)}" with a .docm extension, or add stripMacros: true to remove the macro parts`
43482
44354
  }
43483
44355
  );
43484
44356
  }
43485
44357
  if (isStripped && macroParts.length === 0 && /\.(docm|dotm)$/i.test(target)) {
43486
44358
  throw Object.assign(
43487
44359
  new Error(
43488
- `E_VALIDATION: save_document: stripMacros removes the macro parts, so the result is a plain document and cannot be written to "${path15.basename(target)}", a macro-enabled name`
44360
+ `E_VALIDATION: save_document: stripMacros removes the macro parts, so the result is a plain document and cannot be written to "${path17.basename(target)}", a macro-enabled name`
43489
44361
  ),
43490
44362
  {
43491
44363
  code: "E_VALIDATION",
43492
- remedy: `write the result to a .docx with outPath: "${path15.basename(target).replace(/\.docm$/i, ".docx")}", or use allowMacros: true to keep the macro parts in the .docm`
44364
+ remedy: `write the result to a .docx with outPath: "${path17.basename(target).replace(/\.docm$/i, ".docx")}", or use allowMacros: true to keep the macro parts in the .docm`
43493
44365
  }
43494
44366
  );
43495
44367
  }
@@ -43530,19 +44402,22 @@ async function executeSaveDocument(store, input) {
43530
44402
  const saveGateNormalized = readParserNormalizations(handle.parser);
43531
44403
  const buf = handle.parser.rezip();
43532
44404
  const saveNormalized = mergeNormalized(saveGateNormalized, readParserNormalizations(handle.parser));
44405
+ const savedXml = handle.parser.readDocumentXml();
44406
+ const extras = collectTrackedExtrasForGit(handle.parser);
43533
44407
  const prevRevision = handle.revision;
43534
44408
  const prevSavedRevision = handle.savedRevision;
43535
44409
  const prevByteSize = handle.byteSize;
43536
44410
  const prevFilePath = handle.filePath;
43537
44411
  let prevBytes = null;
43538
44412
  try {
43539
- prevBytes = await fsp6.readFile(target);
44413
+ prevBytes = await fsp8.readFile(target);
43540
44414
  } catch {
43541
44415
  prevBytes = null;
43542
44416
  }
44417
+ await assertHistoryReadyForWrite(target);
43543
44418
  try {
43544
44419
  assertWriteTargetNoSymlink(target);
43545
- await fsp6.mkdir(pathDirname(target), { recursive: true });
44420
+ await fsp8.mkdir(pathDirname(target), { recursive: true });
43546
44421
  assertWriteTargetNoSymlink(target);
43547
44422
  await writeFileNoFollow(target, buf);
43548
44423
  } catch (err) {
@@ -43553,7 +44428,7 @@ async function executeSaveDocument(store, input) {
43553
44428
  if (prevBytes !== null) {
43554
44429
  await writeFileNoFollow(target, prevBytes);
43555
44430
  } else {
43556
- await fsp6.unlink(target);
44431
+ await fsp8.unlink(target);
43557
44432
  }
43558
44433
  } catch (restoreErr) {
43559
44434
  diskOk = false;
@@ -43565,8 +44440,6 @@ async function executeSaveDocument(store, input) {
43565
44440
  remedy
43566
44441
  });
43567
44442
  }
43568
- const savedXml = handle.parser.readDocumentXml();
43569
- const extras = collectTrackedExtrasForGit(handle.parser);
43570
44443
  const opsSinceSave = handle.revision - handle.savedRevision;
43571
44444
  const fallbackMessage = opsSinceSave <= 0 ? `save_document: rev ${handle.revision} (initial baseline)` : `save_document: rev ${handle.revision} (${opsSinceSave} ops since last save)`;
43572
44445
  const baseMessage = sanitizeCommitMessage(parsed.data.message, fallbackMessage);
@@ -43574,7 +44447,7 @@ async function executeSaveDocument(store, input) {
43574
44447
  const authorName = sanitizeCommitAuthor(parsed.data.author);
43575
44448
  let commitOid;
43576
44449
  try {
43577
- await ensureGitRepo(target, savedXml, extras);
44450
+ await ensureGitRepo(target, savedXml, extras, prevBytes ?? void 0);
43578
44451
  commitOid = await commitChange(target, savedXml, commitMessage, extras, void 0, authorName);
43579
44452
  } catch (err) {
43580
44453
  const msg = err instanceof Error ? err.message : String(err);
@@ -43583,7 +44456,7 @@ async function executeSaveDocument(store, input) {
43583
44456
  if (prevBytes !== null) {
43584
44457
  await writeFileNoFollow(target, prevBytes);
43585
44458
  } else {
43586
- await fsp6.unlink(target);
44459
+ await fsp8.unlink(target);
43587
44460
  }
43588
44461
  } catch (restoreErr) {
43589
44462
  diskOk = false;
@@ -43604,6 +44477,15 @@ async function executeSaveDocument(store, input) {
43604
44477
  }
43605
44478
  try {
43606
44479
  handle.filePath = target;
44480
+ if (prevFilePath !== target || !handle.originalInfo) {
44481
+ handle.originalInfo = await getOriginalInfo(target);
44482
+ handle.originalPin = handle.originalInfo.available ? { kind: "retained", sha256: handle.originalInfo.sha256 } : void 0;
44483
+ }
44484
+ if (prevFilePath !== target) handle.originalWarning = void 0;
44485
+ if (!handle.originalInfo.available && !["predates-feature", "not-applicable"].includes(handle.originalInfo.reason)) {
44486
+ handle.originalWarning = `Exact-original recovery is unavailable: ${handle.originalInfo.reason}. Keep a separate copy of the input file.`;
44487
+ }
44488
+ handle.lastWrittenSha256 = (0, import_crypto7.createHash)("sha256").update(buf).digest("hex");
43607
44489
  handle.byteSize = buf.length;
43608
44490
  const auditNodeIds = [...handle.pendingAudit.nodeIds];
43609
44491
  const auditOps = handle.pendingAudit.ops;
@@ -43641,6 +44523,7 @@ async function executeSaveDocument(store, input) {
43641
44523
  ok: true,
43642
44524
  sessionId: handle.sessionId,
43643
44525
  path: target,
44526
+ ...handle.originalWarning ? { originalWarning: handle.originalWarning } : {},
43644
44527
  revision: handle.revision,
43645
44528
  commitOid,
43646
44529
  commitMessage,
@@ -43710,8 +44593,151 @@ async function handleCloseDocument(store, input) {
43710
44593
  }
43711
44594
  }
43712
44595
 
44596
+ // src/lib/mcp/handlers/original.ts
44597
+ var fsp9 = __toESM(require("fs/promises"));
44598
+ var path18 = __toESM(require("path"));
44599
+ var import_crypto8 = require("crypto");
44600
+ var hash = (buf) => (0, import_crypto8.createHash)("sha256").update(buf).digest("hex");
44601
+ function refuse2(code, message, remedy = "Inspect the file and its history before retrying. Export the original to a new path for recovery without changing this document.") {
44602
+ throw Object.assign(new Error(message), { code, remedy });
44603
+ }
44604
+ async function requireAvailableOriginal(handle) {
44605
+ const info = await resolveSessionOriginal(handle);
44606
+ if (info.available) return;
44607
+ const detail = info.detail ?? "Close and reopen after checking history.";
44608
+ const remedy = info.reason === "initializing" ? "History initialization for this document has not finished. Call get_document_history again later; the original becomes available once it completes for this same file." : "The retained original cannot be trusted, so no substitute is returned. Keep the working file and any separate backup, and call get_document_history to see the reason.";
44609
+ refuse2("E_CONFLICT", `Exact-original recovery is unavailable to this session (${info.reason}). ${detail}`, remedy);
44610
+ }
44611
+ async function handleExportOriginalDocument(store, input) {
44612
+ const parsed = ExportOriginalDocumentSchema.safeParse(input);
44613
+ if (!parsed.success) return invalidInput(`export_original_document: ${parsed.error.message}`);
44614
+ try {
44615
+ return await store.withSession(parsed.data.sessionId, async (handle) => {
44616
+ if (!path18.isAbsolute(handle.filePath)) refuse2("E_VALIDATION", "This memory session has no retained original.");
44617
+ await requireAvailableOriginal(handle);
44618
+ const outPath = validateSaveTarget(parsed.data.outPath);
44619
+ if (outPath === handle.filePath) refuse2("E_CONFLICT", "Use restore_original_document to replace the active document.");
44620
+ const original = await exportOriginal(handle.filePath, outPath, { overwrite: parsed.data.overwrite === true });
44621
+ return { ok: true, sessionId: handle.sessionId, path: outPath, original };
44622
+ });
44623
+ } catch (err) {
44624
+ return mcpErrorFrom(err, "E_IO", "Choose a writable output path inside the allowed directory.");
44625
+ }
44626
+ }
44627
+ async function confirmHistoryCurrent(handle, bytes) {
44628
+ const history = await getHistory(handle.filePath);
44629
+ if (!history[0]) refuse2("E_CONFLICT", "Cannot confirm history is current: there is no recorded head.");
44630
+ const head = await checkoutCommit(handle.filePath, history[0].oid);
44631
+ if (!head.complete) refuse2("E_CONFLICT", "Cannot confirm history is current: the head is not a complete package snapshot. Save the document first.");
44632
+ const parser2 = new DocxParser(bytes, handle.macroPolicy);
44633
+ await parser2.getAST();
44634
+ const snapshot3 = parser2.capturePackageSnapshot();
44635
+ if (parser2.readDocumentXml() !== head.xml) refuse2("E_CONFLICT", "History differs from the current file. Save or revert before restoring the original.");
44636
+ const names = [...snapshot3.keys()].filter(isTrackedPackageFile).sort();
44637
+ const recorded = [...head.extra.keys()].sort();
44638
+ if (JSON.stringify(names) !== JSON.stringify(recorded) || names.some((name2) => !snapshot3.get(name2).equals(head.extra.get(name2)))) {
44639
+ refuse2("E_CONFLICT", "History is behind the file or the file was modified outside ASTra. Save or revert before restoring the original.");
44640
+ }
44641
+ return { oid: history[0].oid, parser: parser2 };
44642
+ }
44643
+ async function handleRestoreOriginalDocument(store, input) {
44644
+ const parsed = RestoreOriginalDocumentSchema.safeParse(input);
44645
+ if (!parsed.success) return invalidInput(`restore_original_document: ${parsed.error.message}`);
44646
+ try {
44647
+ return await store.withSession(parsed.data.sessionId, async (handle) => {
44648
+ if (!path18.isAbsolute(handle.filePath)) refuse2("E_VALIDATION", "This memory session has no retained original.");
44649
+ await requireAvailableOriginal(handle);
44650
+ if (handle.revision !== handle.savedRevision && !parsed.data.discard) refuse2("E_CONFLICT", "The session has unsaved edits. Save first, or explicitly pass discard:true.");
44651
+ const source = validateSaveTarget(handle.filePath);
44652
+ await assertHistoryReadyForWrite(source);
44653
+ const original = await getOriginalInfo(source);
44654
+ if (!original.available) refuse2("E_NOT_FOUND", `The original is unavailable: ${original.reason}.`);
44655
+ const bytes = await readOriginal(source);
44656
+ const previous = await fsp9.readFile(source);
44657
+ if (!handle.lastWrittenSha256 || hash(previous) !== handle.lastWrittenSha256) refuse2("E_CONFLICT", "The file changed outside ASTra. Reopen it before restoring.");
44658
+ const checked = await confirmHistoryCurrent(handle, previous);
44659
+ const parser2 = new DocxParser(bytes, handle.macroPolicy);
44660
+ const ast = await parser2.getAST();
44661
+ const engine = new OperationEngine(ast, parser2);
44662
+ if (handle.styleGuide) engine.setStyleGuide(handle.styleGuide);
44663
+ const validation = await validateForBaseline(bytes);
44664
+ const backup = newRestoreBackupPath(source);
44665
+ await createVerifiedBackup(backup, previous);
44666
+ const historyBefore = await captureHistoryState(source);
44667
+ const oldRedo = Array.from(handle.redoStack);
44668
+ const previousSession = {
44669
+ parser: handle.parser,
44670
+ ast: handle.ast,
44671
+ engine: handle.engine,
44672
+ toon: handle.toon,
44673
+ revision: handle.revision,
44674
+ savedRevision: handle.savedRevision,
44675
+ byteSize: handle.byteSize,
44676
+ lastWrittenSha256: handle.lastWrittenSha256,
44677
+ validationBaseline: handle.validationBaseline,
44678
+ pendingAudit: { ops: handle.pendingAudit.ops, nodeIds: [...handle.pendingAudit.nodeIds] }
44679
+ };
44680
+ let replaced = false;
44681
+ let commitAttempted = false;
44682
+ let commitOid;
44683
+ let durabilityUnconfirmed = false;
44684
+ try {
44685
+ handle.redoStack.length = 0;
44686
+ try {
44687
+ handle.redoStack.push((parsed.data.discard ? checked.parser : handle.parser).capturePackageSnapshot());
44688
+ } catch (quota) {
44689
+ if (quota?.code !== "E_QUOTA") throw quota;
44690
+ }
44691
+ if (hash(await fsp9.readFile(source)) !== hash(previous)) refuse2("E_CONFLICT", "The file changed while restore was being prepared.");
44692
+ const write = await replaceFileAtomic(source, bytes);
44693
+ replaced = true;
44694
+ durabilityUnconfirmed = write.durabilityUnconfirmed === true;
44695
+ commitAttempted = true;
44696
+ commitOid = await commitChange(source, parser2.readDocumentXml(), `Restore original file (sha256 ${hash(bytes)})`, collectTrackedExtrasForGit(parser2));
44697
+ handle.parser = parser2;
44698
+ handle.ast = ast;
44699
+ handle.engine = engine;
44700
+ handle.toon = new ToonGenerator();
44701
+ handle.byteSize = bytes.length;
44702
+ store.bumpRevision(handle);
44703
+ store.markSaved(handle);
44704
+ handle.lastWrittenSha256 = hash(bytes);
44705
+ adoptBaseline(handle, validation);
44706
+ } catch (err) {
44707
+ Object.assign(handle, previousSession);
44708
+ handle.redoStack.length = 0;
44709
+ Array.prototype.push.apply(handle.redoStack, oldRedo);
44710
+ const { recovered: diskRecovered, failure: rollbackFailure } = await rollbackReplacedFile(source, previous, { replaced, error: err });
44711
+ const rollbackError = rollbackFailure ?? "";
44712
+ const historyChanged = commitAttempted && await historyChangedSince(source, historyBefore);
44713
+ if (diskRecovered) await fsp9.unlink(backup).catch(() => void 0);
44714
+ const detail = `${String(err instanceof Error ? err.message : err)}${historyChanged ? " History may have changed despite the failed commit; inspect history before continuing." : ""}${diskRecovered ? "" : ` Rollback failed (${rollbackError}); recovery bytes are retained at ${backup}.`}`;
44715
+ throw Object.assign(new Error(detail), {
44716
+ code: err?.code ?? "E_IO",
44717
+ remedy: diskRecovered ? "Inspect the file and history before retrying." : `Recover from ${backup} before editing further.`
44718
+ });
44719
+ }
44720
+ const cleanupFailed = await fsp9.unlink(backup).then(() => false, () => true);
44721
+ return {
44722
+ ok: true,
44723
+ sessionId: handle.sessionId,
44724
+ path: source,
44725
+ revision: handle.revision,
44726
+ dirty: false,
44727
+ commitOid,
44728
+ original,
44729
+ ...durabilityUnconfirmed ? { durabilityUnconfirmed: true } : {},
44730
+ ...cleanupFailed ? { warning: `Restore succeeded but the temporary recovery backup could not be removed: ${backup}` } : {}
44731
+ };
44732
+ });
44733
+ } catch (err) {
44734
+ return mcpErrorFrom(err, "E_IO", "Inspect the document and history; export the original to a new path if in-place restore is unavailable.");
44735
+ }
44736
+ }
44737
+
43713
44738
  // src/lib/mcp/handlers/history.ts
43714
- var fsp7 = __toESM(require("fs/promises"));
44739
+ var import_crypto9 = require("crypto");
44740
+ var fsp10 = __toESM(require("fs/promises"));
43715
44741
  async function handleGetDocumentHistory(store, input) {
43716
44742
  const parsed = GetDocumentHistorySchema.safeParse(input);
43717
44743
  if (!parsed.success) {
@@ -43738,7 +44764,21 @@ async function handleGetDocumentHistory(store, input) {
43738
44764
  currentCommit: null,
43739
44765
  totalCommits: 0,
43740
44766
  history: [],
43741
- warning: emptyWarning
44767
+ warning: emptyWarning,
44768
+ original: await resolveSessionOriginal(handle)
44769
+ };
44770
+ }
44771
+ const original = await resolveSessionOriginal(handle);
44772
+ if (!original.available && original.reason === "initializing") {
44773
+ return {
44774
+ ok: true,
44775
+ sessionId,
44776
+ documentPath: filePath,
44777
+ currentCommit: null,
44778
+ totalCommits: 0,
44779
+ history: [],
44780
+ warning: original.detail ?? "History initialization has not completed.",
44781
+ original
43742
44782
  };
43743
44783
  }
43744
44784
  const full = await getHistory(filePath);
@@ -43772,7 +44812,10 @@ async function handleGetDocumentHistory(store, input) {
43772
44812
  currentCommit: full.length > 0 ? full[0].oid : null,
43773
44813
  totalCommits: full.length,
43774
44814
  history: slice,
43775
- warning
44815
+ warning,
44816
+ // Recovery status was re-verified above against what this session pinned at open, so a corrupted or
44817
+ // replaced original is not reported as available and another history's original is never adopted.
44818
+ original
43776
44819
  };
43777
44820
  });
43778
44821
  } catch (err) {
@@ -43874,7 +44917,7 @@ async function handleRevertDocument(store, input) {
43874
44917
  const prevByteSize = handle.byteSize;
43875
44918
  let prevFileBytes = null;
43876
44919
  try {
43877
- prevFileBytes = await fsp7.readFile(handle.filePath);
44920
+ prevFileBytes = await fsp10.readFile(handle.filePath);
43878
44921
  } catch {
43879
44922
  prevFileBytes = null;
43880
44923
  }
@@ -43897,6 +44940,7 @@ async function handleRevertDocument(store, input) {
43897
44940
  truncated: false
43898
44941
  };
43899
44942
  }
44943
+ await assertHistoryReadyForWrite(handle.filePath);
43900
44944
  let target;
43901
44945
  try {
43902
44946
  target = await checkoutCommit(handle.filePath, targetOid);
@@ -43908,15 +44952,16 @@ async function handleRevertDocument(store, input) {
43908
44952
  });
43909
44953
  }
43910
44954
  const merged = new Map(preRevertSnap);
44955
+ const isTrackedForTarget = trackedPredicateFor(target);
43911
44956
  for (const key of [...merged.keys()]) {
43912
- if (key !== "word/document.xml" && isTrackedPackageFile(key)) merged.delete(key);
44957
+ if (key !== "word/document.xml" && isTrackedForTarget(key)) merged.delete(key);
43913
44958
  }
43914
44959
  for (const [k, v] of target.extra) merged.set(k, v);
43915
44960
  merged.set("word/document.xml", Buffer.from(target.xml, "utf8"));
43916
44961
  let writeAttempted = false;
43917
44962
  let commitPhase = false;
43918
44963
  try {
43919
- handle.parser.restorePackageSnapshot(merged);
44964
+ handle.parser.restorePackageSnapshot(merged, { registerCoreProperties: !target.complete });
43920
44965
  await refreshHandleAstInPlace(handle);
43921
44966
  const buf = handle.parser.rezip();
43922
44967
  const candidate = await validateForBaseline(buf);
@@ -43925,7 +44970,7 @@ async function handleRevertDocument(store, input) {
43925
44970
  }
43926
44971
  assertWriteTargetNoSymlink(handle.filePath);
43927
44972
  writeAttempted = true;
43928
- await fsp7.mkdir(pathDirname(handle.filePath), { recursive: true });
44973
+ await fsp10.mkdir(pathDirname(handle.filePath), { recursive: true });
43929
44974
  assertWriteTargetNoSymlink(handle.filePath);
43930
44975
  await writeFileNoFollow(handle.filePath, buf);
43931
44976
  handle.byteSize = buf.length;
@@ -43954,6 +44999,7 @@ async function handleRevertDocument(store, input) {
43954
44999
  affectedNodeIds: diffIdTexts(liveBefore.texts, liveAfter.order, liveAfter.texts)
43955
45000
  });
43956
45001
  store.markSaved(handle);
45002
+ handle.lastWrittenSha256 = (0, import_crypto9.createHash)("sha256").update(buf).digest("hex");
43957
45003
  adoptBaseline(handle, candidate);
43958
45004
  return {
43959
45005
  ok: true,
@@ -43964,7 +45010,9 @@ async function handleRevertDocument(store, input) {
43964
45010
  dirty: false,
43965
45011
  discardedUnsaved,
43966
45012
  commitOid,
43967
- commitMessage
45013
+ commitMessage,
45014
+ snapshotComplete: target.complete,
45015
+ ...target.complete ? {} : { restoreWarning: INCOMPLETE_SNAPSHOT_WARNING }
43968
45016
  };
43969
45017
  } catch (stageErr) {
43970
45018
  const preserved = getEngineErrorCode(stageErr);
@@ -43987,7 +45035,7 @@ async function handleRevertDocument(store, input) {
43987
45035
  if (prevFileBytes !== null) {
43988
45036
  await writeFileNoFollow(handle.filePath, prevFileBytes);
43989
45037
  } else {
43990
- await fsp7.unlink(handle.filePath);
45038
+ await fsp10.unlink(handle.filePath);
43991
45039
  }
43992
45040
  } catch (restoreErr) {
43993
45041
  diskOk = false;
@@ -44044,6 +45092,7 @@ async function handleRedoDocument(store, input) {
44044
45092
  const redoReasonProvided = "reason" in rawRedo && parsed.data.reason !== void 0;
44045
45093
  const redoReasonText = redoReasonProvided ? sanitizeCommitMessage(parsed.data.reason, "") : "";
44046
45094
  const authorName = sanitizeCommitAuthor(parsed.data.author);
45095
+ await assertHistoryReadyForWrite(handle.filePath);
44047
45096
  const redoBefore = collectAstIdTexts(handle.ast);
44048
45097
  const prevRevision = handle.revision;
44049
45098
  const prevSavedRevision = handle.savedRevision;
@@ -44051,7 +45100,7 @@ async function handleRedoDocument(store, input) {
44051
45100
  const preRedoSnap = handle.parser.capturePackageSnapshot();
44052
45101
  let prevFileBytes = null;
44053
45102
  try {
44054
- prevFileBytes = await fsp7.readFile(handle.filePath);
45103
+ prevFileBytes = await fsp10.readFile(handle.filePath);
44055
45104
  } catch {
44056
45105
  prevFileBytes = null;
44057
45106
  }
@@ -44071,7 +45120,7 @@ async function handleRedoDocument(store, input) {
44071
45120
  didPop = true;
44072
45121
  assertWriteTargetNoSymlink(handle.filePath);
44073
45122
  writeAttempted = true;
44074
- await fsp7.mkdir(pathDirname(handle.filePath), { recursive: true });
45123
+ await fsp10.mkdir(pathDirname(handle.filePath), { recursive: true });
44075
45124
  assertWriteTargetNoSymlink(handle.filePath);
44076
45125
  await writeFileNoFollow(handle.filePath, buf);
44077
45126
  handle.byteSize = buf.length;
@@ -44104,6 +45153,7 @@ async function handleRedoDocument(store, input) {
44104
45153
  affectedNodeIds: diffIdTexts(redoBefore.texts, redoAfter.order, redoAfter.texts)
44105
45154
  });
44106
45155
  store.markSaved(handle);
45156
+ handle.lastWrittenSha256 = (0, import_crypto9.createHash)("sha256").update(buf).digest("hex");
44107
45157
  adoptBaseline(handle, candidate);
44108
45158
  return {
44109
45159
  ok: true,
@@ -44138,7 +45188,7 @@ async function handleRedoDocument(store, input) {
44138
45188
  if (prevFileBytes !== null) {
44139
45189
  await writeFileNoFollow(handle.filePath, prevFileBytes);
44140
45190
  } else {
44141
- await fsp7.unlink(handle.filePath);
45191
+ await fsp10.unlink(handle.filePath);
44142
45192
  }
44143
45193
  } catch (restoreErr) {
44144
45194
  diskOk = false;
@@ -44266,7 +45316,8 @@ async function handlePreviewRevert(store, input) {
44266
45316
  const targetXmlBytes = Buffer.from(target.xml, "utf8");
44267
45317
  if (!liveXml || Buffer.compare(liveXml, targetXmlBytes) !== 0) affectedFiles.push("word/document.xml");
44268
45318
  const names = /* @__PURE__ */ new Set();
44269
- for (const k of liveSnap.keys()) if (k !== "word/document.xml" && isTrackedPackageFile(k)) names.add(k);
45319
+ const isTrackedForTarget = trackedPredicateFor(target);
45320
+ for (const k of liveSnap.keys()) if (k !== "word/document.xml" && isTrackedForTarget(k)) names.add(k);
44270
45321
  for (const k of target.extra.keys()) names.add(k);
44271
45322
  for (const name2 of [...names].sort()) {
44272
45323
  if (affectedFiles.length >= AUDIT_FILES_MAX) break;
@@ -44314,6 +45365,8 @@ async function handlePreviewRevert(store, input) {
44314
45365
  changedValuesTruncated,
44315
45366
  changedValuesPositional,
44316
45367
  ...changedValuesReason ? { changedValuesReason } : {},
45368
+ snapshotComplete: target.complete,
45369
+ ...target.complete ? {} : { restoreWarning: INCOMPLETE_SNAPSHOT_WARNING },
44317
45370
  dirty,
44318
45371
  dirtyHandling: dirty ? `Session holds ${unsavedOps} revision(s) of unsaved edits: a bare real revert would fail closed with E_CONFLICT; a real revert with discardUnsaved:true WOULD discard them (echoed back bounded). Net-zero edits still count as dirty (revisions pending, not a content diff). This preview discarded nothing.` : "Session is clean: a real revert would discard nothing.",
44319
45372
  currentHeadOid: full[0].oid,
@@ -44329,6 +45382,8 @@ async function handlePreviewRevert(store, input) {
44329
45382
  }
44330
45383
  }
44331
45384
  var GIT_HISTORY_HANDLERS = {
45385
+ export_original_document: handleExportOriginalDocument,
45386
+ restore_original_document: handleRestoreOriginalDocument,
44332
45387
  get_document_history: handleGetDocumentHistory,
44333
45388
  preview_revert: handlePreviewRevert,
44334
45389
  revert_document: handleRevertDocument,
@@ -44376,9 +45431,9 @@ function adoptForkNumbering(fork, ast) {
44376
45431
  }
44377
45432
 
44378
45433
  // src/tools/export.ts
44379
- var fs15 = __toESM(require("fs"));
44380
- var fsp8 = __toESM(require("fs/promises"));
44381
- var path16 = __toESM(require("path"));
45434
+ var fs17 = __toESM(require("fs"));
45435
+ var fsp11 = __toESM(require("fs/promises"));
45436
+ var path19 = __toESM(require("path"));
44382
45437
  var ExportDocumentSchema = import_zod6.z.object({
44383
45438
  sessionId: import_zod6.z.string().describe("Active document session ID returned by open_document"),
44384
45439
  format: import_zod6.z.enum(["html", "markdown", "md", "pdf"]).describe('Target export format: "html", "markdown", "md", or "pdf"'),
@@ -44434,18 +45489,18 @@ function validateExportTarget(rawPath, expectedFormat) {
44434
45489
  remedy: "Use a path inside the workspace documents directory."
44435
45490
  });
44436
45491
  }
44437
- const resolved = path16.resolve(trimmed);
45492
+ const resolved = path19.resolve(trimmed);
44438
45493
  const canonical = canonicalizeForAsset(resolved);
44439
45494
  const allowedRoots = getMcpAllowedRoots();
44440
45495
  const isInside2 = allowedRoots.some((root) => {
44441
45496
  let canonicalRoot = root;
44442
45497
  try {
44443
- canonicalRoot = fs15.realpathSync(root);
45498
+ canonicalRoot = fs17.realpathSync(root);
44444
45499
  } catch {
44445
45500
  }
44446
- const normC = path16.normalize(canonical);
44447
- const normR = path16.normalize(canonicalRoot);
44448
- return normC === normR || normC.startsWith(normR + path16.sep);
45501
+ const normC = path19.normalize(canonical);
45502
+ const normR = path19.normalize(canonicalRoot);
45503
+ return normC === normR || normC.startsWith(normR + path19.sep);
44449
45504
  });
44450
45505
  if (!isInside2) {
44451
45506
  throw Object.assign(
@@ -44456,7 +45511,7 @@ function validateExportTarget(rawPath, expectedFormat) {
44456
45511
  }
44457
45512
  );
44458
45513
  }
44459
- const ext = path16.extname(trimmed).toLowerCase();
45514
+ const ext = path19.extname(trimmed).toLowerCase();
44460
45515
  const validExts = {
44461
45516
  html: [".html", ".htm"],
44462
45517
  markdown: [".md", ".markdown"],
@@ -44500,18 +45555,18 @@ function validateAssetTarget(rawPath) {
44500
45555
  remedy: "Use a path inside the workspace documents directory."
44501
45556
  });
44502
45557
  }
44503
- const resolved = path16.resolve(trimmed);
45558
+ const resolved = path19.resolve(trimmed);
44504
45559
  const canonical = canonicalizeForAsset(resolved);
44505
45560
  const allowedRoots = getMcpAllowedRoots();
44506
45561
  const isInside2 = allowedRoots.some((root) => {
44507
45562
  let canonicalRoot = root;
44508
45563
  try {
44509
- canonicalRoot = fs15.realpathSync(root);
45564
+ canonicalRoot = fs17.realpathSync(root);
44510
45565
  } catch {
44511
45566
  }
44512
- const normC = path16.normalize(canonical);
44513
- const normR = path16.normalize(canonicalRoot);
44514
- return normC === normR || normC.startsWith(normR + path16.sep);
45567
+ const normC = path19.normalize(canonical);
45568
+ const normR = path19.normalize(canonicalRoot);
45569
+ return normC === normR || normC.startsWith(normR + path19.sep);
44515
45570
  });
44516
45571
  if (!isInside2) {
44517
45572
  throw Object.assign(
@@ -44619,7 +45674,7 @@ async function handleExportDocument(store, rawArgs) {
44619
45674
  remedy: "Install LibreOffice on the host system: sudo apt install libreoffice (Linux) or brew install --cask libreoffice (macOS)."
44620
45675
  };
44621
45676
  }
44622
- const defaultPdf = targetPath || path16.join(path16.dirname(session.filePath), `${path16.basename(session.filePath, path16.extname(session.filePath))}.pdf`);
45677
+ const defaultPdf = targetPath || path19.join(path19.dirname(session.filePath), `${path19.basename(session.filePath, path19.extname(session.filePath))}.pdf`);
44623
45678
  try {
44624
45679
  validatedPdfPath = validateExportTarget(defaultPdf, "pdf");
44625
45680
  } catch (err) {
@@ -44658,7 +45713,7 @@ async function handleExportDocument(store, rawArgs) {
44658
45713
  }
44659
45714
  throw original;
44660
45715
  }
44661
- const stat3 = await fsp8.stat(validatedPdfPath);
45716
+ const stat3 = await fsp11.stat(validatedPdfPath);
44662
45717
  return {
44663
45718
  ok: true,
44664
45719
  format: "pdf",
@@ -44672,22 +45727,22 @@ async function handleExportDocument(store, rawArgs) {
44672
45727
  }
44673
45728
  const ast = session.ast;
44674
45729
  if (format === "html") {
44675
- const title = htmlDocumentTitle(ast, path16.basename(session.filePath, path16.extname(session.filePath)));
45730
+ const title = htmlDocumentTitle(ast, path19.basename(session.filePath, path19.extname(session.filePath)));
44676
45731
  const html = convertAstToHtml(ast, title, void 0, session.parser);
44677
45732
  if (targetPath) {
44678
45733
  assertWriteTargetNoSymlink(targetPath);
44679
- await fsp8.mkdir(path16.dirname(targetPath), { recursive: true });
45734
+ await fsp11.mkdir(path19.dirname(targetPath), { recursive: true });
44680
45735
  assertWriteTargetNoSymlink(targetPath);
44681
45736
  await writeFileNoFollow(targetPath, html, "utf-8");
44682
- const stat3 = await fsp8.stat(targetPath);
45737
+ const stat3 = await fsp11.stat(targetPath);
44683
45738
  return { ok: true, format: "html", path: targetPath, bytes: stat3.size, dirty, revision };
44684
45739
  }
44685
45740
  return { ok: true, format: "html", content: html, bytes: Buffer.byteLength(html, "utf-8"), dirty, revision };
44686
45741
  } else {
44687
45742
  const effectiveImageMode = parsed.data.imageMode ?? (targetPath ? "file" : "embed");
44688
- const docBase = path16.basename(
45743
+ const docBase = path19.basename(
44689
45744
  targetPath || session.filePath || "document.docx",
44690
- path16.extname(targetPath || session.filePath || "document.docx")
45745
+ path19.extname(targetPath || session.filePath || "document.docx")
44691
45746
  ) || "document";
44692
45747
  const safeDocBase = docBase.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "").slice(0, 120) || "document";
44693
45748
  const defaultAssetPrefix = `${safeDocBase}_assets`;
@@ -44695,39 +45750,39 @@ async function handleExportDocument(store, rawArgs) {
44695
45750
  if (effectiveImageMode === "file") {
44696
45751
  const initialAssets = collectMarkdownImageAssets(ast, effectiveAssetPrefix);
44697
45752
  if (!targetPath) throw new Error("Markdown file image mode requires an output path.");
44698
- const destDir = path16.dirname(targetPath);
45753
+ const destDir = path19.dirname(targetPath);
44699
45754
  let generation;
44700
45755
  let staging;
44701
45756
  let published = false;
44702
45757
  try {
44703
45758
  assertWriteTargetNoSymlink(targetPath);
44704
- await fsp8.mkdir(destDir, { recursive: true });
45759
+ await fsp11.mkdir(destDir, { recursive: true });
44705
45760
  assertWriteTargetNoSymlink(targetPath);
44706
45761
  let outputPrefix = effectiveAssetPrefix;
44707
45762
  if (initialAssets.length > 0) {
44708
- const prefixDir = validateAssetTarget(path16.resolve(destDir, effectiveAssetPrefix));
45763
+ const prefixDir = validateAssetTarget(path19.resolve(destDir, effectiveAssetPrefix));
44709
45764
  assertWriteTargetNoSymlink(prefixDir);
44710
- await fsp8.mkdir(prefixDir, { recursive: true });
45765
+ await fsp11.mkdir(prefixDir, { recursive: true });
44711
45766
  assertWriteTargetNoSymlink(prefixDir);
44712
- generation = await fsp8.mkdtemp(path16.join(prefixDir, "export-"));
45767
+ generation = await fsp11.mkdtemp(path19.join(prefixDir, "export-"));
44713
45768
  assertWriteTargetNoSymlink(generation);
44714
- outputPrefix = path16.relative(destDir, generation).split(path16.sep).join("/");
45769
+ outputPrefix = path19.relative(destDir, generation).split(path19.sep).join("/");
44715
45770
  }
44716
45771
  const md = convertAstToMarkdown(ast, session.parser, { imageMode: "file", assetPrefix: outputPrefix });
44717
45772
  const imageAssets = collectMarkdownImageAssets(ast, outputPrefix);
44718
45773
  const writtenAssets = [];
44719
45774
  for (const asset of imageAssets) {
44720
- const assetPath = validateAssetTarget(path16.resolve(destDir, asset.name));
45775
+ const assetPath = validateAssetTarget(path19.resolve(destDir, asset.name));
44721
45776
  assertWriteTargetNoSymlink(assetPath);
44722
45777
  await writeFileNoFollow(assetPath, asset.data);
44723
45778
  writtenAssets.push(assetPath);
44724
45779
  }
44725
- staging = await fsp8.mkdtemp(path16.join(destDir, ".astra-export-"));
44726
- const stagedFile = path16.join(staging, "document.md");
45780
+ staging = await fsp11.mkdtemp(path19.join(destDir, ".astra-export-"));
45781
+ const stagedFile = path19.join(staging, "document.md");
44727
45782
  assertWriteTargetNoSymlink(stagedFile);
44728
45783
  await writeFileNoFollow(stagedFile, md, "utf-8");
44729
45784
  assertWriteTargetNoSymlink(targetPath);
44730
- await fsp8.rename(stagedFile, targetPath);
45785
+ await fsp11.rename(stagedFile, targetPath);
44731
45786
  published = true;
44732
45787
  return {
44733
45788
  ok: true,
@@ -44742,17 +45797,17 @@ async function handleExportDocument(store, rawArgs) {
44742
45797
  assetCount: writtenAssets.length
44743
45798
  };
44744
45799
  } finally {
44745
- if (staging) await fsp8.rm(staging, { recursive: true, force: true }).catch(() => void 0);
44746
- if (!published && generation) await fsp8.rm(generation, { recursive: true, force: true }).catch(() => void 0);
45800
+ if (staging) await fsp11.rm(staging, { recursive: true, force: true }).catch(() => void 0);
45801
+ if (!published && generation) await fsp11.rm(generation, { recursive: true, force: true }).catch(() => void 0);
44747
45802
  }
44748
45803
  } else {
44749
45804
  const md = convertAstToMarkdown(ast, session.parser, { imageMode: "embed" });
44750
45805
  if (targetPath) {
44751
45806
  assertWriteTargetNoSymlink(targetPath);
44752
- await fsp8.mkdir(path16.dirname(targetPath), { recursive: true });
45807
+ await fsp11.mkdir(path19.dirname(targetPath), { recursive: true });
44753
45808
  assertWriteTargetNoSymlink(targetPath);
44754
45809
  await writeFileNoFollow(targetPath, md, "utf-8");
44755
- const stat3 = await fsp8.stat(targetPath);
45810
+ const stat3 = await fsp11.stat(targetPath);
44756
45811
  return {
44757
45812
  ok: true,
44758
45813
  format: "markdown",
@@ -44790,8 +45845,8 @@ async function handleExportDocument(store, rawArgs) {
44790
45845
  }
44791
45846
 
44792
45847
  // src/tools/visual.ts
44793
- var fs16 = __toESM(require("fs"));
44794
- var path17 = __toESM(require("path"));
45848
+ var fs18 = __toESM(require("fs"));
45849
+ var path20 = __toESM(require("path"));
44795
45850
  var import_zod7 = require("zod");
44796
45851
  function visualError(code, message, remedy) {
44797
45852
  return { ok: false, code, message, remedy };
@@ -44939,14 +45994,14 @@ function serializeSessionCopy2(parser2, ast) {
44939
45994
  return fork.rezip();
44940
45995
  }
44941
45996
  function placeImages(images, dir, docPath) {
44942
- fs16.mkdirSync(dir, { recursive: true });
44943
- const base = path17.basename(docPath || "document", path17.extname(docPath || "")).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document";
45997
+ fs18.mkdirSync(dir, { recursive: true });
45998
+ const base = path20.basename(docPath || "document", path20.extname(docPath || "")).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document";
44944
45999
  return images.map((img) => {
44945
- const ext = path17.extname(img.path) || (img.format === "jpeg" ? ".jpg" : ".png");
44946
- const dest = path17.join(dir, `${base}-page-${img.page}${ext}`);
44947
- writeFileNoFollowSync(dest, fs16.readFileSync(img.path));
46000
+ const ext = path20.extname(img.path) || (img.format === "jpeg" ? ".jpg" : ".png");
46001
+ const dest = path20.join(dir, `${base}-page-${img.page}${ext}`);
46002
+ writeFileNoFollowSync(dest, fs18.readFileSync(img.path));
44948
46003
  try {
44949
- fs16.rmSync(img.path, { force: true });
46004
+ fs18.rmSync(img.path, { force: true });
44950
46005
  } catch {
44951
46006
  }
44952
46007
  return { ...img, path: dest };
@@ -45073,8 +46128,8 @@ async function handleVisualInspect(store, input) {
45073
46128
 
45074
46129
  // src/tools/new-document.ts
45075
46130
  var import_zod8 = require("zod");
45076
- var fsp9 = __toESM(require("fs/promises"));
45077
- var path18 = __toESM(require("path"));
46131
+ var fsp12 = __toESM(require("fs/promises"));
46132
+ var path21 = __toESM(require("path"));
45078
46133
  var NEW_DOCUMENT_TEMPLATES = ["report", "memo", "blank"];
45079
46134
  var NewDocumentSchema = import_zod8.z.object({
45080
46135
  outPath: import_zod8.z.string().min(1).max(1024),
@@ -45150,10 +46205,10 @@ async function handleNewDocument(store, rawArgs) {
45150
46205
  }
45151
46206
  if (!force) {
45152
46207
  try {
45153
- await fsp9.access(target);
46208
+ await fsp12.access(target);
45154
46209
  return newDocError(
45155
46210
  "E_CONFLICT",
45156
- `new_document: destination "${path18.basename(target)}" already exists (refusing to overwrite without force)`,
46211
+ `new_document: destination "${path21.basename(target)}" already exists (refusing to overwrite without force)`,
45157
46212
  'Retry with {"force":true} to overwrite, or choose a different outPath.'
45158
46213
  );
45159
46214
  } catch {
@@ -45204,7 +46259,7 @@ async function handleNewDocument(store, rawArgs) {
45204
46259
  }
45205
46260
  try {
45206
46261
  assertWriteTargetNoSymlink(target);
45207
- await fsp9.mkdir(path18.dirname(target), { recursive: true });
46262
+ await fsp12.mkdir(path21.dirname(target), { recursive: true });
45208
46263
  assertWriteTargetNoSymlink(target);
45209
46264
  await writeFileNoFollow(target, buf);
45210
46265
  } catch (err) {
@@ -45216,12 +46271,12 @@ async function handleNewDocument(store, rawArgs) {
45216
46271
  );
45217
46272
  }
45218
46273
  try {
45219
- const handle = await store.openDocument(target, parsed.data.sessionId);
46274
+ const handle = await store.openDocument(target, parsed.data.sessionId, void 0, false);
45220
46275
  handle.parser.adoptBuiltinSeedFrom(parser2);
45221
46276
  if (parsed.data.author !== void 0) handle.ast.settings.defaultAuthor = parsed.data.author;
45222
46277
  let bytes = buf.length;
45223
46278
  try {
45224
- bytes = (await fsp9.stat(target)).size;
46279
+ bytes = (await fsp12.stat(target)).size;
45225
46280
  } catch {
45226
46281
  }
45227
46282
  if (guideRef !== void 0 && resolvedGuide !== void 0) {
@@ -45251,8 +46306,8 @@ async function handleNewDocument(store, rawArgs) {
45251
46306
  }
45252
46307
 
45253
46308
  // src/lib/mcp/handlers/parity.ts
45254
- var fs17 = __toESM(require("fs"));
45255
- var path19 = __toESM(require("path"));
46309
+ var fs19 = __toESM(require("fs"));
46310
+ var path22 = __toESM(require("path"));
45256
46311
  var IMPORT_FILE_MAX_BYTES = COMPOSE_MARKDOWN_MAX;
45257
46312
  async function refreshSessionAst(handle) {
45258
46313
  await refreshAstInPlace(handle.ast, handle.parser);
@@ -45507,17 +46562,17 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45507
46562
  if (segments.includes("..")) {
45508
46563
  throw toError("E_SANDBOX", 'import path contains forbidden ".." traversal', "Use a path inside the workspace documents directory.");
45509
46564
  }
45510
- const resolved = path19.resolve(trimmed);
46565
+ const resolved = path22.resolve(trimmed);
45511
46566
  const canonical = canonicalizeForAsset(resolved);
45512
46567
  const inside = roots.some((root) => {
45513
46568
  let canonicalRoot = root;
45514
46569
  try {
45515
- canonicalRoot = fs17.realpathSync(root);
46570
+ canonicalRoot = fs19.realpathSync(root);
45516
46571
  } catch {
45517
46572
  }
45518
- const normC = path19.normalize(canonical);
45519
- const normR = path19.normalize(canonicalRoot);
45520
- return normC === normR || normC.startsWith(normR + path19.sep);
46573
+ const normC = path22.normalize(canonical);
46574
+ const normR = path22.normalize(canonicalRoot);
46575
+ return normC === normR || normC.startsWith(normR + path22.sep);
45521
46576
  });
45522
46577
  if (!inside) {
45523
46578
  throw toError(
@@ -45526,12 +46581,12 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45526
46581
  "Place the Markdown file inside one of the allowed directories, or name its folder with --allowed-dir or ASTRA_EXTRA_DOC_ROOTS."
45527
46582
  );
45528
46583
  }
45529
- const base = path19.basename(canonical);
45530
- const nofollow = fs17.constants.O_NOFOLLOW;
45531
- const flags = typeof nofollow === "number" && nofollow !== 0 ? fs17.constants.O_RDONLY | nofollow : "r";
46584
+ const base = path22.basename(canonical);
46585
+ const nofollow = fs19.constants.O_NOFOLLOW;
46586
+ const flags = typeof nofollow === "number" && nofollow !== 0 ? fs19.constants.O_RDONLY | nofollow : "r";
45532
46587
  let fd;
45533
46588
  try {
45534
- fd = fs17.openSync(canonical, flags);
46589
+ fd = fs19.openSync(canonical, flags);
45535
46590
  } catch (err) {
45536
46591
  const code = err?.code;
45537
46592
  if (code === "ENOENT") {
@@ -45543,7 +46598,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45543
46598
  throw toError("E_IO", `import file cannot be read (${base})`, "Check the file exists and is readable, then retry.");
45544
46599
  }
45545
46600
  try {
45546
- const st = fs17.fstatSync(fd);
46601
+ const st = fs19.fstatSync(fd);
45547
46602
  if (!st.isFile()) {
45548
46603
  throw toError("E_IO", `import reference is not a file (${base})`, "Point the import at a regular Markdown file.");
45549
46604
  }
@@ -45557,7 +46612,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45557
46612
  "Split the Markdown source into smaller files and import each in its own call."
45558
46613
  );
45559
46614
  }
45560
- const buf = fs17.readFileSync(fd);
46615
+ const buf = fs19.readFileSync(fd);
45561
46616
  if (buf.length > IMPORT_FILE_MAX_BYTES) {
45562
46617
  throw toError(
45563
46618
  "E_LIMIT",
@@ -45567,18 +46622,18 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45567
46622
  }
45568
46623
  let post = canonical;
45569
46624
  try {
45570
- post = fs17.realpathSync(canonical);
46625
+ post = fs19.realpathSync(canonical);
45571
46626
  } catch {
45572
46627
  }
45573
46628
  const stillInside = roots.some((root) => {
45574
46629
  let canonicalRoot = root;
45575
46630
  try {
45576
- canonicalRoot = fs17.realpathSync(root);
46631
+ canonicalRoot = fs19.realpathSync(root);
45577
46632
  } catch {
45578
46633
  }
45579
- const normC = path19.normalize(post);
45580
- const normR = path19.normalize(canonicalRoot);
45581
- return normC === normR || normC.startsWith(normR + path19.sep);
46634
+ const normC = path22.normalize(post);
46635
+ const normR = path22.normalize(canonicalRoot);
46636
+ return normC === normR || normC.startsWith(normR + path22.sep);
45582
46637
  });
45583
46638
  if (!stillInside) {
45584
46639
  throw toError("E_SANDBOX", "import path points outside the allowed directory", `Keep Markdown files ${describeAllowedFolders()}.`);
@@ -45586,7 +46641,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45586
46641
  return buf.toString("utf8");
45587
46642
  } finally {
45588
46643
  try {
45589
- if (fd !== void 0) fs17.closeSync(fd);
46644
+ if (fd !== void 0) fs19.closeSync(fd);
45590
46645
  } catch {
45591
46646
  }
45592
46647
  }
@@ -45602,12 +46657,12 @@ async function handleImportDocument(store, input) {
45602
46657
  remedy: 'Retry with {"sessionId":"<id from open_document>","path":"<allowed-dir>/file.md"} (optional allowedDir for image refs, optional template report|memo|blank, optional styleGuide).'
45603
46658
  };
45604
46659
  }
45605
- const srcExt = path19.extname(parsed.data.path).toLowerCase();
46660
+ const srcExt = path22.extname(parsed.data.path).toLowerCase();
45606
46661
  if (srcExt === ".html" || srcExt === ".htm") {
45607
46662
  return {
45608
46663
  ok: false,
45609
46664
  code: "E_VALIDATION",
45610
- message: `HTML import is not supported directly ("${path19.basename(parsed.data.path)}")`,
46665
+ message: `HTML import is not supported directly ("${path22.basename(parsed.data.path)}")`,
45611
46666
  remedy: "ASTra imports Markdown (.md). Convert the HTML file to Markdown or supply a .md document"
45612
46667
  };
45613
46668
  }
@@ -45615,7 +46670,7 @@ async function handleImportDocument(store, input) {
45615
46670
  return {
45616
46671
  ok: false,
45617
46672
  code: "E_VALIDATION",
45618
- message: `import_document cannot ingest .docx files ("${path19.basename(parsed.data.path)}"): open .docx files directly with open_document instead`,
46673
+ message: `import_document cannot ingest .docx files ("${path22.basename(parsed.data.path)}"): open .docx files directly with open_document instead`,
45619
46674
  remedy: "Call open_document with the .docx path to work with it, or convert it to Markdown first and import the .md file."
45620
46675
  };
45621
46676
  }
@@ -45651,15 +46706,15 @@ async function handleImportDocument(store, input) {
45651
46706
  }
45652
46707
 
45653
46708
  // src/lib/mcp/schema-dump.ts
45654
- var fs18 = __toESM(require("fs"));
45655
- var path20 = __toESM(require("path"));
46709
+ var fs20 = __toESM(require("fs"));
46710
+ var path23 = __toESM(require("path"));
45656
46711
  var import_zod9 = require("zod");
45657
46712
 
45658
46713
  // package.json
45659
46714
  var package_default = {
45660
46715
  name: "@attentionform/astra-mcp-server",
45661
46716
  mcpName: "com.attentionform/astra-mcp-server",
45662
- version: "0.4.1",
46717
+ version: "0.5.0",
45663
46718
  description: "Official Model Context Protocol (MCP) server for ASTra document IDE",
45664
46719
  main: "dist/index.js",
45665
46720
  types: "dist/index.d.ts",
@@ -45700,8 +46755,8 @@ var package_default = {
45700
46755
  "adm-zip-gate": "node ../../scripts/check-adm-zip-imports.mjs"
45701
46756
  },
45702
46757
  dependencies: {
45703
- "@attentionform/astra-engine": "^0.4.1",
45704
- "@attentionform/ooxml-validator": "^0.4.1",
46758
+ "@attentionform/astra-engine": "^0.5.0",
46759
+ "@attentionform/ooxml-validator": "^0.5.0",
45705
46760
  "@modelcontextprotocol/sdk": "^1.32.2",
45706
46761
  "@xmldom/xmldom": "0.8.15",
45707
46762
  "adm-zip": "0.6.1",
@@ -45746,6 +46801,8 @@ function getToolDefinitions() {
45746
46801
  { name: "compose_document", description: COMPOSE_DOCUMENT_DESCRIPTION, schema: ComposeDocumentSchema },
45747
46802
  { name: "export_document", description: EXPORT_DOCUMENT_DESCRIPTION, schema: ExportDocumentSchema },
45748
46803
  { name: "visual_inspect", description: VISUAL_INSPECT_DESCRIPTION, schema: VisualInspectSchema },
46804
+ { name: "export_original_document", description: GIT_HISTORY_DESCRIPTIONS.export_original_document, schema: ExportOriginalDocumentSchema },
46805
+ { name: "restore_original_document", description: GIT_HISTORY_DESCRIPTIONS.restore_original_document, schema: RestoreOriginalDocumentSchema },
45749
46806
  { name: "get_document_history", description: GIT_HISTORY_DESCRIPTIONS.get_document_history, schema: GetDocumentHistorySchema },
45750
46807
  { name: "preview_revert", description: GIT_HISTORY_DESCRIPTIONS.preview_revert, schema: PreviewRevertSchema },
45751
46808
  { name: "revert_document", description: GIT_HISTORY_DESCRIPTIONS.revert_document, schema: RevertDocumentSchema },
@@ -45766,8 +46823,8 @@ function compactToolSchema(schema) {
45766
46823
  return raw;
45767
46824
  }
45768
46825
  function generateSchemasToDir(targetDir, version = getServerVersion()) {
45769
- if (!fs18.existsSync(targetDir)) {
45770
- fs18.mkdirSync(targetDir, { recursive: true });
46826
+ if (!fs20.existsSync(targetDir)) {
46827
+ fs20.mkdirSync(targetDir, { recursive: true });
45771
46828
  }
45772
46829
  const stamp2 = buildStamp(version);
45773
46830
  const written = [];
@@ -45779,8 +46836,8 @@ function generateSchemasToDir(targetDir, version = getServerVersion()) {
45779
46836
  parameters: raw,
45780
46837
  [SCHEMA_STAMP_KEY]: stamp2
45781
46838
  };
45782
- const filePath = path20.join(targetDir, `${tool.name}.json`);
45783
- fs18.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
46839
+ const filePath = path23.join(targetDir, `${tool.name}.json`);
46840
+ fs20.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
45784
46841
  written.push(filePath);
45785
46842
  }
45786
46843
  return { written, count: written.length };
@@ -45790,11 +46847,11 @@ function checkSchemasInDir(dir, expectedVersion2 = getServerVersion()) {
45790
46847
  const mismatches = [];
45791
46848
  let checked = 0;
45792
46849
  for (const tool of getToolDefinitions()) {
45793
- const file = path20.join(dir, `${tool.name}.json`);
46850
+ const file = path23.join(dir, `${tool.name}.json`);
45794
46851
  checked += 1;
45795
46852
  let stamped = "<missing>";
45796
46853
  try {
45797
- const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
46854
+ const raw = JSON.parse(fs20.readFileSync(file, "utf8"));
45798
46855
  const value = raw[SCHEMA_STAMP_KEY];
45799
46856
  stamped = typeof value === "string" ? value : "<unstamped>";
45800
46857
  } catch {
@@ -45807,7 +46864,7 @@ function checkSchemasInDir(dir, expectedVersion2 = getServerVersion()) {
45807
46864
  return { ok: mismatches.length === 0, mismatches, checked };
45808
46865
  }
45809
46866
  function formatMismatchWarning(mismatches) {
45810
- const lines = mismatches.map((m) => ` ${path20.basename(m.file)}: stamped ${m.stamped}, installed ${m.expected}`);
46867
+ const lines = mismatches.map((m) => ` ${path23.basename(m.file)}: stamped ${m.stamped}, installed ${m.expected}`);
45811
46868
  return "Schema cache is stale: regenerated wire schemas differ from the installed server. Refresh file-based client copies with `astra-mcp-server --dump-schemas <dir>` and restart memory-cached clients.\n" + lines.join("\n");
45812
46869
  }
45813
46870
 
@@ -45874,20 +46931,20 @@ function buildUsageText() {
45874
46931
  var CLI_USAGE_ERROR = 2;
45875
46932
  function initAllowedDirs(argv = process.argv) {
45876
46933
  const currentRoots = new Set(
45877
- (process.env.ASTRA_EXTRA_DOC_ROOTS || "").split(path21.delimiter).map((p) => p.trim()).filter(Boolean)
46934
+ (process.env.ASTRA_EXTRA_DOC_ROOTS || "").split(path24.delimiter).map((p) => p.trim()).filter(Boolean)
45878
46935
  );
45879
46936
  for (let i = 0; i < argv.length; i++) {
45880
46937
  if (argv[i] === "--") break;
45881
46938
  if (argv[i] === "--allowed-dir" && argv[i + 1] && !argv[i + 1].startsWith("-")) {
45882
- currentRoots.add(path21.resolve(argv[i + 1]));
46939
+ currentRoots.add(path24.resolve(argv[i + 1]));
45883
46940
  i++;
45884
46941
  } else if (argv[i].startsWith("--allowed-dir=")) {
45885
46942
  const value = argv[i].slice("--allowed-dir=".length).trim();
45886
- if (value && !value.startsWith("-")) currentRoots.add(path21.resolve(value));
46943
+ if (value && !value.startsWith("-")) currentRoots.add(path24.resolve(value));
45887
46944
  }
45888
46945
  }
45889
46946
  if (currentRoots.size > 0) {
45890
- process.env.ASTRA_EXTRA_DOC_ROOTS = Array.from(currentRoots).join(path21.delimiter);
46947
+ process.env.ASTRA_EXTRA_DOC_ROOTS = Array.from(currentRoots).join(path24.delimiter);
45891
46948
  }
45892
46949
  }
45893
46950
  function takeDirFlag(argv, flag) {
@@ -45953,6 +47010,16 @@ function createAstraMcpServer(store = createMcpSessionStore()) {
45953
47010
  },
45954
47011
  async (args) => toTextResult(await handleVisualInspect(store, args))
45955
47012
  );
47013
+ server.registerTool(
47014
+ "export_original_document",
47015
+ { description: GIT_HISTORY_DESCRIPTIONS.export_original_document, inputSchema: ExportOriginalDocumentSchema },
47016
+ async (args) => toTextResult(await GIT_HISTORY_HANDLERS.export_original_document(store, args))
47017
+ );
47018
+ server.registerTool(
47019
+ "restore_original_document",
47020
+ { description: GIT_HISTORY_DESCRIPTIONS.restore_original_document, inputSchema: RestoreOriginalDocumentSchema },
47021
+ async (args) => toTextResult(await GIT_HISTORY_HANDLERS.restore_original_document(store, args))
47022
+ );
45956
47023
  server.registerTool(
45957
47024
  "get_document_history",
45958
47025
  { description: GIT_HISTORY_DESCRIPTIONS.get_document_history, inputSchema: GetDocumentHistorySchema },
@@ -46080,7 +47147,7 @@ async function main(argv = process.argv) {
46080
47147
  if (openTimeout !== void 0) setOpenValidationTimeoutMs(Number(openTimeout));
46081
47148
  const dumpDir = takeDirFlag(argv, "--dump-schemas");
46082
47149
  if (dumpDir !== void 0) {
46083
- const resolved = path21.resolve(dumpDir);
47150
+ const resolved = path24.resolve(dumpDir);
46084
47151
  const result = generateSchemasToDir(resolved, SERVER_VERSION);
46085
47152
  process.stdout.write(`[${SERVER_NAME} v${SERVER_VERSION}] wrote ${result.count} schemas to ${resolved}
46086
47153
  `);
@@ -46088,7 +47155,7 @@ async function main(argv = process.argv) {
46088
47155
  }
46089
47156
  const checkDir = takeDirFlag(argv, "--check-schemas");
46090
47157
  if (checkDir !== void 0) {
46091
- const resolved = path21.resolve(checkDir);
47158
+ const resolved = path24.resolve(checkDir);
46092
47159
  const verdict = checkSchemasInDir(resolved, SERVER_VERSION);
46093
47160
  if (verdict.ok) {
46094
47161
  process.stdout.write(`[${SERVER_NAME} v${SERVER_VERSION}] schemas fresh (${verdict.checked} tools)