dsh-hashline-edit 0.2.2 → 0.3.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 (118) hide show
  1. package/README.md +57 -45
  2. package/cordis.patch.yml +9 -3
  3. package/lib/client.js +231 -0
  4. package/lib/index.js +3298 -3957
  5. package/lib/types/dsh/contracts.d.ts +12 -3
  6. package/lib/types/dsh/fs.d.ts +3 -0
  7. package/lib/types/dsh/guidance.d.ts +12 -6
  8. package/lib/types/dsh/hide.d.ts +79 -0
  9. package/lib/types/dsh/host-compat.d.ts +7 -78
  10. package/lib/types/dsh/index.d.ts +53 -10
  11. package/lib/types/dsh/metrics/events.d.ts +1 -1
  12. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +1 -1
  15. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  16. package/lib/types/dsh/tools/read.d.ts +3 -3
  17. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  18. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  19. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  20. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  21. package/lib/types/src/apply-patch/render.d.ts +51 -0
  22. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  23. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  24. package/lib/types/src/apply-patch/types.d.ts +362 -0
  25. package/lib/types/src/domain-errors.d.ts +16 -2
  26. package/lib/types/src/edit-undo.d.ts +11 -2
  27. package/lib/types/src/file-content/index.d.ts +1 -1
  28. package/lib/types/src/file-content/preview.d.ts +1 -1
  29. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  30. package/lib/types/src/hashline/alphabet.d.ts +4 -3
  31. package/lib/types/src/hashline/hash-identity.d.ts +12 -7
  32. package/lib/types/src/host-utils.d.ts +63 -0
  33. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  34. package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
  35. package/lib/types/src/mutation-engine/types.d.ts +40 -50
  36. package/lib/types/src/served-session/serve-render.d.ts +6 -0
  37. package/lib/types/src/undo-store.d.ts +17 -1
  38. package/package.json +37 -21
  39. package/prompts/apply-patch-guidelines.md +4 -0
  40. package/prompts/apply-patch.md +1 -0
  41. package/prompts/read-guidelines.md +6 -6
  42. package/prompts/read.md +1 -1
  43. package/prompts/undo-last-edit-guidelines.md +2 -2
  44. package/prompts/undo-last-edit-snippet.md +1 -1
  45. package/prompts/undo-last-edit.md +1 -1
  46. package/prompts/zh/apply-patch-guidelines.md +4 -0
  47. package/prompts/zh/apply-patch.md +1 -0
  48. package/prompts/zh/read-guidelines.md +6 -0
  49. package/prompts/zh/read-snippet.md +1 -0
  50. package/prompts/zh/read.md +1 -0
  51. package/prompts/zh/undo-last-edit-guidelines.md +2 -0
  52. package/prompts/zh/undo-last-edit-snippet.md +1 -0
  53. package/prompts/zh/undo-last-edit.md +1 -0
  54. package/src/apply-patch/commit.ts +190 -0
  55. package/src/apply-patch/grammar.ts +620 -0
  56. package/src/apply-patch/index.ts +17 -0
  57. package/src/apply-patch/lifecycle.ts +195 -0
  58. package/src/apply-patch/plan.ts +800 -0
  59. package/src/apply-patch/render.ts +137 -0
  60. package/src/apply-patch/resolve.ts +470 -0
  61. package/src/apply-patch/tool.ts +257 -0
  62. package/src/apply-patch/types.ts +387 -0
  63. package/src/cut-repair.ts +77 -11
  64. package/src/domain-errors.ts +35 -3
  65. package/src/edit-undo.ts +379 -25
  66. package/src/file-content/index.ts +1 -1
  67. package/src/file-content/preview.ts +1 -1
  68. package/src/file-content/served-header.ts +1 -1
  69. package/src/fs-lifecycle.ts +201 -0
  70. package/src/hash-store.ts +18 -1
  71. package/src/hashline/alphabet.ts +7 -7
  72. package/src/hashline/hash-identity.ts +12 -7
  73. package/src/host-utils.ts +224 -0
  74. package/src/mutation-engine/edit-request.ts +39 -0
  75. package/src/mutation-engine/pipeline.ts +198 -27
  76. package/src/mutation-engine/types.ts +39 -57
  77. package/src/mutation-queue.ts +1 -1
  78. package/src/prompts.ts +3 -30
  79. package/src/served-session/serve-render.ts +2 -2
  80. package/src/undo-store.ts +74 -5
  81. package/lib/types/dsh/tools/edit.d.ts +0 -10
  82. package/lib/types/src/edit-tool.d.ts +0 -41
  83. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  84. package/lib/types/src/payload-contract.d.ts +0 -135
  85. package/prompts/edit-guidelines.md +0 -10
  86. package/prompts/edit-snippet.md +0 -1
  87. package/prompts/edit.md +0 -1
  88. package/prompts/read-snippet.md +0 -1
  89. package/src/bash-classifier.ts +0 -1626
  90. package/src/bash-conversion/decide.ts +0 -812
  91. package/src/bash-display-transform.ts +0 -50
  92. package/src/bash-multifile-config.ts +0 -239
  93. package/src/bash-view-config.ts +0 -158
  94. package/src/edit-normalize.ts +0 -8
  95. package/src/edit-render.ts +0 -161
  96. package/src/edit-tool.ts +0 -118
  97. package/src/edit.ts +0 -136
  98. package/src/integrations/pi-lens/command.ts +0 -712
  99. package/src/integrations/pi-lens/config.ts +0 -148
  100. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  101. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  102. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  103. package/src/lifecycle-hooks/index.ts +0 -1992
  104. package/src/lifecycle-hooks/types.ts +0 -64
  105. package/src/mutated-files.ts +0 -75
  106. package/src/mutation-engine/engine.ts +0 -239
  107. package/src/mutation-engine/index.ts +0 -17
  108. package/src/payload-contract.ts +0 -685
  109. package/src/preview-controller.ts +0 -84
  110. package/src/read.ts +0 -865
  111. package/src/search-hook.ts +0 -41
  112. package/src/store-config.ts +0 -73
  113. package/src/store-janitor-warning.ts +0 -169
  114. package/src/store-janitor.ts +0 -539
  115. package/src/store-kernel/settings.ts +0 -233
  116. package/src/store.ts +0 -154
  117. package/src/tui-presenter.ts +0 -134
  118. 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,
