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
@@ -85,12 +85,16 @@ import { saveCutIntent, dropCutIntent } from "../undo-store.js";
85
85
  import { repairCutIntents } from "../cut-repair.js";
86
86
  import { withSortedMutationQueues } from "../mutation-queue.js";
87
87
  import { resolveTarget, writeAtomic, readBytes } from "../fs-write.js";
88
+ import {
89
+ missingAncestorDirs,
90
+ pathKind,
91
+ pruneEmptyDirs,
92
+ removeFile,
93
+ renameFile,
94
+ assertPathWithinWorkspace,
95
+ } from "../fs-lifecycle.js";
88
96
  import { toCwd } from "../paths.js";
89
- import type {
90
- DesiredContent,
91
- NormalizedEditItem,
92
- NormalizedEditRequest,
93
- } from "../payload-contract.js";
97
+ import type { DesiredContent, NormalizedEditItem, NormalizedEditRequest } from "./edit-request.js";
94
98
  import {
95
99
  batchAbortFor,
96
100
  batchAbortForMany,
@@ -99,7 +103,13 @@ import {
99
103
  wrapParseFailure,
100
104
  } from "../edit-response.js";
101
105
  import { DEFERRED_STORE_SYNC_WARNING } from "../constants.js";
102
- import type { PipelineOptions, ProcessedEditFile, ForeignCutRecord } from "./types.js";
106
+ import type {
107
+ PipelineOptions,
108
+ ProcessedEditFile,
109
+ ForeignCutRecord,
110
+ MemberLifecycle,
111
+ TransactionRecoveryReport,
112
+ } from "./types.js";
103
113
  import {
104
114
  loadEditFile,
105
115
  loadForeignServedView,
@@ -365,7 +375,11 @@ function replacementTextForPayload(payload: DesiredContent): string {
365
375
  */
366
376
  type ParsedItem = { edit: HEdit } | { noopInsert: true };
367
377
 
368
- function parseEdits(items: NormalizedEditRequest["edits"], path: string): ParsedItem[] {
378
+ function parseEdits(
379
+ items: NormalizedEditRequest["edits"],
380
+ path: string,
381
+ itemNumbers?: readonly number[],
382
+ ): ParsedItem[] {
369
383
  const parsed: ParsedItem[] = [];
370
384
  // WHY: payload parsing aggregates like the span gate — one malformed item must not mask another,
371
385
  // WHY: so the model fixes every malformed item in a single resubmission. Parsing is pure (no
@@ -373,6 +387,9 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
373
387
  const failures: { error: Error; index: number }[] = [];
374
388
  for (let index = 0; index < items.length; index++) {
375
389
  const item = items[index]!;
390
+ // WHY: the number the CALLER submitted — a per-file plan keeps its items' global positions,
391
+ // WHY: so a multi-file refusal still names the item the model sent, not a per-file 0.
392
+ const number = itemNumbers?.[index] ?? index;
376
393
  try {
377
394
  const payload = item.payload;
378
395
  let text: string;
@@ -388,7 +405,7 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
388
405
  if (payload.text === "") {
389
406
  throw new DomainError("E_INTERNAL_INVARIANT", {
390
407
  message:
391
- `Edit request edits[${index}] "text" must carry at least one line; ` +
408
+ `Edit request edits[${number}] "text" must carry at least one line; ` +
392
409
  `"text": "" is the deletion payload. Nothing was written.`,
393
410
  });
394
411
  }
@@ -438,7 +455,10 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
438
455
  });
439
456
  } catch (error) {
440
457
  if (items.length === 1) throw error;
441
- failures.push({ error: error instanceof Error ? error : new Error(String(error)), index });
458
+ failures.push({
459
+ error: error instanceof Error ? error : new Error(String(error)),
460
+ index: number,
461
+ });
442
462
  }
443
463
  }
444
464
  if (failures.length === 1) {
@@ -716,6 +736,7 @@ async function runMutations(
716
736
  request: NormalizedEditRequest,
717
737
  cwd: string,
718
738
  options?: PipelineOptions,
739
+ itemNumbers?: readonly number[],
719
740
  ): Promise<ProcessedEditFile> {
720
741
  // WHY: the file was answered at admission (assertReq); the narrowed type carries it here.
721
742
  const path = request.file;
@@ -748,12 +769,19 @@ async function runMutations(
748
769
 
749
770
  const isPreview = options?.noPersist === true;
750
771
 
751
- const parsed = parseEdits(items, path);
772
+ const parsed = parseEdits(items, path, itemNumbers);
752
773
  // WHY: (ticket-04 §3.6) noop-insert markers mutate nothing, so they are not part of the batch
753
774
  // WHY: span gate — the gate compares regions the call will rewrite.
754
775
  const realEdits: HEdit[] = [];
755
- for (const one of parsed) {
756
- if ("edit" in one) realEdits.push(one.edit);
776
+ // WHY: the gate reports failures by item number, and a noop-insert marker is not one of its items,
777
+ // WHY: so the caller-facing numbers are filtered in lockstep with the edits it does compare.
778
+ const realNumbers: number[] = [];
779
+ for (let index = 0; index < parsed.length; index++) {
780
+ const one = parsed[index]!;
781
+ if ("edit" in one) {
782
+ realEdits.push(one.edit);
783
+ realNumbers.push(itemNumbers?.[index] ?? index);
784
+ }
757
785
  }
758
786
 
759
787
  const {
@@ -788,6 +816,9 @@ async function runMutations(
788
816
  // WHY: compares is the `s'_k` the apply path would rewrite — one source for both (spec §3.2.1).
789
817
  await assertBatchSpansDisjoint(realEdits, {
790
818
  served,
819
+ // WHY: the caller's own item numbers, parallel to `realEdits` — a per-file plan keeps its
820
+ // WHY: items' global positions, so a gate refusal names the item the model sent.
821
+ itemNumbers: realNumbers,
791
822
  identity: leaseSpanSource({
792
823
  store: hashStore,
793
824
  sessionKey,
@@ -864,6 +895,7 @@ async function runMutations(
864
895
  abortIf(options?.signal);
865
896
  const item = items[index]!;
866
897
  const parsedItem = parsed[index]!;
898
+ const number = itemNumbers?.[index] ?? index;
867
899
  if ("noopInsert" in parsedItem) {
868
900
  // WHY: (ticket-04 §3.6) an empty "before"/"after" writes nothing by construction: bytes
869
901
  // WHY: unchanged, counted as a noop, model channel warned. It skips `applyEdit` AND the
@@ -871,7 +903,7 @@ async function runMutations(
871
903
  noopCount += 1;
872
904
  warnings.push(
873
905
  formatWarning("W_NOOP_INSERT", {
874
- ref: `edit[${index}] (${path})`,
906
+ ref: `edit[${number}] (${path})`,
875
907
  removeFrom: item.target.anchor_from,
876
908
  removeTo: item.target.anchor_to,
877
909
  }),
@@ -899,7 +931,7 @@ async function runMutations(
899
931
  ...(isPreview ? {} : { currentIds }),
900
932
  onRejected: async (error) => {
901
933
  if (items.length === 1) throw error;
902
- throw batchAbortFor({ error, index, path });
934
+ throw batchAbortFor({ error, index: number, path });
903
935
  },
904
936
  });
905
937
 
@@ -932,7 +964,7 @@ async function runMutations(
932
964
  removeFrom: item.target.anchor_from,
933
965
  removeTo: item.target.anchor_to,
934
966
  replacementText: replacementTextForPayload(item.payload),
935
- ref: `edit[${index}] (${path})`,
967
+ ref: `edit[${number}] (${path})`,
936
968
  batch: items.length > 1,
937
969
  range,
938
970
  hashes: currentHashes,
@@ -947,12 +979,12 @@ async function runMutations(
947
979
  // WHY: calls keep the direct rejection path.
948
980
  if (decision.action === "reject") {
949
981
  if (items.length === 1) throw decision.error;
950
- throw batchAbortFor({ error: decision.error, index, path });
982
+ throw batchAbortFor({ error: decision.error, index: number, path });
951
983
  }
952
984
  if (decision.action === "warn") warnings.push(decision.notice);
953
985
  if (items.length > 1) {
954
986
  warnings.push(
955
- `edit[${index}] (${path}) was a noop: the range already contains the replacement text.`,
987
+ `edit[${number}] (${path}) was a noop: the range already contains the replacement text.`,
956
988
  );
957
989
  }
958
990
  pushNoopWarnings(outcome.anchorWarnings);
@@ -1260,7 +1292,141 @@ async function runCutTransaction(
1260
1292
  );
1261
1293
  }
1262
1294
 
1263
- const plans = [file, ...sourcePlans];
1295
+ // WHY: the cut path is the correlated transaction with the source plans derived from this file's
1296
+ // WHY: own `foreignCuts`; the multi-file path derives them from the same records across every
1297
+ // WHY: target. One skeleton owns the pre-images, undo rows, intent, ordered renames, rollback,
1298
+ // WHY: store commit and the single response (ADR-0028).
1299
+ return await runCorrelatedTransaction(
1300
+ [file, ...sourcePlans],
1301
+ [file, ...sourcePlans],
1302
+ options,
1303
+ sessionKey,
1304
+ );
1305
+ }
1306
+
1307
+ /**
1308
+ * The correlated multi-file commit: the one skeleton both the foreign-cut path and a multi-target
1309
+ * edit run.
1310
+ *
1311
+ * Steps, in order: durable pre-images for every written file → correlated undo rows → the intent
1312
+ * record → ordered renames (primary first) → best-effort rollback on a failed write → the intent
1313
+ * dropped → one store commit per written file → ONE response whose sections cover every file.
1314
+ *
1315
+ * @param writePlans - the plans to write, in commit order. `writePlans[0]` is the primary: it is
1316
+ * renamed first and its undo row is the intent's target row.
1317
+ * @param sectionPlans - every plan the response reports, including the ones that applied nothing
1318
+ * (those are reported and never committed).
1319
+ * @returns the batch outcome, anchored on the primary file.
1320
+ */
1321
+ function memberLifecycleOf(plan: ProcessedEditFile): MemberLifecycle {
1322
+ return plan.lifecycle ?? { kind: "keep" };
1323
+ }
1324
+
1325
+ function memberPostBytes(plan: ProcessedEditFile): Buffer {
1326
+ return Buffer.from(plan.bom + restoreEndings(plan.result, plan.originalEnding), "utf-8");
1327
+ }
1328
+
1329
+ /**
1330
+ * Publish one already-validated member. This is the only place a lifecycle action reaches the
1331
+ * filesystem: content writes keep using writeAtomic (one serializer); add/delete/move use the
1332
+ * fs-lifecycle primitives that writeAtomic deliberately does not express.
1333
+ */
1334
+ async function commitMember(
1335
+ plan: ProcessedEditFile,
1336
+ rawPreBytes: ReadonlyMap<string, Buffer>,
1337
+ onPublished: (plan: ProcessedEditFile) => void,
1338
+ workspaceRoot: string | undefined,
1339
+ onAfterMoveRename?: (toAbsolutePath: string) => void | Promise<void>,
1340
+ ): Promise<void> {
1341
+ const lifecycle = memberLifecycleOf(plan);
1342
+ switch (lifecycle.kind) {
1343
+ case "keep":
1344
+ case "add":
1345
+ if (workspaceRoot !== undefined) {
1346
+ await assertPathWithinWorkspace(workspaceRoot, plan.absolutePath);
1347
+ }
1348
+ await writeAtomic(plan.absolutePath, memberPostBytes(plan));
1349
+ onPublished(plan);
1350
+ return;
1351
+ case "delete":
1352
+ await removeFile(plan.absolutePath);
1353
+ onPublished(plan);
1354
+ return;
1355
+ case "move": {
1356
+ // WHY: rename first, then (only for an edited move) rewrite the destination. A pure move
1357
+ // WHY: must stay a rename: re-serializing would launder the file's raw line endings.
1358
+ await renameFile(
1359
+ lifecycle.fromAbsolutePath,
1360
+ lifecycle.toAbsolutePath,
1361
+ undefined,
1362
+ workspaceRoot,
1363
+ );
1364
+ // WHY: the rename is already a publication: a later content-write failure must roll this
1365
+ // WHY: member back or the source disappears while the failure claims nothing changed.
1366
+ onPublished(plan);
1367
+ if (onAfterMoveRename !== undefined) await onAfterMoveRename(lifecycle.toAbsolutePath);
1368
+ const post = memberPostBytes(plan);
1369
+ // WHY: a pure rename must not re-serialize: the normalized text equals the baseline text,
1370
+ // WHY: so the destination keeps the exact bytes the OS rename carried.
1371
+ if (
1372
+ plan.originalNormalized !== plan.result &&
1373
+ !post.equals(rawPreBytes.get(plan.absolutePath)!)
1374
+ ) {
1375
+ await writeAtomic(lifecycle.toAbsolutePath, post);
1376
+ }
1377
+ return;
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Restore one member that the commit had already published. A rollback is a BYTE restore, never
1384
+ * a re-serialization: keep/delete write the captured pre-image buffer back; add removes what it
1385
+ * created (and the empty directories it made); move renames back first, then restores the bytes.
1386
+ */
1387
+ async function rollbackMember(
1388
+ plan: ProcessedEditFile,
1389
+ rawPreBytes: ReadonlyMap<string, Buffer>,
1390
+ workspaceRoot: string | undefined,
1391
+ ): Promise<void> {
1392
+ const lifecycle = memberLifecycleOf(plan);
1393
+ const pre = rawPreBytes.get(plan.absolutePath)!;
1394
+ switch (lifecycle.kind) {
1395
+ case "add":
1396
+ if ((await pathKind(plan.absolutePath)) !== "absent") await removeFile(plan.absolutePath);
1397
+ await pruneEmptyDirs(lifecycle.createdDirs);
1398
+ return;
1399
+ case "delete":
1400
+ await writeAtomic(plan.absolutePath, pre);
1401
+ return;
1402
+ case "move": {
1403
+ if ((await pathKind(lifecycle.toAbsolutePath)) !== "absent") {
1404
+ await renameFile(
1405
+ lifecycle.toAbsolutePath,
1406
+ lifecycle.fromAbsolutePath,
1407
+ undefined,
1408
+ workspaceRoot,
1409
+ );
1410
+ }
1411
+ // WHY: the rename carries the pre bytes for a pure move and for a failed content write; only
1412
+ // WHY: rewrite when the destination really holds different bytes (a completed edit rolled back).
1413
+ if (!(await readBytes(lifecycle.fromAbsolutePath)).equals(pre)) {
1414
+ await writeAtomic(lifecycle.fromAbsolutePath, pre);
1415
+ }
1416
+ await pruneEmptyDirs(lifecycle.createdDirs);
1417
+ return;
1418
+ }
1419
+ case "keep":
1420
+ await writeAtomic(plan.absolutePath, pre);
1421
+ }
1422
+ }
1423
+ async function runCorrelatedTransaction(
1424
+ writePlans: ProcessedEditFile[],
1425
+ sectionPlans: ProcessedEditFile[],
1426
+ options: PipelineOptions | undefined,
1427
+ sessionKey: string,
1428
+ ): Promise<BatchOutcome> {
1429
+ const primary = writePlans[0]!;
1264
1430
  const txnId = randomUUID();
1265
1431
 
1266
1432
  // WHY: STEP 1 durable pre-images: undo rows for EVERY file (pre bytes + post bytes + the
@@ -1270,21 +1436,63 @@ async function runCutTransaction(
1270
1436
  // WHY: first rename. Byte identity is not promised by this read, it is PROVEN upstream: the
1271
1437
  // WHY: admission round-trip guard refused any file whose decoded text re-encodes differently,
1272
1438
  // WHY: so decode/encode here is lossless by construction, stray line-break spellings included
1273
- // WHY: (ticket-04b §11: after an abort BOTH files are byte-identical to their pre bytes).
1439
+ // WHY: (ticket-04b §11: after an abort EVERY file is byte-identical to its pre bytes).
1274
1440
  const rawPreBytes = new Map<string, Buffer>();
1275
- for (const plan of plans) {
1441
+ for (const plan of writePlans) {
1442
+ const lifecycle = plan.lifecycle;
1443
+ if (lifecycle?.kind === "add") {
1444
+ // WHY: an add has no pre-image by construction. A dry buffer keeps every consumer of the
1445
+ // WHY: map total (rollback reads it, undo carries it as NULL through its lifecycle arm).
1446
+ rawPreBytes.set(plan.absolutePath, Buffer.alloc(0));
1447
+ plan.lifecycle = { ...lifecycle, createdDirs: await missingAncestorDirs(plan.absolutePath) };
1448
+ continue;
1449
+ }
1450
+ if (lifecycle?.kind === "move") {
1451
+ rawPreBytes.set(plan.absolutePath, await readBytes(lifecycle.fromAbsolutePath));
1452
+ plan.lifecycle = {
1453
+ ...lifecycle,
1454
+ createdDirs: await missingAncestorDirs(lifecycle.toAbsolutePath),
1455
+ };
1456
+ continue;
1457
+ }
1276
1458
  rawPreBytes.set(plan.absolutePath, await readBytes(plan.absolutePath));
1277
1459
  }
1278
1460
  const saved: { restore: () => Promise<void> }[] = [];
1279
- for (const plan of plans) {
1461
+ for (const plan of writePlans) {
1462
+ const lifecycle = plan.lifecycle;
1463
+ const isAdd = lifecycle?.kind === "add";
1464
+ const isDelete = lifecycle?.kind === "delete";
1465
+ const isMove = lifecycle?.kind === "move";
1466
+ const rawPost = Buffer.from(
1467
+ plan.bom + restoreEndings(plan.result, plan.originalEnding),
1468
+ "utf-8",
1469
+ );
1470
+ // WHY: a pure move carries the raw pre bytes to the destination; recording the canonical
1471
+ // WHY: fold as its post image would make undo's byte oracle reject a legal mixed-ending file.
1472
+ const storedRawPost =
1473
+ isMove && plan.originalNormalized === plan.result
1474
+ ? rawPreBytes.get(plan.absolutePath)!
1475
+ : rawPost;
1280
1476
  const undo = await saveUndo(plan.absolutePath, {
1281
- content: plan.originalNormalized,
1477
+ content: isAdd ? "" : plan.originalNormalized,
1282
1478
  bom: plan.bom,
1283
1479
  originalEnding: plan.originalEnding,
1284
1480
  hashes: plan.originalHashes,
1285
- resultContent: plan.result,
1481
+ resultContent: isDelete ? "" : plan.result,
1286
1482
  transactionId: txnId,
1287
- rawPre: rawPreBytes.get(plan.absolutePath)!.toString("utf-8"),
1483
+ rawPre: isAdd ? null : rawPreBytes.get(plan.absolutePath)!.toString("utf-8"),
1484
+ ...(isMove
1485
+ ? {
1486
+ lifecycle: "move" as const,
1487
+ fromPath: lifecycle.fromAbsolutePath,
1488
+ toPath: lifecycle.toAbsolutePath,
1489
+ rawPost: storedRawPost.toString("utf-8"),
1490
+ }
1491
+ : isAdd
1492
+ ? { lifecycle: "add" as const, createdDirs: lifecycle.createdDirs }
1493
+ : isDelete
1494
+ ? { lifecycle: "delete" as const }
1495
+ : { lifecycle: "edit" as const }),
1288
1496
  });
1289
1497
  if (!undo.persisted) {
1290
1498
  for (const prior of saved) await prior.restore();
@@ -1293,17 +1501,17 @@ async function runCutTransaction(
1293
1501
  saved.push(undo);
1294
1502
  }
1295
1503
  try {
1296
- await saveCutIntent(txnId, file.absolutePath);
1504
+ await saveCutIntent(txnId, primary.absolutePath);
1297
1505
  } catch (error) {
1298
1506
  for (const prior of saved) await prior.restore();
1299
1507
  throw error;
1300
1508
  }
1301
1509
 
1302
- // WHY: STEP 2 ordered commit: the target insert first, then each source retirement, using the
1303
- // WHY: raw pre-images captured in STEP 1 above. The commit path serializes canonically (one
1304
- // WHY: convention, no third path); a ROLLBACK is not a commit and must not smuggle in the
1305
- // WHY: canonical fold as if it were the restore — it writes the captured BUFFERS (ticket-04b
1306
- // WHY: §11: after an abort BOTH files are byte-identical to the pre-transaction contents).
1510
+ // WHY: STEP 2 ordered commit: the primary file first, then every remaining file in plan order,
1511
+ // WHY: using the raw pre-images captured in STEP 1 above. The commit path serializes canonically
1512
+ // WHY: (one convention, no third path); a ROLLBACK is not a commit and must not smuggle in the
1513
+ // WHY: canonical fold as if it were the restore — it writes the captured BUFFERS (ticket-04b §11:
1514
+ // WHY: after an abort EVERY file is byte-identical to the pre-transaction contents).
1307
1515
  const written: ProcessedEditFile[] = [];
1308
1516
  try {
1309
1517
  if (options?.onBeforeFirstCutWrite) {
@@ -1312,38 +1520,45 @@ async function runCutTransaction(
1312
1520
  await options.onBeforeFirstCutWrite();
1313
1521
  }
1314
1522
  abortIf(options?.signal);
1315
- await writeAtomic(
1316
- file.absolutePath,
1317
- file.bom + restoreEndings(file.result, file.originalEnding),
1318
- );
1319
- written.push(file);
1320
- for (const plan of sourcePlans) {
1321
- if (options?.onCutBetweenWrites) {
1523
+ for (let index = 0; index < writePlans.length; index++) {
1524
+ const plan = writePlans[index]!;
1525
+ if (index > 0 && options?.onCutBetweenWrites) {
1322
1526
  // WHY: test-only fault-injection seam (ticket-04b §3): fires INSIDE the two-rename
1323
1527
  // WHY: window, after the previous file's rename is durable.
1324
1528
  await options.onCutBetweenWrites(written[written.length - 1]!.absolutePath);
1325
1529
  }
1326
1530
  abortIf(options?.signal);
1327
- await writeAtomic(
1328
- plan.absolutePath,
1329
- plan.bom + restoreEndings(plan.result, plan.originalEnding),
1531
+ const publish = (member: ProcessedEditFile): void => {
1532
+ written.push(member);
1533
+ };
1534
+ await commitMember(
1535
+ plan,
1536
+ rawPreBytes,
1537
+ publish,
1538
+ options?.workspaceRoot,
1539
+ options?.onAfterMoveRename,
1330
1540
  );
1331
- written.push(plan);
1332
1541
  }
1333
1542
  } catch (error) {
1334
- let rolledBack = true;
1543
+ // WHY: the recovery report is the honest three-way split the contract requires: restored =
1544
+ // WHY: proven back at its pre-image, remaining = rollback did not run / did not hold,
1545
+ // WHY: uncertain = the rollback attempt itself threw (we cannot claim either state).
1546
+ const report: TransactionRecoveryReport = { restored: [], remaining: [], uncertain: [] };
1335
1547
  for (const plan of written) {
1336
1548
  try {
1337
- await writeAtomic(plan.absolutePath, rawPreBytes.get(plan.absolutePath)!);
1549
+ await rollbackMember(plan, rawPreBytes, options?.workspaceRoot);
1550
+ report.restored.push(plan.path);
1338
1551
  } catch (rollbackError) {
1339
- rolledBack = false;
1552
+ report.remaining.push(plan.path);
1553
+ report.uncertain.push(plan.path);
1340
1554
  reportDiagnostic(
1341
1555
  "mutation-engine",
1342
- "Failed to roll back a cut-transaction file:",
1556
+ "Failed to roll back a transaction member:",
1343
1557
  rollbackError,
1344
1558
  );
1345
1559
  }
1346
1560
  }
1561
+ const rolledBack = report.remaining.length === 0;
1347
1562
  if (rolledBack) {
1348
1563
  for (const prior of saved) await prior.restore();
1349
1564
  try {
@@ -1357,8 +1572,8 @@ async function runCutTransaction(
1357
1572
  }
1358
1573
  }
1359
1574
  // WHY: a rollback that did not hold LEAVES the intent row and the transaction-tagged undo
1360
- // WHY: rows: next-run repair resolves to complete-the-cut or restore-both from the stored
1361
- // WHY: pre/post bytes (ADR-0028) — never a silent loss, never a fake atomicity claim.
1575
+ // WHY: rows: next-run repair resolves to complete-the-transaction or restore-from-rows.
1576
+ (error as Error & { recovery?: TransactionRecoveryReport }).recovery = report;
1362
1577
  throw error;
1363
1578
  }
1364
1579
 
@@ -1371,38 +1586,288 @@ async function runCutTransaction(
1371
1586
  }
1372
1587
 
1373
1588
  // WHY: STEP 3 store commit per file — the same `finishCommittedFile` the single-file path
1374
- // WHY: runs, then ONE response whose sections cover BOTH files (§5).
1589
+ // WHY: runs, then ONE response whose sections cover EVERY file (§5). A plan that applied nothing
1590
+ // WHY: is REPORTED but never committed: its bytes did not move, so it owns no store write.
1375
1591
  const sections = [];
1376
- for (const plan of plans) {
1377
- sections.push(toSection(plan, await finishCommittedFile(plan, options, sessionKey)));
1592
+ for (const plan of sectionPlans) {
1593
+ // WHY: a deleted file has no post-image to snapshot or serve; every other member commits its
1594
+ // WHY: new anchors through the same finishCommittedFile the single-file edit uses.
1595
+ const materializes = plan.appliedCount > 0 && memberLifecycleOf(plan).kind !== "delete";
1596
+ sections.push(
1597
+ toSection(
1598
+ plan,
1599
+ materializes ? await finishCommittedFile(plan, options, sessionKey) : undefined,
1600
+ ),
1601
+ );
1378
1602
  }
1379
- const toolResult = buildBatchResult(sections);
1603
+ return batchOutcome(sectionPlans, buildBatchResult(sections));
1604
+ }
1605
+
1606
+ /** One group of items sharing a target file, keyed by that file's RESOLVED path. */
1607
+ interface TargetGroup {
1608
+ /** Resolved absolute path — the identity two spellings of one file share (§9.4). */
1609
+ readonly key: string;
1610
+ /** The spelling this group's items used, for messages and the response section. */
1611
+ readonly file: string;
1612
+ readonly items: NormalizedEditItem[];
1613
+ /**
1614
+ * The call-relative 0-based number of every item in `items`, parallel to it: a refusal must name
1615
+ * the item the model submitted, not this group's position inside the plan.
1616
+ */
1617
+ readonly numbers: number[];
1618
+ }
1619
+
1620
+ /** What one plan or committed batch reports: the primary file leads, every section rides the result. */
1621
+ interface BatchOutcome {
1622
+ result: string;
1623
+ diff: string;
1624
+ drift: string | undefined;
1625
+ metrics: ReturnType<typeof buildBatchResult>["details"]["metrics"];
1626
+ raw: ProcessedEditFile;
1627
+ toolResult: ReturnType<typeof buildBatchResult>;
1628
+ }
1629
+
1630
+ /** Shape one batch outcome around its primary plan: the first plan leads, every plan's drift joins. */
1631
+ function batchOutcome(
1632
+ plans: ProcessedEditFile[],
1633
+ toolResult: ReturnType<typeof buildBatchResult>,
1634
+ ): BatchOutcome {
1635
+ const primary = plans[0]!;
1380
1636
  const drift = plans
1381
- .map((p) => p.driftNotice)
1382
- .filter((d): d is string => d !== undefined)
1637
+ .map((plan) => plan.driftNotice)
1638
+ .filter((notice): notice is string => notice !== undefined)
1383
1639
  .join("\n\n");
1384
1640
  return {
1385
- result: file.result,
1641
+ result: primary.result,
1386
1642
  diff: toolResult.details.diff ?? "",
1387
1643
  drift: drift === "" ? undefined : drift,
1388
1644
  metrics: toolResult.details.metrics,
1389
- raw: file,
1645
+ raw: primary,
1390
1646
  toolResult,
1391
1647
  };
1392
1648
  }
1393
1649
 
1650
+ /** True when a request widens its targets past the call's single `file`. */
1651
+ function hasItemFiles(request: NormalizedEditRequest): boolean {
1652
+ return request.edits.some((item) => item.file !== undefined);
1653
+ }
1654
+
1655
+ /** The request one group plans: its items on its own file, under the call's mode. */
1656
+ function groupRequest(group: TargetGroup, request: NormalizedEditRequest): NormalizedEditRequest {
1657
+ return {
1658
+ file: group.file,
1659
+ edits: group.items,
1660
+ ...(request.mode !== undefined ? { mode: request.mode } : {}),
1661
+ };
1662
+ }
1663
+
1664
+ /**
1665
+ * Group one request's items by the file each edits: an item's own `file` when it names one, else the
1666
+ * request's top-level `file`. Groups keep first-appearance order, so the call's FIRST item names the
1667
+ * primary file.
1668
+ *
1669
+ * WHY resolved paths as the key: `./a.ts` and `a.ts` are one file (§9.4), and every per-file
1670
+ * mechanism this plan reuses — lease resolution, the batch-span gate, undo, the served commit —
1671
+ * keys on the resolved identity.
1672
+ */
1673
+ async function groupItemsByTarget(
1674
+ request: NormalizedEditRequest,
1675
+ cwd: string,
1676
+ ): Promise<TargetGroup[]> {
1677
+ const groups: TargetGroup[] = [];
1678
+ const byKey = new Map<string, TargetGroup>();
1679
+ for (let index = 0; index < request.edits.length; index++) {
1680
+ const item = request.edits[index]!;
1681
+ const file = item.file ?? request.file;
1682
+ const key = await resolveTarget(toCwd(file, cwd));
1683
+ let group = byKey.get(key);
1684
+ if (group === undefined) {
1685
+ group = { key, file, items: [], numbers: [] };
1686
+ byKey.set(key, group);
1687
+ groups.push(group);
1688
+ }
1689
+ group.items.push(item);
1690
+ group.numbers.push(index);
1691
+ }
1692
+ return groups;
1693
+ }
1694
+
1695
+ /**
1696
+ * Fold every foreign `cut`'s retirement into the group of the file it retires.
1697
+ *
1698
+ * WHY: a cut's source may itself be another target of the same call, and one file must be planned —
1699
+ * and written — exactly once. A source the call does not otherwise edit becomes a group of its own:
1700
+ * exactly the source plan the single-target cut path builds, with its own load, lease verification,
1701
+ * batch-span gate and undo row. The retirement is the ordinary deletion payload over the SUBMITTED
1702
+ * anchors, so it goes through the same parse and the same gates as any other item.
1703
+ */
1704
+ async function foldCutRetirements(
1705
+ groups: TargetGroup[],
1706
+ request: NormalizedEditRequest,
1707
+ cwd: string,
1708
+ ): Promise<TargetGroup[]> {
1709
+ const byKey = new Map(groups.map((group) => [group.key, group] as const));
1710
+ // WHY: snapshot the target groups first — a retirement may ADD a group while this loop runs.
1711
+ const targets = [...groups];
1712
+ for (const group of targets) {
1713
+ for (let local = 0; local < group.items.length; local++) {
1714
+ const item = group.items[local]!;
1715
+ const payload = item.payload;
1716
+ if (payload.kind !== "reference" || payload.mode !== "cut") continue;
1717
+ // WHY: the same predicate and the same item target file the planning pre-pass uses, so the
1718
+ // WHY: two halves cannot disagree about which references are foreign.
1719
+ const refFile = await foreignRefFileOf(item, { cwd, file: group.file });
1720
+ if (refFile === undefined) continue;
1721
+ const key = await resolveTarget(toCwd(refFile, cwd));
1722
+ let source = byKey.get(key);
1723
+ if (source === undefined) {
1724
+ source = { key, file: refFile, items: [], numbers: [] };
1725
+ byKey.set(key, source);
1726
+ groups.push(source);
1727
+ }
1728
+ source.items.push({
1729
+ target: {
1730
+ anchor_from: payload.span.anchor_from,
1731
+ anchor_to: payload.span.anchor_to,
1732
+ },
1733
+ at: "in-place",
1734
+ payload: { kind: "empty" },
1735
+ });
1736
+ source.numbers.push(group.numbers[local]!);
1737
+ }
1738
+ }
1739
+ return groups;
1740
+ }
1741
+
1742
+ /**
1743
+ * Order groups for planning and writing: the primary (the call's first item's file) first, then the
1744
+ * rest by resolved path.
1745
+ *
1746
+ * WHY the primary leads: it is renamed first and its undo row is the transaction's target row, so
1747
+ * next-run repair reads its POST state to decide "complete the rest" — the cut path's own ordering
1748
+ * rule (ADR-0028), generalized from two files to N.
1749
+ */
1750
+ function orderGroups(groups: TargetGroup[]): TargetGroup[] {
1751
+ const [primary, ...rest] = groups;
1752
+ if (primary === undefined) return [];
1753
+ rest.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
1754
+ return [primary, ...rest];
1755
+ }
1756
+
1757
+ /** Plan every ordered group: one per-file pass through the existing load → gate → buffer path. */
1758
+ async function planGroups(
1759
+ groups: TargetGroup[],
1760
+ request: NormalizedEditRequest,
1761
+ cwd: string,
1762
+ options: PipelineOptions | undefined,
1763
+ ): Promise<ProcessedEditFile[]> {
1764
+ const plans: ProcessedEditFile[] = [];
1765
+ for (const group of groups) {
1766
+ plans.push(await runMutations(groupRequest(group, request), cwd, options, group.numbers));
1767
+ }
1768
+ return plans;
1769
+ }
1770
+
1771
+ /**
1772
+ * Plan a multi-file request without persisting, for the preview seam.
1773
+ * @returns one plan per target file in commit order, or `undefined` when the call names a single
1774
+ * file — the caller then keeps its own single-file preview path.
1775
+ */
1776
+ export async function planEditTargets(
1777
+ request: NormalizedEditRequest,
1778
+ cwd: string,
1779
+ options?: PipelineOptions,
1780
+ ): Promise<ProcessedEditFile[] | undefined> {
1781
+ if (!hasItemFiles(request)) return undefined;
1782
+ const groups = orderGroups(
1783
+ await foldCutRetirements(await groupItemsByTarget(request, cwd), request, cwd),
1784
+ );
1785
+ return await planGroups(groups, request, cwd, { ...options, noPersist: true });
1786
+ }
1787
+
1394
1788
  export async function apply(
1395
1789
  request: NormalizedEditRequest,
1396
1790
  cwd: string,
1397
1791
  options?: PipelineOptions,
1398
- ): Promise<{
1399
- result: string;
1400
- diff: string;
1401
- drift: string | undefined;
1402
- metrics: ReturnType<typeof buildBatchResult>["details"]["metrics"];
1403
- raw: ProcessedEditFile;
1404
- toolResult: ReturnType<typeof buildBatchResult>;
1405
- }> {
1792
+ ): Promise<BatchOutcome> {
1793
+ // WHY: the fast path — a request without item-level files is the pre-existing single-file edit
1794
+ // WHY: and takes exactly the code it always took: no grouping round-trip, no extra lock, no intent.
1795
+ if (!hasItemFiles(request)) {
1796
+ return await applySingleFile(request, cwd, options);
1797
+ }
1798
+ const groups = orderGroups(
1799
+ await foldCutRetirements(await groupItemsByTarget(request, cwd), request, cwd),
1800
+ );
1801
+ const primaryGroup = groups[0];
1802
+ if (groups.length === 1 && primaryGroup !== undefined) {
1803
+ // WHY: one item-level file for the whole call — the same single-file machinery, run on the file
1804
+ // WHY: the items name. Items that merely repeat the top-level `file` rebuild the same request.
1805
+ return await applySingleFile(groupRequest(primaryGroup, request), cwd, options);
1806
+ }
1807
+ return await applyMultiFile(groups, request, cwd, options);
1808
+ }
1809
+
1810
+ /**
1811
+ * Plan and commit several target files as ONE correlated transaction: each file gets its own load,
1812
+ * lease verification, batch-span gate and working buffer, and the write set commits with the same
1813
+ * pre-images, undo rows, intent record and rollback the foreign-cut path uses.
1814
+ *
1815
+ * @param groups - every target group in commit order; `groups[0]` is the primary.
1816
+ * @param request - the admitted call, supplying the mode.
1817
+ * @param cwd - the session working directory every request path resolves against.
1818
+ * @param options - the pipeline options; `noPersist` selects the preview half.
1819
+ * @returns the batch outcome, anchored on the primary file.
1820
+ */
1821
+ async function applyMultiFile(
1822
+ groups: TargetGroup[],
1823
+ request: NormalizedEditRequest,
1824
+ cwd: string,
1825
+ options: PipelineOptions | undefined,
1826
+ ): Promise<BatchOutcome> {
1827
+ if (options?.noPersist === true) {
1828
+ // WHY: no queues for a preview: it writes nothing, which is the single-file preview's own rule.
1829
+ const plans = await planGroups(groups, request, cwd, options);
1830
+ return batchOutcome(plans, buildBatchResult(plans.map((plan) => toSection(plan))));
1831
+ }
1832
+
1833
+ // WHY: (ticket-04b §2) repair before work, exactly as the single-file path does: a previous run
1834
+ // WHY: that crashed inside a correlated window left a durable intent this call resolves first.
1835
+ await repairCutIntents(options?.store);
1836
+ const sessionKey = requireSessionKey(options?.sessionKey);
1837
+
1838
+ // WHY: (ticket-04b §2) the transaction serializes EVERY file it will write, in deterministic
1839
+ // WHY: sorted order (`withSortedMutationQueues`): two calls whose file sets overlap in different
1840
+ // WHY: orders can never deadlock.
1841
+ return await withSortedMutationQueues(
1842
+ groups.map((group) => group.key),
1843
+ async () => {
1844
+ abortIf(options?.signal);
1845
+ const plans = await planGroups(groups, request, cwd, {
1846
+ ...options,
1847
+ sessionKey,
1848
+ accessMode: options?.accessMode ?? constants.R_OK | constants.W_OK,
1849
+ });
1850
+ const writePlans = plans.filter((plan) => plan.appliedCount > 0);
1851
+ if (writePlans.length === 0) {
1852
+ // WHY: an all-noop call writes nothing, so it takes no undo row and no intent — the
1853
+ // WHY: single-file noop contract, over every file the call named.
1854
+ return batchOutcome(plans, buildBatchResult(plans.map((plan) => toSection(plan))));
1855
+ }
1856
+ return await runCorrelatedTransaction(writePlans, plans, options, sessionKey);
1857
+ },
1858
+ );
1859
+ }
1860
+
1861
+ /**
1862
+ * The single-file path: one target, the call's top-level `file`. Unchanged from before multi-file
1863
+ * edit existed — a request with item-level files whose items all name ONE file is funnelled here
1864
+ * with that file's own request, so this path owns every single-target semantic in one place.
1865
+ */
1866
+ async function applySingleFile(
1867
+ request: NormalizedEditRequest,
1868
+ cwd: string,
1869
+ options?: PipelineOptions,
1870
+ ): Promise<BatchOutcome> {
1406
1871
  const isPreview = options?.noPersist === true;
1407
1872
  if (isPreview) {
1408
1873
  const file = await runMutations(request, cwd, options);
@@ -1516,3 +1981,10 @@ export async function execEdits(
1516
1981
  ): Promise<ProcessedEditFile> {
1517
1982
  return runMutations(request, cwd, options);
1518
1983
  }
1984
+
1985
+ /**
1986
+ * Visibility-only export of the correlated transaction for apply_patch's commit seam. The
1987
+ * function body is the one the cut path already uses; apply_patch members carry an optional
1988
+ * lifecycle action read by that body. No engine semantics change.
1989
+ */
1990
+ export { runCorrelatedTransaction as commitTransaction };