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
@@ -1,4 +1,4 @@
1
- import { type TruncationResult } from "@earendil-works/pi-coding-agent";
1
+ import { type TruncationResult } from "../host-utils.js";
2
2
  import { type AnchorWalk } from "../hashline/hash-identity.js";
3
3
  import type { ServedRow } from "../served-session/serve-render.js";
4
4
  /** The lines one page actually rendered, 1-based and inclusive. */
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Filesystem primitives for apply_patch's file lifecycle: classify, create directories, delete a
3
+ * regular file and rename one. Every entry point resolves through {@link resolveTarget}, the same
4
+ * symlink-resolving boundary every read/write uses, so a lifecycle action can never escape it.
5
+ *
6
+ * These are primitives, not a second write engine: file CONTENTS still go through writeAtomic;
7
+ * this module only owns namespace operations that writeAtomic deliberately does not express.
8
+ *
9
+ * @module dsh-hashline-edit/fs-lifecycle
10
+ */
11
+ /** The physical kind of a path as the file layer sees it (symlinks resolved first). */
12
+ export type PathKind = "file" | "directory" | "other" | "absent";
13
+ /** Classify one path through the file layer's symlink-resolving boundary. */
14
+ export declare function pathKind(path: string): Promise<PathKind>;
15
+ /**
16
+ * True when `path` resolves inside `root` once every existing symlink/junction component is
17
+ * followed. A missing tail is checked against its nearest existing ancestor: an Add File under a
18
+ * junction ancestor therefore fails before mkdir can create anything outside the workspace.
19
+ */
20
+ export declare function isPathWithinWorkspace(root: string, path: string): Promise<boolean>;
21
+ /** Throw the E_PATCH_SYNTAX boundary refusal for one escaping lifecycle path. */
22
+ export declare function assertPathWithinWorkspace(root: string, path: string): Promise<void>;
23
+ /** Create one directory (and missing parents), through the symlink-resolving boundary. */
24
+ export declare function mkdirp(path: string, workspaceRoot?: string): Promise<void>;
25
+ /**
26
+ * Delete one regular file. Deliberately NOT recursive: a lifecycle delete targets a file, and a
27
+ * directory at the same path is a caller bug that must surface, never be removed wholesale.
28
+ */
29
+ export declare function removeFile(path: string, signal?: AbortSignal): Promise<void>;
30
+ /**
31
+ * Rename one regular file, creating the destination parent first. The destination must not exist:
32
+ * a move never clobbers another file (spec §6: target-exists is a rejection, not an overwrite).
33
+ */
34
+ export declare function renameFile(from: string, to: string, signal?: AbortSignal, workspaceRoot?: string): Promise<void>;
35
+ /**
36
+ * The directories on the way to `path` that do not exist yet, OUTERMOST FIRST. Captured before a
37
+ * lifecycle add writes, so an undo removes exactly the directories the add created and no others.
38
+ */
39
+ export declare function missingAncestorDirs(path: string): Promise<string[]>;
40
+ /**
41
+ * Best-effort removal of directories a lifecycle add created, deepest first. Returns the removed
42
+ * paths. A directory that is no longer empty (an outside writer put something there) is kept.
43
+ */
44
+ export declare function pruneEmptyDirs(dirs: readonly string[]): Promise<string[]>;
@@ -128,6 +128,12 @@ export type HTEdit = {
128
128
  retire: boolean;
129
129
  };
130
130
  };
