dsh-hashline-edit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. package/src/write-hook.ts +0 -130
package/src/edit-undo.ts CHANGED
@@ -1,6 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { withFileMutationQueue as piFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
- import { Type } from "typebox";
1
+ import { withFileMutationQueue as piFileMutationQueue } from "./host-utils.js";
4
2
  import {
5
3
  readUndo,
6
4
  writeUndo,
@@ -21,13 +19,14 @@ import {
21
19
  import { loadHashStore } from "./hash-store.js";
22
20
  import { sessionKeyFor } from "./served-session/session.js";
23
21
  import { readBytes, resolveTarget, writeAtomic } from "./fs-write.js";
22
+ import { pruneEmptyDirs, removeFile, renameFile } from "./fs-lifecycle.js";
24
23
  import { toCwd } from "./paths.js";
25
24
  import { DEFERRED_STORE_SYNC_WARNING } from "./constants.js";
26
25
  import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./edit-diff.js";
27
26
  import { visLines, splitLines, errCode } from "./utils.js";
28
- import { loadP, loadGuide } from "./prompts.js";
29
27
  import { buildMetrics, type EditDetails } from "./edit-response.js";
30
28
  import { DomainError, withPayloadSubject } from "./domain-errors.js";
29
+ import type { UndoLifecycle } from "./undo-store.js";
31
30
  import { ANCHOR_GENERATION, lineHashes } from "./hashline/hash-identity.js";
32
31
 
33
32
  /**
@@ -68,6 +67,16 @@ export interface UndoEntry {
68
67
  rawPre?: string | null;
69
68
  /** The anchor generation the stored hashes were derived under (0 = pre-generation). */
70
69
  anchorGeneration?: number | null;
70
+ /** The lifecycle action this entry reverses; absent/null is an ordinary edit. */
71
+ lifecycle?: UndoLifecycle | null;
72
+ /** Move: the absolute pre-transaction path. */
73
+ fromPath?: string | null;
74
+ /** Move: the absolute committed path. */
75
+ toPath?: string | null;
76
+ /** The raw post-transaction text when the commit wrote non-canonical bytes (pure move). */
77
+ rawPost?: string | null;
78
+ /** Add: directories the add created, outermost first. */
79
+ createdDirs?: readonly string[] | null;
71
80
  }
72
81
 
73
82
  export async function saveUndo(
@@ -89,6 +98,11 @@ export async function saveUndo(
89
98
  snapshotHash: entry.snapshotHash ?? snapshotHashFor(entry.content),
90
99
  transactionId: entry.transactionId ?? null,
91
100
  rawPre: entry.rawPre ?? null,
101
+ lifecycle: entry.lifecycle ?? null,
102
+ fromPath: entry.fromPath ?? null,
103
+ toPath: entry.toPath ?? null,
104
+ rawPost: entry.rawPost ?? null,
105
+ createdDirs: entry.createdDirs ?? null,
92
106
  // WHY: ADR-0031 §4 — forward the payload generation so the store stamp (upsertUndo)
93
107
  // WHY: can preserve it on replay; absent (ordinary edit) defaults to ANCHOR_GENERATION there.
94
108
  anchorGeneration: entry.anchorGeneration ?? ANCHOR_GENERATION,
@@ -121,7 +135,7 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
121
135
  await removeUndo(path);
122
136
  return undefined;
123
137
  }
124
- return {
138
+ const entry: UndoEntry = {
125
139
  content: record.content,
126
140
  bom: record.bom,
127
141
  originalEnding,
@@ -131,6 +145,15 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
131
145
  transactionId: record.transactionId ?? null,
132
146
  anchorGeneration: record.anchorGeneration ?? 0,
133
147
  };
148
+ if (record.lifecycle !== null && record.lifecycle !== undefined)
149
+ entry.lifecycle = record.lifecycle;
150
+ if (record.fromPath !== null && record.fromPath !== undefined) entry.fromPath = record.fromPath;
151
+ if (record.toPath !== null && record.toPath !== undefined) entry.toPath = record.toPath;
152
+ if (record.rawPost !== null && record.rawPost !== undefined) entry.rawPost = record.rawPost;
153
+ if (record.createdDirs !== null && record.createdDirs !== undefined) {
154
+ entry.createdDirs = record.createdDirs;
155
+ }
156
+ return entry;
134
157
  } catch (error) {
135
158
  // SAFETY: best-effort undo load — failures return undefined (no history) and caller reports "No undo history"; stale or corrupt store is recoverable on next edit, not silent undefined without log.
136
159
  reportDiagnostic("edit-undo", "Failed to load undo entry:", error);
@@ -162,6 +185,16 @@ interface UndoMember {
162
185
  rawPre: string | null;
163
186
  /** The anchor generation the stored hashes were derived under (0 = pre-generation). */
164
187
  anchorGeneration: number;
188
+ /** Lifecycle action this row reverses; `edit` is the back-compatible default. */
189
+ lifecycle: UndoLifecycle;
190
+ /** Move: absolute pre-transaction path. */
191
+ fromPath: string | null;
192
+ /** Move: absolute committed path. */
193
+ toPath: string | null;
194
+ /** The raw post bytes when the commit wrote a non-canonical image (pure move). */
195
+ rawPost: string | null;
196
+ /** Add: the directories the add created, outermost first. */
197
+ createdDirs: readonly string[];
165
198
  }
166
199
 
167
200
  function toMember(row: UndoRecord & { path: string }, displayPath: string): UndoMember | undefined {
@@ -178,6 +211,11 @@ function toMember(row: UndoRecord & { path: string }, displayPath: string): Undo
178
211
  snapshotHash: row.snapshotHash ?? null,
179
212
  rawPre: row.rawPre ?? null,
180
213
  anchorGeneration: row.anchorGeneration ?? 0,
214
+ lifecycle: (row.lifecycle as UndoLifecycle | null) ?? "edit",
215
+ fromPath: row.fromPath ?? null,
216
+ toPath: row.toPath ?? null,
217
+ rawPost: row.rawPost ?? null,
218
+ createdDirs: row.createdDirs ?? [],
181
219
  };
182
220
  }
183
221
 
@@ -236,6 +274,18 @@ async function undoCorrelatedTransaction(
236
274
  };
237
275
  }
238
276
 
277
+ // WHY: a row whose lifecycle is anything but `edit` reverses a namespace action (add/delete/
278
+ // WHY: move). Those need path-level restore steps, not `writeAtomic(pre)`; one lifecycle-aware
279
+ // WHY: executor handles every member of the batch (including the edit members beside them).
280
+ if (members.some((m) => m.lifecycle !== "edit")) {
281
+ return await undoLifecycleTransaction(
282
+ txnId,
283
+ requestedAbsolutePath,
284
+ requestedDisplayPath,
285
+ members,
286
+ ctx,
287
+ );
288
+ }
239
289
  const sessionKeyForUndo = sessionKeyFor(
240
290
  // SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
241
291
  ctx as unknown as { sessionManager?: { getSessionId(): string } },
@@ -563,6 +613,330 @@ async function undoCorrelatedTransaction(
563
613
  );
564
614
  }
565
615
 
616
+ /**
617
+ * Undo ONE correlated transaction that contains lifecycle members (apply_patch's Add/Delete/Move
618
+ * batch, possibly beside ordinary text edits). Same durable recipe as `undoCorrelatedTransaction`:
619
+ * validate every member by BYTES first, land the revert intent before the first write, complete a
620
+ * partially failed revert inline, fail closed with `E_UNDO_REVERT_FAILED` only when completion is
621
+ * itself defeated. The namespace steps are the only addition: an add undo removes the created
622
+ * file (and the empty directories it made), a delete undo re-creates it from the raw pre-image,
623
+ * and a move undo renames back before restoring the pre bytes.
624
+ */
625
+ async function undoLifecycleTransaction(
626
+ txnId: string,
627
+ requestedAbsolutePath: string,
628
+ requestedDisplayPath: string,
629
+ members: UndoMember[],
630
+ ctx: unknown,
631
+ ): Promise<UndoLastEditResult> {
632
+ const sessionKeyForUndo = sessionKeyFor(
633
+ // SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
634
+ ctx as unknown as { sessionManager?: { getSessionId(): string } },
635
+ );
636
+ const names = members.map((member) => member.displayPath).join(", ");
637
+ const fileExists = async (path: string): Promise<boolean> => {
638
+ try {
639
+ await readBytes(path);
640
+ return true;
641
+ } catch (error) {
642
+ if (errCode(error) === "ENOENT") return false;
643
+ throw error;
644
+ }
645
+ };
646
+ const stale = (member: UndoMember, reason: "deleted" | "modified"): UndoLastEditResult => ({
647
+ content: [
648
+ {
649
+ type: "text" as const,
650
+ text: new DomainError("E_UNDO_STALE", { path: member.displayPath, reason }).message,
651
+ },
652
+ ],
653
+ isError: true,
654
+ details: {},
655
+ });
656
+
657
+ return withSortedMutationQueues(
658
+ members.map((member) => member.absolutePath),
659
+ async () => {
660
+ // WHY: STEP 1 — validate EVERY member before reverting ANY. A delete row's post state
661
+ // WHY: is "absent"; every other row's post state is its bytes. A move's pre path must not exist.
662
+ for (const member of members) {
663
+ const committedPath =
664
+ member.lifecycle === "move"
665
+ ? (member.toPath ?? member.absolutePath)
666
+ : member.absolutePath;
667
+ if (member.lifecycle === "delete") {
668
+ if (await fileExists(committedPath)) return stale(member, "modified");
669
+ continue;
670
+ }
671
+ let bytes: Buffer;
672
+ try {
673
+ bytes = await readBytes(committedPath);
674
+ } catch (error) {
675
+ if (errCode(error) === "ENOENT") return stale(member, "deleted");
676
+ throw error;
677
+ }
678
+ const expectedPost =
679
+ member.rawPost !== null
680
+ ? Buffer.from(member.rawPost, "utf-8")
681
+ : Buffer.from(
682
+ member.bom + restoreEndings(member.resultContent, member.ending),
683
+ "utf-8",
684
+ );
685
+ if (!bytes.equals(expectedPost)) return stale(member, "modified");
686
+ if (member.lifecycle === "move" && member.fromPath !== null) {
687
+ if (await fileExists(member.fromPath)) return stale(member, "modified");
688
+ }
689
+ }
690
+
691
+ // WHY: STEP 2 — prepare every restore IN MEMORY (no write side touched yet).
692
+ interface RestorePlan {
693
+ member: UndoMember;
694
+ restorePath: string;
695
+ restoreBytes: Buffer | null;
696
+ removeOnly: boolean;
697
+ moveFrom: string | null;
698
+ moveTo: string | null;
699
+ dirs: readonly string[];
700
+ content: string;
701
+ currentNormalized: string;
702
+ diff: string;
703
+ servedRows: ServedRow[];
704
+ restoredContentHash: string;
705
+ restoredLineCount: number;
706
+ restoredHashes: string[];
707
+ displacedAnchors: string[];
708
+ }
709
+ const plans: RestorePlan[] = [];
710
+ for (const member of members) {
711
+ const isDelete = member.lifecycle === "delete";
712
+ const isAdd = member.lifecycle === "add";
713
+ const isMove = member.lifecycle === "move";
714
+ const committedPath = isMove ? (member.toPath ?? member.absolutePath) : member.absolutePath;
715
+ const restorePath = isMove ? (member.fromPath ?? member.absolutePath) : member.absolutePath;
716
+ const currentNormalized = isDelete ? "" : member.resultContent;
717
+ const content = isDelete || isMove || !isAdd ? member.content : "";
718
+ const storedIsCurrentGeneration = member.anchorGeneration === ANCHOR_GENERATION;
719
+ const restoredContentHash =
720
+ storedIsCurrentGeneration && member.lifecycle !== "add"
721
+ ? (member.snapshotHash ?? snapshotHashFor(content))
722
+ : snapshotHashFor(content);
723
+ const restoredLineCount = visLines(content).length;
724
+ let restoredHashes: string[] = [];
725
+ if (!isAdd) {
726
+ try {
727
+ restoredHashes = await restoreHashes(content, restorePath);
728
+ } catch (error) {
729
+ reportDiagnostic("edit-undo", "Failed to derive lifecycle restore anchors:", error);
730
+ restoredHashes = [];
731
+ }
732
+ }
733
+ const currentHashes = isAdd
734
+ ? await restoreHashes(member.resultContent, committedPath)
735
+ : isDelete
736
+ ? []
737
+ : await restoreHashes(member.resultContent, restorePath);
738
+ const undoDenseRows = denseServeRows(restoredHashes);
739
+ const curSet = new Set(currentHashes);
740
+ const restoredSet = new Set(restoredHashes);
741
+ const displacedAnchors = [...curSet].filter((hash) => !restoredSet.has(hash));
742
+ const undoDiff = isAdd
743
+ ? genDiff(currentNormalized, "", 1, [], currentHashes).diff
744
+ : isDelete
745
+ ? genDiff("", content, 1, restoredHashes, []).diff
746
+ : genDiff(currentNormalized, content, 1, restoredHashes, currentHashes).diff;
747
+ const restoreBytes = isAdd
748
+ ? null
749
+ : member.rawPre !== null
750
+ ? Buffer.from(member.rawPre, "utf-8")
751
+ : Buffer.from(member.bom + restoreEndings(content, member.ending), "utf-8");
752
+ plans.push({
753
+ member,
754
+ restorePath,
755
+ restoreBytes,
756
+ removeOnly: isAdd,
757
+ moveFrom: isMove ? restorePath : null,
758
+ moveTo: isMove ? committedPath : null,
759
+ dirs: member.createdDirs,
760
+ content,
761
+ currentNormalized,
762
+ diff: undoDiff,
763
+ servedRows: undoDenseRows,
764
+ restoredContentHash,
765
+ restoredLineCount,
766
+ restoredHashes,
767
+ displacedAnchors,
768
+ });
769
+ }
770
+
771
+ // WHY: STEP 3 — durable revert intent BEFORE the first write.
772
+ await saveCutIntent(txnId, requestedAbsolutePath, "revert");
773
+ const undoCtx = ctx as { onBeforeUndoWrites?: () => void | Promise<void> };
774
+ if (undoCtx?.onBeforeUndoWrites) await undoCtx.onBeforeUndoWrites();
775
+
776
+ // WHY: STEP 4 — apply the restore steps; a failed step first COMPLETES the remaining members.
777
+ const applyRestore = async (plan: RestorePlan): Promise<void> => {
778
+ if (plan.removeOnly) {
779
+ if (await fileExists(plan.restorePath)) await removeFile(plan.restorePath);
780
+ await pruneEmptyDirs(plan.dirs);
781
+ return;
782
+ }
783
+ if (plan.moveFrom !== null && plan.moveTo !== null) {
784
+ if (await fileExists(plan.moveTo)) await renameFile(plan.moveTo, plan.moveFrom);
785
+ }
786
+ if (plan.restoreBytes !== null) await writeAtomic(plan.restorePath, plan.restoreBytes);
787
+ };
788
+ const reverted = new Set<string>();
789
+ let defeated: UndoMember | undefined;
790
+ for (const plan of plans) {
791
+ try {
792
+ await applyRestore(plan);
793
+ reverted.add(plan.member.absolutePath);
794
+ } catch (error) {
795
+ reportDiagnostic(
796
+ "edit-undo",
797
+ "Lifecycle undo write failed; completing the revert:",
798
+ error,
799
+ );
800
+ for (const rest of plans) {
801
+ if (reverted.has(rest.member.absolutePath)) continue;
802
+ try {
803
+ await applyRestore(rest);
804
+ reverted.add(rest.member.absolutePath);
805
+ } catch (completionError) {
806
+ reportDiagnostic(
807
+ "edit-undo",
808
+ "Failed to complete the interrupted lifecycle revert:",
809
+ completionError,
810
+ );
811
+ defeated ??= rest.member;
812
+ }
813
+ }
814
+ break;
815
+ }
816
+ }
817
+ if (defeated !== undefined) {
818
+ return {
819
+ content: [
820
+ {
821
+ type: "text" as const,
822
+ text: new DomainError("E_UNDO_REVERT_FAILED", { path: defeated.displayPath }).message,
823
+ },
824
+ ],
825
+ isError: true,
826
+ details: {},
827
+ };
828
+ }
829
+
830
+ // WHY: STEP 5 — store commit for every restored file (a removed add owns no snapshot).
831
+ const deferredSyncWarnings: string[] = [];
832
+ const servedRowsAll: ServedRow[] = [];
833
+ const sections: { plan: RestorePlan }[] = [];
834
+ for (const plan of plans) {
835
+ if (plan.removeOnly) {
836
+ sections.push({ plan });
837
+ continue;
838
+ }
839
+ try {
840
+ await adoptPinnedSnapshotFor(
841
+ {
842
+ path: plan.restorePath,
843
+ snapshotHash: plan.restoredContentHash,
844
+ lineCount: plan.restoredLineCount,
845
+ hashes: plan.restoredHashes,
846
+ content: plan.content,
847
+ },
848
+ {
849
+ retireLeases: true,
850
+ leases: { sessionKey: sessionKeyForUndo, rows: plan.servedRows },
851
+ servedMirror: {
852
+ sessionKey: sessionKeyForUndo,
853
+ rows: plan.servedRows,
854
+ shape: { lineCount: plan.restoredLineCount, clearFrom: 0 },
855
+ retireAnchors: plan.displacedAnchors,
856
+ },
857
+ },
858
+ );
859
+ } catch (error) {
860
+ reportDiagnostic("edit-undo", "Failed to commit the lifecycle undo restore:", error);
861
+ deferredSyncWarnings.push(DEFERRED_STORE_SYNC_WARNING);
862
+ }
863
+ servedRowsAll.push(...plan.servedRows);
864
+ sections.push({ plan });
865
+ }
866
+
867
+ await clearUndoTransaction(txnId);
868
+ try {
869
+ await dropCutIntent(txnId);
870
+ } catch (error) {
871
+ reportDiagnostic("edit-undo", "Failed to clear the revert intent after commit:", error);
872
+ }
873
+
874
+ const parts: string[] = ["Undone last edit on " + names + "."];
875
+ for (const { plan } of sections) {
876
+ switch (plan.member.lifecycle) {
877
+ case "add":
878
+ parts.push("Removed the created file " + plan.member.displayPath + ".");
879
+ break;
880
+ case "delete":
881
+ parts.push("Restored the deleted file " + plan.member.displayPath + ".");
882
+ break;
883
+ case "move":
884
+ parts.push(
885
+ "Moved " +
886
+ (plan.member.toPath ?? plan.member.absolutePath) +
887
+ " back to " +
888
+ plan.restorePath +
889
+ ".",
890
+ );
891
+ break;
892
+ default:
893
+ break;
894
+ }
895
+ }
896
+ parts.push(
897
+ deferredSyncWarnings.length > 0
898
+ ? "Files reverted; store synchronization is deferred (see the warning below)."
899
+ : "Files reverted; diff rows carry fresh anchors for follow-up edits.",
900
+ );
901
+ parts.push(...deferredSyncWarnings);
902
+
903
+ const totalAdded = plans.reduce(
904
+ (total, plan) =>
905
+ total +
906
+ Math.max(0, visLines(plan.content).length - visLines(plan.currentNormalized).length),
907
+ 0,
908
+ );
909
+ const totalRemoved = plans.reduce(
910
+ (total, plan) =>
911
+ total +
912
+ Math.max(0, visLines(plan.currentNormalized).length - visLines(plan.content).length),
913
+ 0,
914
+ );
915
+ const details: EditDetails = {
916
+ diff: sections
917
+ .map(({ plan }) => "--- " + plan.restorePath + " ---\n" + plan.diff)
918
+ .join("\n"),
919
+ resultLineCount: visLines(plans[0]!.content).length,
920
+ servedRows: servedRowsAll,
921
+ contentHash: plans[0]!.restoredContentHash,
922
+ ...(deferredSyncWarnings.length > 0 ? { warnings: deferredSyncWarnings } : {}),
923
+ metrics: buildMetrics({
924
+ classification: "applied",
925
+ editsAttempted: plans.length,
926
+ noopEditsCount: 0,
927
+ warningsCount: deferredSyncWarnings.length,
928
+ addedLines: totalAdded,
929
+ removedLines: totalRemoved,
930
+ }),
931
+ };
932
+ return {
933
+ content: [{ type: "text" as const, text: parts.join("\n") }],
934
+ details,
935
+ };
936
+ },
937
+ );
938
+ }
939
+
566
940
  /**
567
941
  * Execution facts one host supplies for the undo use case: the directory relative `path` resolves
568
942
  * against, and the session whose served anchors the restore re-leases.
@@ -884,23 +1258,3 @@ export async function executeUndoLastEdit(
884
1258
  throw withPayloadSubject(error, "undo_last_edit");
885
1259
  }
886
1260
  }
887
-
888
- export function regEditUndo(pi: ExtensionAPI): void {
889
- pi.registerTool({
890
- name: "undo_last_edit",
891
- label: "Undo Last Edit",
892
- description: loadP("../prompts/undo-last-edit.md"),
893
- promptSnippet: loadP("../prompts/undo-last-edit-snippet.md"),
894
- promptGuidelines: loadGuide("../prompts/undo-last-edit-guidelines.md"),
895
- parameters: Type.Object({
896
- path: Type.String({
897
- description: "Path to the file to undo",
898
- }),
899
- }),
900
-
901
- // WHY: the registration owns only the pi wire shape; the use case lives in the host-agnostic seam.
902
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
903
- return executeUndoLastEdit(params, ctx as unknown as UndoLastEditContext);
904
- },
905
- });
906
- }
@@ -288,6 +288,40 @@ async function loadFileKindAndTextViaIo(
288
288
  };
289
289
  }
290
290
 
291
+ /**
292
+ * Decode a whole file image as strict UTF-8, stripping a UTF-8 BOM.
293
+ *
294
+ * WHY this helper exists after the multi-encoding governance was removed: the host
295
+ * layer reads files as UTF-8 only, but it still has to name a non-UTF-8 target as
296
+ * such instead of handing the model replacement characters. A fatal decode is the
297
+ * admission test — the bytes are either UTF-8 or they are refused here, and the
298
+ * UTF-8 BOM is the one BOM the text keeps meaning for, stripped so it never
299
+ * reaches a line anchor.
300
+ *
301
+ * The refusal is a model-facing fact plus the next action (the user decides how to
302
+ * convert the file): the message names the path and tells the model to ask.
303
+ * @param path - the model-supplied path, used verbatim in the refusal.
304
+ * @returns the decoded text without a BOM character.
305
+ * @throws DomainError `E_UNSUPPORTED_FILE` when the bytes are not valid UTF-8.
306
+ */
307
+ export function decodeUtf8Strict(bytes: Uint8Array, path: string): string {
308
+ // WHY: the leading-BOM skip below and `TextDecoder`'s own BOM rule agree, so a
309
+ // WHY: UTF-8 BOM can never reach the first line's anchor text.
310
+ const payload =
311
+ bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
312
+ ? bytes.subarray(3)
313
+ : bytes;
314
+ try {
315
+ return new TextDecoder("utf-8", { fatal: true }).decode(payload);
316
+ } catch {
317
+ throw new DomainError("E_UNSUPPORTED_FILE", {
318
+ path,
319
+ kind: "binary",
320
+ description:
321
+ "not UTF-8 text; this build reads and writes UTF-8 only — ask the user how to handle this file",
322
+ });
323
+ }
324
+ }
291
325
  /**
292
326
  * The non-printable control characters of the I2 byte-view notice (spec §6): C0 except the three
293
327
  * text whitespaces (\t, \n, \r) that render as layout, plus DEL and the C1 block. These are the
@@ -32,10 +32,10 @@ import {
32
32
  } from "./preview.js";
33
33
  import { findStatSnapshotFor } from "../snapshot-store";
34
34
  import type { ServedRow } from "../served-session/serve-render.js";
35
- import type { TruncationResult } from "@earendil-works/pi-coding-agent";
35
+ import type { TruncationResult } from "../host-utils.js";
36
36
 
37
37
  export type { FileStats, LFile, LoadFileOptions } from "./detection.js";
38
- export { loadFileKindAndText } from "./detection.js";
38
+ export { decodeUtf8Strict, loadFileKindAndText } from "./detection.js";
39
39
  export {
40
40
  readNormFile,
41
41
  decodeNormText,
@@ -4,7 +4,7 @@ import {
4
4
  DEFAULT_MAX_LINES,
5
5
  DEFAULT_MAX_BYTES,
6
6
  type TruncationResult,
7
- } from "@earendil-works/pi-coding-agent";
7
+ } from "../host-utils.js";
8
8
  import { MAX_READ_WINDOWS, MAX_READ_WINDOWS_MESSAGE } from "../constants.js";
9
9
  import { DomainError } from "../domain-errors.js";
10
10
  import {
@@ -5,7 +5,7 @@
5
5
  * WHY: one result. Both builders live here, so a header's shape has ONE definition site and the footer
6
6
  * WHY: they replaced cannot come back from a call site.
7
7
  */
8
- import { formatSize } from "@earendil-works/pi-coding-agent";
8
+ import { formatSize } from "../host-utils.js";
9
9
 
10
10
  /** The lines a page rendered, 1-based and inclusive. */
11
11
  interface RenderedRange {