@@ -1308,6 +1318,108 @@ async function runCutTransaction(
1308
1318
  * (those are reported and never committed).
1309
1319
  * @returns the batch outcome, anchored on the primary file.
1310
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
+ }
1311
1423
  async function runCorrelatedTransaction(
1312
1424
  writePlans: ProcessedEditFile[],
1313
1425
  sectionPlans: ProcessedEditFile[],
@@ -1327,18 +1439,60 @@ async function runCorrelatedTransaction(
1327
1439
  // WHY: (ticket-04b §11: after an abort EVERY file is byte-identical to its pre bytes).
1328
1440
  const rawPreBytes = new Map<string, Buffer>();
1329
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
+ }
1330
1458
  rawPreBytes.set(plan.absolutePath, await readBytes(plan.absolutePath));
1331
1459
  }
1332
1460
  const saved: { restore: () => Promise<void> }[] = [];
1333
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;
1334
1476
  const undo = await saveUndo(plan.absolutePath, {
1335
- content: plan.originalNormalized,
1477
+ content: isAdd ? "" : plan.originalNormalized,
1336
1478
  bom: plan.bom,
1337
1479
  originalEnding: plan.originalEnding,
1338
1480
  hashes: plan.originalHashes,
1339
- resultContent: plan.result,
1481
+ resultContent: isDelete ? "" : plan.result,
1340
1482
  transactionId: txnId,
1341
- 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 }),
1342
1496
  });