131
+ /**
132
+ * SAFETY: the rejection summary plus the rows to serve. The message is facts-only: it names the
133
+ * distinct failing anchors and, for ambiguous ones, the candidate line numbers. Row content travels
134
+ * only in `servedRows` so a caller renders one evidence block; a message that also echoes the rows
135
+ * would make the retry read the same lines twice.
136
+ */
131
137
  export declare function fmtMismatchWithServes(mismatches: HMismatch[], snapshot: FileSnapshotContext): {
132
138
  message: string;
133
139
  servedRows: ServedRow[];
@@ -0,0 +1,63 @@
1
+ /** Retained line budget, matching pi's `DEFAULT_MAX_LINES`. */
2
+ export declare const DEFAULT_MAX_LINES = 2000;
3
+ /** Retained byte budget, matching pi's `DEFAULT_MAX_BYTES` (50KB). */
4
+ export declare const DEFAULT_MAX_BYTES: number;
5
+ /** The truncation report the core's read page renders. */
6
+ export interface TruncationResult {
7
+ /** The truncated content. */
8
+ content: string;
9
+ /** Whether truncation occurred. */
10
+ truncated: boolean;
11
+ /** Which limit was hit: `"lines"`, `"bytes"`, or `null` when nothing was cut. */
12
+ truncatedBy: "lines" | "bytes" | null;
13
+ /** Total number of lines in the original content. */
14
+ totalLines: number;
15
+ /** Total number of bytes in the original content. */
16
+ totalBytes: number;
17
+ /** Number of complete lines in the truncated output. */
18
+ outputLines: number;
19
+ /** Number of bytes in the truncated output. */
20
+ outputBytes: number;
21
+ /** Whether the last line was partially truncated (tail truncation only). */
22
+ lastLinePartial: boolean;
23
+ /** Whether the first line exceeded the byte limit (head truncation only). */
24
+ firstLineExceedsLimit: boolean;
25
+ /** The line limit that was applied. */
26
+ maxLines: number;
27
+ /** The byte limit that was applied. */
28
+ maxBytes: number;
29
+ }
30
+ /** The two independent limits a truncation call applies. */
31
+ export interface TruncationOptions {
32
+ /** Maximum number of lines; defaults to {@link DEFAULT_MAX_LINES}. */
33
+ maxLines?: number;
34
+ /** Maximum number of bytes; defaults to {@link DEFAULT_MAX_BYTES}. */
35
+ maxBytes?: number;
36
+ }
37
+ /**
38
+ * Format bytes as a human-readable size (`512B`, `1.5KB`, `2.0MB`).
39
+ * @param bytes - the byte count to render.
40
+ * @returns the pi-compatible size label.
41
+ */
42
+ export declare function formatSize(bytes: number): string;
43
+ /**
44
+ * Truncate content from the head (keep the first N lines / bytes).
45
+ *
46
+ * WHY: the read page's oversize and truncation warnings must agree with the pi
47
+ * implementation the rest of the contract was written against, so this is a
48
+ * faithful port including its two edge rules: complete lines only, and an
49
+ * over-long first line yields empty content with `firstLineExceedsLimit`.
50
+ * @param content - the text to truncate.
51
+ * @param options - the line and byte limits.
52
+ * @returns the truncation report.
53
+ */
54
+ export declare function truncateHead(content: string, options?: TruncationOptions): TruncationResult;
55
+ /**
56
+ * Serialize file mutation operations that target the same file; different files still run
57
+ * in parallel. A faithful port of pi's queue so the core's cut transaction keeps its
58
+ * lock ordering guarantees under the dsh host.
59
+ * @param filePath - the file the mutation targets.
60
+ * @param run - the critical section to run once the path is free.
61
+ * @returns the critical section's result.
62
+ */
63
+ export declare function withFileMutationQueue<T>(filePath: string, run: () => Promise<T>): Promise<T>;
@@ -36,6 +36,12 @@ export interface BaselineSpanContext {
36
36
  sessionKey: string;
37
37
  absolutePath: string;
38
38
  isPreview: boolean;
39
+ /**
40
+ * WHY: the CALLER's own item numbers, parallel to `edits` — a per-file plan keeps its items'
41
+ * WHY: global positions, so an aggregated refusal names the item the model sent instead of this
42
+ * WHY: plan's local index. Absent means `edits` IS the whole call, so local index = caller number.
43
+ */
44
+ itemNumbers?: readonly number[];
39
45
  path: string;
40
46
  originalHashes: string[];
41
47
  originalNormalized: string;
@@ -0,0 +1,34 @@
1
+ /** The internal payload vocabulary the mutation engine consumes; no wire schema lives here. */
2
+ type SpanRef = {
3
+ anchor_from: string;
4
+ anchor_to: string;
5
+ file?: string;
6
+ mode: "copy" | "cut";
7
+ };
8
+ type EditMode = "general" | "literal";
9
+ type Placement = "in-place" | "before" | "after";
10
+ export type DesiredContent = {
11
+ kind: "literal";
12
+ text: string;
13
+ } | {
14
+ kind: "reference";
15
+ span: SpanRef;
16
+ mode: "copy" | "cut";
17
+ } | {
18
+ kind: "empty";
19
+ };
20
+ export type NormalizedEditItem = {
21
+ target: {
22
+ anchor_from: string;
23
+ anchor_to: string;
24
+ };
25
+ at: Placement;
26
+ payload: DesiredContent;
27
+ file?: string;
28
+ };
29
+ export type NormalizedEditRequest = {
30
+ file: string;
31
+ edits: NormalizedEditItem[];
32
+ mode?: EditMode;
33
+ };
34
+ export {};
@@ -1,14 +1,29 @@
1
- import type { NormalizedEditRequest } from "../payload-contract.js";
1
+ import type { NormalizedEditRequest } from "./edit-request.js";
2
2
  import { buildBatchResult } from "../edit-response.js";
3
3
  import type { PipelineOptions, ProcessedEditFile } from "./types.js";
4
4
  export type { PipelineOptions, ProcessedEditFile };
5
5
  export declare function previewEdits(request: NormalizedEditRequest, cwd: string, options?: Omit<PipelineOptions, "noPersist">): Promise<ProcessedEditFile>;
6
- export declare function apply(request: NormalizedEditRequest, cwd: string, options?: PipelineOptions): Promise<{
6
+ declare function runCorrelatedTransaction(writePlans: ProcessedEditFile[], sectionPlans: ProcessedEditFile[], options: PipelineOptions | undefined, sessionKey: string): Promise<BatchOutcome>;
7
+ /** What one plan or committed batch reports: the primary file leads, every section rides the result. */
8
+ interface BatchOutcome {
7
9
  result: string;
8
10
  diff: string;
9
11
  drift: string | undefined;
10
12
  metrics: ReturnType<typeof buildBatchResult>["details"]["metrics"];
11
13
  raw: ProcessedEditFile;
12
14
  toolResult: ReturnType<typeof buildBatchResult>;
13
- }>;
15
+ }
16
+ /**
17
+ * Plan a multi-file request without persisting, for the preview seam.
18
+ * @returns one plan per target file in commit order, or `undefined` when the call names a single
19
+ * file — the caller then keeps its own single-file preview path.
20
+ */
21
+ export declare function planEditTargets(request: NormalizedEditRequest, cwd: string, options?: PipelineOptions): Promise<ProcessedEditFile[] | undefined>;
22
+ export declare function apply(request: NormalizedEditRequest, cwd: string, options?: PipelineOptions): Promise<BatchOutcome>;
14
23
  export declare function execEdits(request: NormalizedEditRequest, cwd: string, options?: PipelineOptions): Promise<ProcessedEditFile>;
24
+ /**
25
+ * Visibility-only export of the correlated transaction for apply_patch's commit seam. The
26
+ * function body is the one the cut path already uses; apply_patch members carry an optional
27
+ * lifecycle action read by that body. No engine semantics change.
28
+ */
29
+ export { runCorrelatedTransaction as commitTransaction };
@@ -7,29 +7,45 @@
7
7
  import type { LineEnding } from "../edit-diff.js";
8
8
  import type { ResolvedRange } from "../served-session/serve-render.js";
9
9
  import type { HashStore } from "../hash-store.js";
10
- import type { EditDetails, RMetrics } from "../edit-response.js";
11
- import type { DomainErrorCode, RangeCause } from "../domain-errors.js";
12
- import type { ServedRow } from "../domain-errors.js";
13
10
  export interface PipelineOptions {
11
+ /** Workspace root used to re-assert lifecycle path boundaries at commit time. */
12
+ workspaceRoot?: string;
14
13
  accessMode?: number;
15
14
  signal?: AbortSignal;
16
15
  store?: HashStore;
17
16
  noPersist?: boolean;
18
17
  sessionKey?: string;
19
18
  /**
20
- * TEST-ONLY fault-injection seam (ticket-04b §3): invoked inside a foreign-source cut transaction
21
- * AFTER the target insert is durably committed and BEFORE the source retirement rename. A
22
- * throw here lands inside the two-rename window, which is exactly the half-applied state the
23
- * intent record and next-run repair must resolve.
19
+ * TEST-ONLY fault-injection seam (ticket-04b §3): invoked inside a correlated write transaction
20
+ * AFTER the first file's rename is durably committed and BEFORE the next file's rename. A throw
21
+ * here lands inside the rename window — the cut path's target-then-sources order, or a multi-file
22
+ * edit's plan order — which is exactly the half-applied state the intent record and next-run
23
+ * repair must resolve.
24
24
  */
25
25
  onCutBetweenWrites?: (committedAbsolutePath: string) => void | Promise<void>;
26
26
  /**
27
- * TEST-ONLY observation seam (ticket-04b remediation-2 R2): invoked inside a foreign-source cut
28
- * transaction BEFORE the first rename, while no byte of the transaction has landed. The rule
29
- * under test is write-AHEAD: the intent record must already be durable at the moment of the
30
- * first mutation — an existence check taken inside the window cannot show that.
27
+ * TEST-ONLY observation seam (ticket-04b remediation-2 R2): invoked inside a correlated write
28
+ * transaction (a foreign-source cut, or a multi-file edit) BEFORE the first rename, while no byte
29
+ * of the transaction has landed. The rule under test is write-AHEAD: the intent record must
30
+ * already be durable at the moment of the first mutation — an existence check taken inside the
31
+ * window cannot show that.
31
32
  */
32
33
  onBeforeFirstCutWrite?: () => void | Promise<void>;
34
+ /**
35
+ * TEST-ONLY mid-member fault seam: called after a move's rename is durable and before its
36
+ * destination content is rewritten, so the rollback of that in-flight member is exercised.
37
+ */
38
+ onAfterMoveRename?: (toAbsolutePath: string) => void | Promise<void>;
39
+ }
40
+ /**
41
+ * The honest outcome of a failed multi-member commit's rollback attempt. `restored` members are
42
+ * proven back at their pre-images; `remaining` members were not rolled back; `uncertain` members
43
+ * are those whose rollback attempt itself threw, so neither state can be claimed for them.
44
+ */
45
+ export interface TransactionRecoveryReport {
46
+ restored: string[];
47
+ remaining: string[];
48
+ uncertain: string[];
33
49
  }
34
50
  /**
35
51
  * (ticket-04b) One foreign `mode: "cut"` item as seen by the pre-pass: the submitted span anchors
@@ -43,6 +59,26 @@ export interface ForeignCutRecord {
43
59
  spanFrom: string;
44
60
  spanTo: string;
45
61
  }
62
+ /**
63
+ * The file-lifecycle action one transaction member carries.
64
+ *
65
+ * This is the engine-local vocabulary: apply_patch's plan IR maps onto it in
66
+ * `src/apply-patch/commit.ts` so the mutation engine never imports the apply-patch layer.
67
+ * Absent is an ordinary in-place edit.
68
+ */
69
+ export type MemberLifecycle = {
70
+ kind: "keep";
71
+ } | {
72
+ kind: "add";
73
+ createdDirs: readonly string[];
74
+ } | {
75
+ kind: "delete";
76
+ } | {
77
+ kind: "move";
78
+ fromAbsolutePath: string;
79
+ toAbsolutePath: string;
80
+ createdDirs: readonly string[];
81
+ };
46
82
  export interface ProcessedEditFile {
47
83
  path: string;
48
84
  absolutePath: string;
@@ -86,53 +122,9 @@ export interface ProcessedEditFile {
86
122
  * correlated two-file transaction; the in-memory buffer itself never touches these files.
87
123
  */
88
124
  foreignCuts: ForeignCutRecord[];
89
- }
90
- export interface MutationSuccess {
91
- ok: true;
92
- /** SAFETY: Normalized result content (LF). */
93
- result: string;
94
- /** SAFETY: Unified diff (hash-anchored) for model consumption. */
95
- diff: string;
96
- /** SAFETY: User-facing drift notice, if any (details only, not model content). */
97
- drift: string | undefined;
98
- /** SAFETY: Metrics for telemetry. */
99
- metrics: RMetrics;
100
- raw: ProcessedEditFile;
101
- /** SAFETY: Full tool result (content + details) for pi's tool_result hook. */
102
- toolResult: {
103
- content: Array<{
104
- type: "text";
105
- text: string;
106
- }>;
107
- details: EditDetails;
108
- };
109
- }
110
- export interface MutationFailure {
111
- ok: false;
112
- /**
113
- * SAFETY: Machine code — a registry member (`DomainErrorCode`). `toFailure` validates every
114
- * caught code through the envelope reader (`src/error-envelope.ts`): non-registry strings
115
- * (errno pass-through included) route to `E_UNKNOWN`, so the union is honest.
116
- */
117
- code: DomainErrorCode;
118
- /** SAFETY: Human message — model-facing signal when applicable. */
119
- message: string;
120
- /** SAFETY: Fresh served block for retry when available (reject-and-serve). */
121
- servedBlock?: string;
122
- servedRows?: ServedRow[];
123
125
  /**
124
- * SAFETY: the RAW `E_BAD_PAYLOAD` message behind a rendered refusal, when the route is one, so
125
- * SAFETY: the tool seam can name the invoking tool instead of falling back to the neutral
126
- * SAFETY: wording. Present only for `code: "E_BAD_PAYLOAD"`.
126
+ * apply_patch lifecycle action for this member; absent is an ordinary edit. The correlated
127
+ * transaction reads it for pre-image capture, commit ordering, rollback and undo-row shape.
127
128
  */
128
- payloadMessage?: string;
129
- /** SAFETY: User-facing diagnosis, never a model remedy — a GLOSSARY.md glossary term. */
130
- cause?: RangeCause;
131
- details?: {
132
- code: DomainErrorCode;
133
- cause: RangeCause;
134
- };
129
+ lifecycle?: MemberLifecycle;
135
130
  }
136
- export type MutationResult = MutationSuccess | MutationFailure;
137
- export declare function isMutationSuccess(r: MutationResult): r is MutationSuccess;
138
- export declare function isMutationFailure(r: MutationResult): r is MutationFailure;
@@ -17,8 +17,10 @@ export interface GrammarManifestEntry {
17
17
  }
18
18
  export type GrammarManifest = Readonly<Record<string, GrammarManifestEntry>>;
19
19
  /**
20
- * TypeScript/TSX grammars from the pinned tree-sitter-typescript release. Both are fetched from a
21
- * versioned URL and verified against the committed digest before they reach the parser.
20
+ * Pinned grammars: TypeScript/TSX from the tree-sitter-typescript release, Python/Go/Rust from their
21
+ * own releases, Java from the tree-sitter-java release and Vue from the tree-sitter-vue-wasm npm
22
+ * package. Every entry is fetched from a versioned URL and verified against the committed digest
23
+ * before it reaches the parser.
22
24
  */
23
25
  export declare const GRAMMAR_MANIFEST: GrammarManifest;
24
26
  /** The manifest entry for a source path, or undefined when no grammar is supported yet. */
@@ -0,0 +1,21 @@
1
+ /** Java vocabulary for the shared scope-decoration walk. */
2
+ import type { DecorationSpec } from "./scope-decoration.js";
3
+ /**
4
+ * Java declaration nodes and their note kinds. A record is a class-like declaration, so it shares
5
+ * the `class` label; a constructor is its own protocol kind and is never relabelled as a method.
6
+ */
7
+ export declare const JAVA_SPAN_KINDS: {
8
+ readonly class_declaration: "class";
9
+ readonly record_declaration: "class";
10
+ readonly interface_declaration: "interface";
11
+ readonly enum_declaration: "enum";
12
+ readonly method_declaration: "method";
13
+ readonly constructor_declaration: "constructor";
14
+ readonly compact_constructor_declaration: "constructor";
15
+ };
16
+ /**
17
+ * A Java declaration carries its modifiers (annotations included) inside its own node, so the
18
+ * span already starts at the first annotation. Only a directly preceding JavaDoc block needs the
19
+ * leading-sibling walk; ordinary `//` and `/*` comments are not documentation and stay outside.
20
+ */
21
+ export declare const JAVA_DECORATION: DecorationSpec;
@@ -36,9 +36,13 @@ export interface SpanProvider {
36
36
  }
37
37
  /**
38
38
  * Only a folded requested span at or below this size triggers expansion. Folding happens first, so
39
- * sixteen adjacent 24-line windows are one 384-line span and refuse the trigger.
39
+ * sixteen adjacent windows are one span and refuse the trigger.
40
+ *
41
+ * WHY 61: measured against the working corpus — 25-40 line reads occurred 66 times, 41-60 38 times,
42
+ * 61-100 38 times and >200 once, with a median refused read of 61 lines. At the previous 24-line
43
+ * trigger expansion engaged on only 9.6% of reads; the cap, not this trigger, still bounds the cost.
40
44
  */
41
- export declare const TRIGGER_MAX = 24;
45
+ export declare const TRIGGER_MAX = 61;
42
46
  /** Code scopes may widen to a function or method; this ceiling is refused, never clamped. */
43
47
  export declare const SPAN_CAP_CODE = 80;
44
48
  /** Markdown sections clamp to a cap-sized window that still contains the requested window. */
@@ -5,7 +5,8 @@
5
5
  * `SPAN_KIND_TO_LSP` carries the canonical PascalCase name next to it. Six extensions cover
6
6
  * concepts the protocol cannot express honestly: `arrow` (an anonymous function assigned to a
7
7
  * name), `typeAlias`, `impl` and `trait` (Rust items; mapping them to `Object` would be a lie),
8
- * and the non-AST prose units `section` and `paragraph`.
8
+ * and the non-symbol document units `section` (Markdown sections and Vue SFC blocks) and
9
+ * `paragraph` (plain-text paragraphs).
9
10
  *
10
11
  * Display stays lowercase because the note is model-facing prose, not a protocol symbol. The two
11
12
  * camel-case members `enumMember` and `typeParameter` are rendered as `enum member` and
@@ -31,7 +32,7 @@ export declare function isSpanKind(value: unknown): value is SpanKind;
31
32
  /** The model-facing note label; the audit keeps the union spelling. */
32
33
  export declare function spanKindLabel(kind: SpanKind): string;
33
34
  /** Kinds a current provider table or non-AST emitter can produce; not the whole protocol. */
34
- export declare const PRODUCED_KINDS: readonly ["function", "method", "class", "interface", "enum", "namespace", "arrow", "impl", "trait", "typeAlias", "section", "paragraph"];
35
+ export declare const PRODUCED_KINDS: readonly ["function", "method", "class", "interface", "enum", "namespace", "arrow", "impl", "trait", "typeAlias", "section", "paragraph", "constructor"];
35
36
  /** One language's grammar-node-type to note-kind data table. */
36
37
  export type SpanKindTable = Readonly<Record<string, SpanKind>>;
37
38
  export {};
@@ -0,0 +1,14 @@
1
+ import type { DecorationSpec } from "./scope-decoration.js";
2
+ /**
3
+ * A `.vue` file is one component made of top-level document sections. The note vocabulary has no
4
+ * template/style symbol and `component` is the file root (never a widening target), so the three
5
+ * SFC block nodes share the existing `section` extension and are distinguished by their names.
6
+ * Embedded JavaScript/TypeScript is raw text in this grammar, so a script section is never parsed
7
+ * or widened beyond its own block.
8
+ */
9
+ export declare const VUE_SPAN_KINDS: {
10
+ readonly template_element: "section";
11
+ readonly script_element: "section";
12
+ readonly style_element: "section";
13
+ };
14
+ export declare const VUE_DECORATION: DecorationSpec;
@@ -14,7 +14,13 @@ export interface FileSnapshotContext {
14
14
  export declare function buildRangeServeRows(startLine: number, endLine: number, fileHashes: string[]): ServedRow[];
15
15
  export declare function denseServeRows(hashes: readonly string[]): ServedRow[];
16
16
  export declare function fmtServedRows(rows: ServedRow[], fileLines: string[]): string;
17
+ export declare function paginationHint(nextOffset: number, more: number): string;
17
18
  export declare function servedPositionsOf(served: (string | null)[], hash: string): number[];
19
+ /** Builds the reject-and-serve served rows + rendered block for a current on-disk range. */
20
+ export declare function buildRangeServeBlock(startLine: number, endLine: number, fileHashes: string[], fileLines: string[]): {
21
+ servedRows: ServedRow[];
22
+ rendered: string;
23
+ };
18
24
  /**
19
25
  * Builds an `[E_TARGET_LOST]` rejection for a retired leased identity whose range cannot be
20
26
  * identified (spec stale-identity-reject-and-serve D1/D6, ADR-0018 decisions 1-2). The payload
@@ -45,9 +51,9 @@ export declare function makeServedRejection(opts: {
45
51
  }): DomainError<"E_STALE_RANGE" | "E_UNVERIFIED_RANGE">;
46
52
  /**
47
53
  * Builds the `[E_STALE_ANCHOR]` reject-and-serve rejection for a boundary anchor the session holds
48
- * no lease for. It serves the rows under `Current range:` with the retry hint (spec §5.3): the
49
- * served rows are themselves serves, so the retry needs no `read`. Distinct from the range-family
50
- * codes, which serve a fresh read with no mandate (issue #149).
54
+ * no lease for. It serves the rows under `Current range:` as evidence with no retry mandate: the
55
+ * served rows are themselves serves, so deciding from them needs no `read` (same fresh-read rule as
56
+ * the range-family codes, issue #149). The registry renders the block at most once.
51
57
  */
52
58
  export declare function makeStaleAnchorRejection(opts: {
53
59
  headline: string;
@@ -25,7 +25,23 @@ export interface UndoRecord {
25
25
  * `null` is a row from before this column, or an ordinary single-file edit.
26
26
  */
27
27
  rawPre?: string | null;
28
+ /**
29
+ * The lifecycle action this row reverses. Absent/null is an ordinary edit (back-compatible).
30
+ * add = the file did not exist before; delete = the file does not exist after; move = a pure
31
+ * rename with an optional content edit.
32
+ */
33
+ lifecycle?: UndoLifecycle | null;
34
+ /** Move: the absolute path the file had before the transaction. */
35
+ fromPath?: string | null;
36
+ /** Move: the absolute path the file has after the transaction. */
37
+ toPath?: string | null;
38
+ /** The RAW post-transaction text when the commit wrote non-canonical bytes (pure move). */
39
+ rawPost?: string | null;
40
+ /** Add: the directories the add created, outermost first, JSON-encoded in the column. */
41
+ createdDirs?: readonly string[] | null;
28
42
  }
43
+ /** The lifecycle action one undo row reverses. */
44
+ export type UndoLifecycle = "edit" | "add" | "delete" | "move";
29
45
  export type CutIntentDirection = "revert" | null;
30
46
  export interface CutIntent {
31
47
  txnId: string;
@@ -33,7 +49,7 @@ export interface CutIntent {
33
49
  direction: CutIntentDirection;
34
50
  }
35
51
  export interface UndoStmts {
36
- undoUpsert: (path: string, content: string, bom: string, ending: string, hashes: string, resultContent: string, snapshotHash: string | null, transactionId: string | null, rawPre: string | null, anchorGeneration: number, updatedAt: number) => void;
52
+ undoUpsert: (path: string, content: string, bom: string, ending: string, hashes: string, resultContent: string, snapshotHash: string | null, transactionId: string | null, rawPre: string | null, lifecycle: string | null, fromPath: string | null, toPath: string | null, rawPost: string | null, createdDirs: string | null, anchorGeneration: number, updatedAt: number) => void;
37
53
  undoGet: (path: string) => Record<string, unknown> | undefined;
38
54
  undoDelete: (path: string) => void;
39
55
  undoGetTransaction: (transactionId: string) => Record<string, unknown>[];
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "dsh-hashline-edit",
3
- "version": "0.1.0",
4
- "description": "Hash-anchored read/edit/undo_last_edit tools for DeepSeek Harness (dsh). Every line gets a unique 4-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
3
+ "version": "0.2.0",
4
+ "description": "Hash-anchored read/apply_patch tools for DeepSeek Harness (dsh). Every line gets a unique 4-char hash (A-Za-z0-9) that stays stable across edits; apply_patch is the only file-mutation entry point and commits one patch batch per transaction. Undo persists across restarts.",
5
5
  "keywords": [
6
6
  "anchors",
7
+ "apply-patch",
7
8
  "coding-agent",
8
9
  "deepseek-harness",
9
10
  "dsh",
10
11
  "dsh-plugin",
11
- "edit",
12
12
  "file-editing",
13
13
  "hash",
14
14
  "hash-anchored",
@@ -56,17 +56,8 @@
56
56
  "format:fix": "oxfmt .",
57
57
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.dsh.json",
58
58
  "build": "node scripts/build-dsh.mjs",
59
- "build:pi": "node scripts/build-dist.mjs",
60
59
  "prepublishOnly": "node scripts/build-dsh.mjs && pnpm run typecheck && pnpm run lint && pnpm run test:win",
61
60
  "canon:audit": "node scripts/canon-churn-audit.mjs",
62
- "prompts:show": "node scripts/served-prompts.mjs",
63
- "replay:read-expansion": "node scripts/read-expansion-replay.mjs",
64
- "test:runtime": "node scripts/runtime-edge-test.mjs",
65
- "verify:dist": "node scripts/verify-dist.mjs",
66
- "verify:dist:mutations": "node scripts/verify-dist-mutations.mjs",
67
- "measure:import": "node scripts/measure-import.mjs --max-ratio 0.75",
68
- "smoke:install": "node scripts/install-smoke.mjs",
69
- "prepare": "node scripts/prepare.mjs",
70
61
  "prepack": "node scripts/build-dsh.mjs",
71
62
  "audit": "pnpm audit --audit-level high --registry https://registry.npmjs.org/",
72
63
  "verify": "pnpm run lint && pnpm run format && pnpm run typecheck && pnpm run test:win:coverage"
@@ -74,8 +65,6 @@
74
65
  "dependencies": {
75
66
  "diff": "^8.0.2",
76
67
  "file-type": "^21.3.0",
77
- "iconv-lite": "^0.6.3",
78
- "typebox": "^1.3.27",
79
68
  "unbash": "5.0.0",
80
69
  "xxhash-wasm": "^1.1.0"
81
70
  },
@@ -83,14 +72,12 @@
83
72
  "@babel/parser": "7.29.8",
84
73
  "@commitlint/cli": "^21.2.2",
85
74
  "@commitlint/config-conventional": "^21.2.2",
86
- "@deepseek-ai/cordis": "^4.0.5",
87
- "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
88
- "@deepseek-ai/dsh-fs": "0.2.1-rc.2",
89
- "@deepseek-ai/dsh-home-paths": "0.2.1-rc.2",
90
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
91
- "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
92
- "@earendil-works/pi-coding-agent": "^0.84.0",
93
- "@earendil-works/pi-tui": "*",
75
+ "@deepseek-ai/cordis": "4.0.6-alpha.2",
76
+ "@deepseek-ai/dsh-agent": "0.2.2-alpha.3",
77
+ "@deepseek-ai/dsh-fs": "0.2.2-alpha.3",
78
+ "@deepseek-ai/dsh-home-paths": "0.2.2-alpha.3",
79
+ "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.3",
80
+ "@deepseek-ai/dsh-tools": "0.2.2-alpha.3",
94
81
  "@semantic-release/git": "^11.0.1",
95
82
  "@semantic-release/github": "^12.0.9",
96
83
  "@semantic-release/npm": "^13.1.5",
@@ -110,6 +97,7 @@
110
97
  "web-tree-sitter": "0.27.1"
111
98
  },
112
99
  "engines": {
100
+ "dsh": ">=0.2.2-alpha.3 <0.2.3-0",
113
101
  "node": ">=24.0.0"
114
102
  },
115
103
  "packageManager": "pnpm@12.0.0",
@@ -0,0 +1,10 @@
1
+ - apply_patch: one call = one transaction. Collect every related file change (code, tests, docs, config) into one patch; split only when an earlier RESULT must decide a later edit.
2
+ - apply_patch: two addressing modes. Fragment mode (`@@` or `@@ <context>`) locates by exact content and needs no served state. Identity mode (`@@<a>` / `@@<a>-<b>`) targets rows this session served and never writes the old text back.
3
+ - apply_patch: the character after `@@` selects the mode. `@@` followed immediately by exactly four anchor characters is identity mode; `@@` followed by a space is Codex context mode (`@@ <line>`); a bare `@@` starts the search at the file start. Any other token is a syntax rejection.
4
+ - apply_patch: fragment mode must match exactly once. Zero or several hits reject and serve the candidate rows; add surrounding context to disambiguate, never guess.
5
+ - apply_patch: identity mode accepts only `+` lines; an empty body deletes the span. A `-` or context line in an identity hunk, or body lines in a Copy/Cut hunk, rejects at admission with its patch line number.
6
+ - apply_patch: every hunk resolves against the file as it was before the call; later windows begin after the previous hunk's end. Overlapping target ranges in one file reject the whole batch.
7
+ - apply_patch: lifecycle joins the same transaction and undo. `*** Add File:` content lines all start with `+`; `*** Delete File:` removes the file; `*** Move to:` must immediately follow `*** Update File:` and renames before its hunks apply; an existing move target rejects.
8
+ - apply_patch: a rejection writes nothing. Read the `- patch line N ([E_*], path): ...` lines, then use the immediately following `HASH│content` block — those are the current rows and anchors; retry directly without issuing a separate `read`.
9
+ - apply_patch: a success starts with `Updated N files:` and ends with the changed lines' fresh anchors per file; use those anchors directly in the next identity hunk. `[W_*]` lines are informational.
10
+ - apply_patch: paths are workspace-relative or workspace-absolute; `..`, `.git`, and symlink targets reject. Whole-file replacement stays with `write`.
@@ -0,0 +1 @@
1
+ Apply one Codex-format patch to one or more files in a single transaction: `{ input: string }`. `input` is the complete patch text: `*** Begin Patch` ... `*** End Patch`, with `*** Add File:`, `*** Delete File:`, `*** Update File:` (plus optional `*** Move to:`) and hunks addressed either by content (`@@` or `@@ <context>` with ` `/`-`/`+` lines) or by served anchors (`@@<a>` / `@@<a>-<b>` with `+` lines only). `*** Copy From:` / `*** Cut From:` copy or move a served span from another file. Every target validates before anything is written; a rejection changes nothing and returns the current rows with anchors. On success the result lists the changed files and the new anchors for the changed lines, so a follow-up call needs no `read`. `write` stays for whole-file replacement.
@@ -1,6 +1,6 @@
1
- - read: call it only for information the tool never served you, such as content past the auto-read diff. A bash view or an auto-read already served the file, so edit from those rows.
2
- - read: when you need several blocks or both boundaries of one replacement, put every range in ONE call. Use `{ file, windows: [{ offset, limit }] }`. Each window is served in that turn, so its anchors work in the same edit. A window can also be `{ around_anchor, radius }` to centre on a line this session already served. An anchor outside this session's served rows is omitted with an inline `[Window warning: ...]`.
3
- - read: a requested window of 24 lines or fewer can widen to its enclosing block; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. Widened rows begin with `[widened <requested> -> <served>, <label>]`; above three requested windows the note is `[widened N windows: <ranges>]`. An over-cap Markdown section clamps and names `(section <extent>)`; over-cap code or plain text refuses with no note. `mode: "verbatim"` widens too, with no anchors.
4
- - read: when you will not edit the file, pass `mode: "verbatim"` for plain content with no anchors. Examples are a config value, docs, a README, or skill content (a SKILL.md or any file in its directory). Omit `mode` when you intend to edit, so the rows carry anchors.
5
- - read: send one file per call as `{ file, windows?, mode? }`, the same way `edit` does. The single top-level `mode` applies to the file. To read several files, make parallel `read` calls.
1
+ - read: call it only for information the tool never served you, such as content past the auto-read diff. A bash view or an auto-read already served the file, so apply_patch from those rows.
2
+ - read: when you need several blocks or both boundaries of one replacement, put every range in ONE call. Use `{ file, windows: [{ offset, limit }] }`. Each window is served in that turn, so its anchors work in the same apply_patch call. A window can also be `{ around_anchor, radius }` to centre on a line this session already served. An anchor outside this session's served rows is omitted with an inline `[Window warning: ...]`.
3
+ - read: a requested window of 61 lines or fewer can widen to its enclosing block; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. Widened rows begin with `[widened <requested> -> <served>, <label>]`; above three requested windows the note is `[widened N windows: <ranges>]`. An over-cap Markdown section clamps and names `(section <extent>)`; over-cap code or plain text refuses with no note. `mode: "verbatim"` widens too, with no anchors.
4
+ - read: when you will not apply_patch the file, pass `mode: "verbatim"` for plain content with no anchors. Examples are a config value, docs, a README, or skill content (a SKILL.md or any file in its directory). Omit `mode` when you intend to use the anchors with apply_patch, so the rows carry anchors.
5
+ - read: send one file per call as `{ file, windows?, mode? }`, the same way `apply_patch` does. The single top-level `mode` applies to the file. To read several files, make parallel `read` calls.
6
6
  - read: PNG/JPG images return as image attachments for multimodal models. Request one with `file` like any other file.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read one text file via `file`, with optional `windows` and top-level `mode`. To read several files, make parallel `read` calls. Served mode (default) returns each line as HASH│content with a 4-char alphanumeric hash. That hash is the `edit` anchor, so copy only the 4 chars before `│`. Pass `mode: "verbatim"` for plain text with no anchors when you will not edit. `mode: "verbatim"` is not capped by line budget. Name ranges with `windows`: `[{ offset, limit }]` (offset is 1-indexed, limit is a line count, maximum 16 windows) or `[{ around_anchor: "a1b2", radius: 10 }]` for a line this session served. When a page stops short, continue from the next unshown line with `windows: [{ offset, limit }]`. A requested window of 24 lines or fewer can widen to its enclosing block before serving; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. A widened page begins with `[widened <requested> -> <served>, <label>]`; more than three requested windows collapse to `[widened N windows: <ranges>]`. An over-cap Markdown section clamps to a cap-sized window and names `(section <extent>)`; an over-cap code or plain-text block refuses, serving the requested window. `mode: "verbatim"` widens the same way but adds no anchors. Inline `[...]` notices describe the served result, not file content. Empty files return `HASH│` (edit to insert). PNG/JPG/GIF/WebP return as image attachments for multimodal models. Binary and directory files are rejected. Hashes are file-scoped: an anchor is bound to the file it was served from.
1
+ Read one text file via `file`, with optional `windows` and top-level `mode`. To read several files, make parallel `read` calls. Served mode (default) returns each line as HASH│content with a 4-char alphanumeric hash. That hash is the `apply_patch` identity anchor, so copy only the 4 chars before `│`. Pass `mode: "verbatim"` for plain text with no anchors when you will not apply_patch. `mode: "verbatim"` is not capped by line budget. Name ranges with `windows`: `[{ offset, limit }]` (offset is 1-indexed, limit is a line count, maximum 16 windows) or `[{ around_anchor: "a1b2", radius: 10 }]` for a line this session served. When a page stops short, continue from the next unshown line with `windows: [{ offset, limit }]`. A requested window of 61 lines or fewer can widen to its enclosing block before serving; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. A widened page begins with `[widened <requested> -> <served>, <label>]`; more than three requested windows collapse to `[widened N windows: <ranges>]`. An over-cap Markdown section clamps to a cap-sized window and names `(section <extent>)`; an over-cap code or plain-text block refuses, serving the requested window. `mode: "verbatim"` widens the same way but adds no anchors. Inline `[...]` notices describe the served result, not file content. Empty files return `HASH│` (use an apply_patch identity hunk to insert). PNG/JPG/GIF/WebP return as image attachments for multimodal models. Binary and directory files are rejected. Hashes are file-scoped: an anchor is bound to the file it was served from.
@@ -1,2 +1,2 @@
1
- - undo_last_edit: reverts the most recent `edit` call on the file at `path`, and every item of its `edits`. Its scope is the edited file. The exception is an edit that was a foreign `cut`: undo then restores the source file too. The file must still hold the edited bytes. An out-of-band write makes the call fail with `E_UNDO_STALE`, and the file stays byte-identical. Call it as soon as a result is bad.
2
- - undo_last_edit: a `write` to the file, or a later `edit`, replaces the undo entry. A read, a bash view or an out-of-band write leaves the entry in place. An edit is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep. Examples are a closing brace, an import, or a declaration.
1
+ - undo_last_edit: reverts the most recent `apply_patch` batch that touched the file at `path`, and every member of that batch: text hunks plus file lifecycle actions (add/delete/move). The exception is a foreign `cut`: undo then restores the source file too. The file must still hold the edited bytes. An out-of-band write makes the call fail with `E_UNDO_STALE`, and the file stays byte-identical. Call it as soon as a result is bad.
2
+ - undo_last_edit: a `write` to the file, or a later `apply_patch` batch, replaces the undo entry. A read, a bash view or an out-of-band write leaves the entry in place. An apply_patch result is bad when its diff shows `-HASH│` rows for lines you meant to keep. Examples are a closing brace, an import, or a declaration.