1343
1497
  if (!undo.persisted) {
1344
1498
  for (const prior of saved) await prior.restore();
@@ -1366,38 +1520,45 @@ async function runCorrelatedTransaction(
1366
1520
  await options.onBeforeFirstCutWrite();
1367
1521
  }
1368
1522
  abortIf(options?.signal);
1369
- await writeAtomic(
1370
- primary.absolutePath,
1371
- primary.bom + restoreEndings(primary.result, primary.originalEnding),
1372
- );
1373
- written.push(primary);
1374
- for (const plan of writePlans.slice(1)) {
1375
- if (options?.onCutBetweenWrites) {
1523
+ for (let index = 0; index < writePlans.length; index++) {
1524
+ const plan = writePlans[index]!;
1525
+ if (index > 0 && options?.onCutBetweenWrites) {
1376
1526
  // WHY: test-only fault-injection seam (ticket-04b §3): fires INSIDE the two-rename
1377
1527
  // WHY: window, after the previous file's rename is durable.
1378
1528
  await options.onCutBetweenWrites(written[written.length - 1]!.absolutePath);
1379
1529
  }
1380
1530
  abortIf(options?.signal);
1381
- await writeAtomic(
1382
- plan.absolutePath,
1383
- 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,
1384
1540
  );
1385
- written.push(plan);
1386
1541
  }
1387
1542
  } catch (error) {
1388
- 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: [] };
1389
1547
  for (const plan of written) {
1390
1548
  try {
1391
- await writeAtomic(plan.absolutePath, rawPreBytes.get(plan.absolutePath)!);
1549
+ await rollbackMember(plan, rawPreBytes, options?.workspaceRoot);
1550
+ report.restored.push(plan.path);
1392
1551
  } catch (rollbackError) {
1393
- rolledBack = false;
1552
+ report.remaining.push(plan.path);
1553
+ report.uncertain.push(plan.path);
1394
1554
  reportDiagnostic(
1395
1555
  "mutation-engine",
1396
- "Failed to roll back a cut-transaction file:",
1556
+ "Failed to roll back a transaction member:",
1397
1557
  rollbackError,
1398
1558
  );
1399
1559
  }
1400
1560
  }
1561
+ const rolledBack = report.remaining.length === 0;
1401
1562
  if (rolledBack) {
1402
1563
  for (const prior of saved) await prior.restore();
1403
1564
  try {
@@ -1411,8 +1572,8 @@ async function runCorrelatedTransaction(
1411
1572
  }
1412
1573
  }
1413
1574
  // WHY: a rollback that did not hold LEAVES the intent row and the transaction-tagged undo
1414
- // WHY: rows: next-run repair resolves to complete-the-cut or restore-both from the stored
1415
- // 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;
1416
1577
  throw error;
1417
1578
  }
1418
1579
 
@@ -1429,10 +1590,13 @@ async function runCorrelatedTransaction(
1429
1590
  // WHY: is REPORTED but never committed: its bytes did not move, so it owns no store write.
1430
1591
  const sections = [];
1431
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";
1432
1596
  sections.push(
1433
1597
  toSection(
1434
1598
  plan,
1435
- plan.appliedCount > 0 ? await finishCommittedFile(plan, options, sessionKey) : undefined,
1599
+ materializes ? await finishCommittedFile(plan, options, sessionKey) : undefined,
1436
1600
  ),
1437
1601
  );
1438
1602
  }
@@ -1817,3 +1981,10 @@ export async function execEdits(
1817
1981
  ): Promise<ProcessedEditFile> {
1818
1982
  return runMutations(request, cwd, options);
1819
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 };
@@ -8,13 +8,12 @@
8
8
  import type { LineEnding } from "../edit-diff.js";
9
9
  import type { ResolvedRange } from "../served-session/serve-render.js";
10
10
  import type { HashStore } from "../hash-store.js";
11
- import type { EditDetails, RMetrics } from "../edit-response.js";
12
- import type { DomainErrorCode, RangeCause } from "../domain-errors.js";
13
- import type { ServedRow } from "../domain-errors.js";
14
11
 
15
12
  // WHY: Re-export pipeline-facing options — validated once at admission (edit.ts),
16
13
  // WHY: trusted inside the engine. No `any`.
17
14
  export interface PipelineOptions {
15
+ /** Workspace root used to re-assert lifecycle path boundaries at commit time. */
16
+ workspaceRoot?: string;
18
17
  accessMode?: number;
19
18
  signal?: AbortSignal;
20
19
  store?: HashStore;
@@ -36,6 +35,22 @@ export interface PipelineOptions {
36
35
  * window cannot show that.
37
36
  */
38
37
  onBeforeFirstCutWrite?: () => void | Promise<void>;
38
+ /**
39
+ * TEST-ONLY mid-member fault seam: called after a move's rename is durable and before its
40
+ * destination content is rewritten, so the rollback of that in-flight member is exercised.
41
+ */
42
+ onAfterMoveRename?: (toAbsolutePath: string) => void | Promise<void>;
43
+ }
44
+
45
+ /**
46
+ * The honest outcome of a failed multi-member commit's rollback attempt. `restored` members are
47
+ * proven back at their pre-images; `remaining` members were not rolled back; `uncertain` members
48
+ * are those whose rollback attempt itself threw, so neither state can be claimed for them.
49
+ */
50
+ export interface TransactionRecoveryReport {
51
+ restored: string[];
52
+ remaining: string[];
53
+ uncertain: string[];
39
54
  }
40
55
 
41
56
  /**
@@ -51,6 +66,24 @@ export interface ForeignCutRecord {
51
66
  spanTo: string;
52
67
  }
53
68
 
69
+ /**
70
+ * The file-lifecycle action one transaction member carries.
71
+ *
72
+ * This is the engine-local vocabulary: apply_patch's plan IR maps onto it in
73
+ * `src/apply-patch/commit.ts` so the mutation engine never imports the apply-patch layer.
74
+ * Absent is an ordinary in-place edit.
75
+ */
76
+ export type MemberLifecycle =
77
+ | { kind: "keep" }
78
+ | { kind: "add"; createdDirs: readonly string[] }
79
+ | { kind: "delete" }
80
+ | {
81
+ kind: "move";
82
+ fromAbsolutePath: string;
83
+ toAbsolutePath: string;
84
+ createdDirs: readonly string[];
85
+ };
86
+
54
87
  // WHY: Internal: the engine's view of one file's mutation outcome.
55
88
  // WHY: Mirrors `ProcessedEditFile` from the old pipeline — kept here as the engine's owned fact.
56
89
  export interface ProcessedEditFile {
@@ -96,60 +129,9 @@ export interface ProcessedEditFile {
96
129
  * correlated two-file transaction; the in-memory buffer itself never touches these files.
97
130
  */
98
131
  foreignCuts: ForeignCutRecord[];
99
- }
100
-
101
- // WHY: Discriminated success/failure for the deep seam.
102
- // WHY: Callers use exhaustive switch on `ok` — no `isError` flag checks,
103
- // WHY: no `any` threading.
104
- export interface MutationSuccess {
105
- ok: true;
106
- /** SAFETY: Normalized result content (LF). */
107
- result: string;
108
- /** SAFETY: Unified diff (hash-anchored) for model consumption. */
109
- diff: string;
110
- /** SAFETY: User-facing drift notice, if any (details only, not model content). */
111
- drift: string | undefined;
112
- /** SAFETY: Metrics for telemetry. */
113
- metrics: RMetrics;
114
- raw: ProcessedEditFile;
115
- /** SAFETY: Full tool result (content + details) for pi's tool_result hook. */
116
- toolResult: {
117
- content: Array<{ type: "text"; text: string }>;
118
- details: EditDetails;
119
- };
120
- }
121
-
122
- export interface MutationFailure {
123
- ok: false;
124
- /**
125
- * SAFETY: Machine code — a registry member (`DomainErrorCode`). `toFailure` validates every
126
- * caught code through the envelope reader (`src/error-envelope.ts`): non-registry strings
127
- * (errno pass-through included) route to `E_UNKNOWN`, so the union is honest.
128
- */
129
- code: DomainErrorCode;
130
- /** SAFETY: Human message — model-facing signal when applicable. */
131
- message: string;
132
- /** SAFETY: Fresh served block for retry when available (reject-and-serve). */
133
- servedBlock?: string;
134
- servedRows?: ServedRow[];
135
132
  /**
136
- * SAFETY: the RAW `E_BAD_PAYLOAD` message behind a rendered refusal, when the route is one, so
137
- * SAFETY: the tool seam can name the invoking tool instead of falling back to the neutral
138
- * SAFETY: wording. Present only for `code: "E_BAD_PAYLOAD"`.
133
+ * apply_patch lifecycle action for this member; absent is an ordinary edit. The correlated
134
+ * transaction reads it for pre-image capture, commit ordering, rollback and undo-row shape.
139
135
  */
140
- payloadMessage?: string;
141
- /** SAFETY: User-facing diagnosis, never a model remedy — a GLOSSARY.md glossary term. */
142
- cause?: RangeCause;
143
- details?: { code: DomainErrorCode; cause: RangeCause };
144
- }
145
-
146
- export type MutationResult = MutationSuccess | MutationFailure;
147
-
148
- // WHY: Narrowing helpers — keep call sites exhaustive.
149
- export function isMutationSuccess(r: MutationResult): r is MutationSuccess {
150
- return r.ok === true;
151
- }
152
-
153
- export function isMutationFailure(r: MutationResult): r is MutationFailure {
154
- return r.ok === false;
136
+ lifecycle?: MemberLifecycle;
155
137
  }
@@ -1,4 +1,4 @@
1
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
1
+ import { withFileMutationQueue } from "./host-utils.js";
2
2
 
3
3
  /**
4
4
  * Enter pi's per-path mutation queues for SEVERAL paths in one deterministic, sorted nesting.
package/src/prompts.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { HASH_LEN } from "./hashline/alphabet.js";
3
- import {
4
- ANCHOR_EXAMPLE,
5
- EDIT_DESCRIPTION,
6
- EDIT_SNIPPET,
7
- EDIT_GUIDELINES,
8
- } from "./payload-contract.js";
9
3
 
10
4
  /**
11
5
  * Resolve template variables in shipped prompt prose. Beyond caller-supplied replacements, the
12
- * anchor-width token and its example are ALWAYS resolved from the live HASH_LEN: the width is a
13
- * build-time setting (ADR-0020), so any prompt that quotes it must not hardcode a digit.
6
+ * anchor-width token is ALWAYS resolved from the live `HASH_LEN`: the width is a build-time setting
7
+ * (ADR-0020), so any prompt that quotes it must not hardcode a digit.
14
8
  */
15
9
  function applyReplacements(content: string, replacements?: Record<string, string>): string {
16
10
  let out = content;
@@ -19,36 +13,15 @@ function applyReplacements(content: string, replacements?: Record<string, string
19
13
  out = out.split(`{{${key}}}`).join(value);
20
14
  }
21
15
  }
22
- return out
23
- .split("{{HASH_LEN}}")
24
- .join(String(HASH_LEN))
25
- .split("{{ANCHOR_EXAMPLE}}")
26
- .join(ANCHOR_EXAMPLE);
16
+ return out.split("{{HASH_LEN}}").join(String(HASH_LEN));
27
17
  }
28
18
 
29
- const EDIT_PROMPT_FILES = new Set([
30
- "../prompts/edit.md",
31
- "prompts/edit.md",
32
- "../prompts/edit-snippet.md",
33
- "prompts/edit-snippet.md",
34
- ]);
35
-
36
- const EDIT_GUIDE_FILES = new Set(["../prompts/edit-guidelines.md", "prompts/edit-guidelines.md"]);
37
-
38
19
  export function loadP(relativePath: string, replacements?: Record<string, string>): string {
39
- if (EDIT_PROMPT_FILES.has(relativePath)) {
40
- const content = relativePath.includes("snippet") ? EDIT_SNIPPET : EDIT_DESCRIPTION;
41
- return applyReplacements(content, replacements);
42
- }
43
20
  const content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
44
21
  return applyReplacements(content, replacements);
45
22
  }
46
23
 
47
24
  export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
48
- if (EDIT_GUIDE_FILES.has(relativePath)) {
49
- const guidelines = [...EDIT_GUIDELINES];
50
- return guidelines.map((line) => applyReplacements(line, replacements));
51
- }
52
25
  const content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
53
26
  return applyReplacements(content, replacements)
54
27
  .split("\n")
@@ -90,7 +90,7 @@ export function fmtServedRows(rows: ServedRow[], fileLines: string[]): string {
90
90
 
91
91
  // WHY: the one continuation a rejected edit offers (read-chrome): the `windows` offset, not a bare
92
92
  // WHY: `offset`, because `offset` alone no longer continues anything a page footer used to name.
93
- function paginationHint(nextOffset: number, more: number): string {
93
+ export function paginationHint(nextOffset: number, more: number): string {
94
94
  return `[... ${more} more — use windows: [{ offset: ${nextOffset} }]]`;
95
95
  }
96
96
 
@@ -107,7 +107,7 @@ export function servedPositionsOf(served: (string | null)[], hash: string): numb
107
107
  // WHY: ---------------------------------------------------------------------------
108
108
 
109
109
  /** Builds the reject-and-serve served rows + rendered block for a current on-disk range. */
110
- function buildRangeServeBlock(
110
+ export function buildRangeServeBlock(
111
111
  startLine: number,
112
112
  endLine: number,
113
113
  fileHashes: string[],
package/src/undo-store.ts CHANGED
@@ -29,8 +29,25 @@ export interface UndoRecord {
29
29
  * `null` is a row from before this column, or an ordinary single-file edit.
30
30
  */
31
31
  rawPre?: string | null;
32
+ /**
33
+ * The lifecycle action this row reverses. Absent/null is an ordinary edit (back-compatible).
34
+ * add = the file did not exist before; delete = the file does not exist after; move = a pure
35
+ * rename with an optional content edit.
36
+ */
37
+ lifecycle?: UndoLifecycle | null;
38
+ /** Move: the absolute path the file had before the transaction. */
39
+ fromPath?: string | null;
40
+ /** Move: the absolute path the file has after the transaction. */
41
+ toPath?: string | null;
42
+ /** The RAW post-transaction text when the commit wrote non-canonical bytes (pure move). */
43
+ rawPost?: string | null;
44
+ /** Add: the directories the add created, outermost first, JSON-encoded in the column. */
45
+ createdDirs?: readonly string[] | null;
32
46
  }
33
47
 
48
+ /** The lifecycle action one undo row reverses. */
49
+ export type UndoLifecycle = "edit" | "add" | "delete" | "move";
50
+
34
51
  export type CutIntentDirection = "revert" | null;
35
52
 
36
53
  export interface CutIntent {
@@ -50,6 +67,11 @@ export interface UndoStmts {
50
67
  snapshotHash: string | null,
51
68
  transactionId: string | null,
52
69
  rawPre: string | null,
70
+ lifecycle: string | null,
71
+ fromPath: string | null,
72
+ toPath: string | null,
73
+ rawPost: string | null,
74
+ createdDirs: string | null,
53
75
  anchorGeneration: number,
54
76
  updatedAt: number,
55
77
  ) => void;
@@ -75,15 +97,15 @@ export function undoStmts(db: DatabaseSync): UndoStmts {
75
97
 
76
98
  function buildStmts(db: DatabaseSync): UndoStmts {
77
99
  const undoUpsertStmt = db.prepare(
78
- "INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
79
- "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre, anchor_generation = excluded.anchor_generation, updated_at = excluded.updated_at",
100
+ "INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
101
+ "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre, lifecycle = excluded.lifecycle, from_path = excluded.from_path, to_path = excluded.to_path, raw_post = excluded.raw_post, created_dirs = excluded.created_dirs, anchor_generation = excluded.anchor_generation, updated_at = excluded.updated_at",
80
102
  );
81
103
  const undoGetStmt = db.prepare(
82
- "SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE path = ?",
104
+ "SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation FROM file_undo WHERE path = ?",
83
105
  );
84
106
  const undoDelStmt = db.prepare("DELETE FROM file_undo WHERE path = ?");
85
107
  const undoGetTransactionStmt = db.prepare(
86
- "SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE transaction_id = ? ORDER BY path",
108
+ "SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation FROM file_undo WHERE transaction_id = ? ORDER BY path",
87
109
  );
88
110
  const undoDelTransactionStmt = db.prepare("DELETE FROM file_undo WHERE transaction_id = ?");
89
111
  const intentUpsertStmt = db.prepare(
@@ -105,6 +127,11 @@ function buildStmts(db: DatabaseSync): UndoStmts {
105
127
  snapshotHash,
106
128
  transactionId,
107
129
  rawPre,
130
+ lifecycle,
131
+ fromPath,
132
+ toPath,
133
+ rawPost,
134
+ createdDirs,
108
135
  anchorGeneration,
109
136
  updatedAt,
110
137
  ) => {
@@ -119,6 +146,11 @@ function buildStmts(db: DatabaseSync): UndoStmts {
119
146
  snapshotHash,
120
147
  transactionId,
121
148
  rawPre,
149
+ lifecycle,
150
+ fromPath,
151
+ toPath,
152
+ rawPost,
153
+ createdDirs,
122
154
  anchorGeneration,
123
155
  updatedAt,
124
156
  );
@@ -164,12 +196,25 @@ onStoreOpen((db) => {
164
196
  ensureFileUndoSchema(db);
165
197
  });
166
198
 
199
+ /** Parse the JSON-encoded created-directory list; a corrupt value degrades to an empty list. */
200
+ function parseCreatedDirs(value: unknown): string[] | null {
201
+ if (typeof value !== "string" || value.length === 0) return null;
202
+ try {
203
+ const parsed: unknown = JSON.parse(value);
204
+ return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")
205
+ ? (parsed as string[])
206
+ : null;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
167
212
  function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | undefined {
168
213
  if (!row) return undefined;
169
214
  try {
170
215
  const parsed = JSON.parse(row.hashes as string);
171
216
  if (!isValidHashList(parsed)) return undefined;
172
- return {
217
+ const record: UndoRecord = {
173
218
  content: row.content as string,
174
219
  bom: row.bom as string,
175
220
  ending: row.ending as string,
@@ -180,6 +225,23 @@ function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | un
180
225
  rawPre: (row.raw_pre as string | null) ?? null,
181
226
  anchorGeneration: (row.anchor_generation as number | null) ?? 0,
182
227
  };
228
+ // WHY: the lifecycle fields are additive; an ordinary row omits them entirely so its
229
+ // WHY: pre-extension shape stays byte-for-byte what existing consumers assert.
230
+ if (row.lifecycle !== null && row.lifecycle !== undefined) {
231
+ record.lifecycle = row.lifecycle as UndoLifecycle;
232
+ }
233
+ if (row.from_path !== null && row.from_path !== undefined) {
234
+ record.fromPath = row.from_path as string;
235
+ }
236
+ if (row.to_path !== null && row.to_path !== undefined) {
237
+ record.toPath = row.to_path as string;
238
+ }
239
+ if (row.raw_post !== null && row.raw_post !== undefined) {
240
+ record.rawPost = row.raw_post as string;
241
+ }
242
+ const createdDirs = parseCreatedDirs(row.created_dirs);
243
+ if (createdDirs !== null) record.createdDirs = createdDirs;
244
+ return record;
183
245
  } catch {
184
246
  return undefined;
185
247
  }
@@ -196,6 +258,13 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
196
258
  entry.snapshotHash ?? null,
197
259
  entry.transactionId ?? null,
198
260
  entry.rawPre ?? null,
261
+ entry.lifecycle ?? null,
262
+ entry.fromPath ?? null,
263
+ entry.toPath ?? null,
264
+ entry.rawPost ?? null,
265
+ entry.createdDirs === null || entry.createdDirs === undefined
266
+ ? null
267
+ : JSON.stringify(entry.createdDirs),
199
268
  // WHY: ADR-0031 §4 (refuse, do not launder) — the stamp comes from the payload, never
200
269
  // WHY: hard-coded: a replay of a pre-generation row preserves its generation so the
201
270
  // WHY: restore gate re-derives, while a fresh write (absent generation) defaults current.
@@ -1,10 +0,0 @@
1
- import type { ToolDefinition } from "@deepseek-ai/dsh-tools";
2
- import type { ToolDependencies } from "../contracts.js";
3
- /** dsh tool name; the agent-layer registration shadows the preset's built-in `edit`. */
4
- export declare const EDIT_TOOL_NAME = "edit";
5
- /**
6
- * Build the dsh `edit` tool for one agent install.
7
- * @param deps - the host seams this tool may touch; `sandbox` advertises and resolves escalation.
8
- * @returns a registry-ready dsh tool definition named `edit`.
9
- */
10
- export declare function buildEditTool(deps: ToolDependencies): ToolDefinition;