dsh-patch-edit-plus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tool.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The single model-facing `apply_patch` tool definition.
3
+ *
4
+ * The description carries an explicit routing guide so the model reaches for
5
+ * the native `edit` tool on cheap single-point changes instead of paying for
6
+ * a patch, and reserves `apply_patch` for large-file small edits, cross-file
7
+ * batch edits, creation, deletion and renames.
8
+ * @module dsh-patch-edit-plus/tool
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { PatchError, codexDisabledError, limitError, unifiedDisabledError } from './errors.js';
12
+ import { detectFormat } from './detect.js';
13
+ import { parseCodexPatch } from './parse/codex.js';
14
+ import { parseUnifiedDiff } from './parse/unified.js';
15
+ import { buildPlan } from './apply/plan.js';
16
+ import { applyPatch, verifyPatch } from './apply/engine.js';
17
+ const ROUTING_GUIDE = [
18
+ 'When to use which editing tool:',
19
+ '- Single small change in ONE already-read file: prefer the native edit tool (cheaper than a patch).',
20
+ '- apply_patch is the right choice when: (a) a SMALL change sits inside a LARGE file (a patch avoids echoing the whole file), '
21
+ + '(b) the same call must change MANY files at once (batch), (c) a new file with substantial content is created, '
22
+ + '(d) a file is deleted or renamed, or (e) several disjoint spots in one file change at once.',
23
+ '- Do not use apply_patch for trivial one-line single-file edits when the native edit tool is available.',
24
+ ].join('\n');
25
+ /**
26
+ * Build the (frozen) tool definition. The tool name is passed in resolved
27
+ * form so registration-side rename avoidance can mint variant names.
28
+ */
29
+ export function buildApplyPatchTool(toolName, ctx, cfg) {
30
+ const styles = [
31
+ cfg.allowUnifiedDiff ? 'git/unified diff (default)' : null,
32
+ cfg.allowCodexPatch ? 'Codex apply_patch syntax' : null,
33
+ ].filter(style => style !== null).join(' and ');
34
+ const definition = defineTool({
35
+ name: toolName,
36
+ description: `Apply a patch-style change to one or more text files, all-or-nothing: every hunk is verified against current file content first, and nothing is written unless the whole patch applies. Accepts ${styles}. `
37
+ + 'Add, update (multi-hunk), delete and move/rename operations are supported. Pass dryRun: true to validate without writing.\n' + ROUTING_GUIDE,
38
+ parameters: {
39
+ patch: { type: 'string', required: true, description: 'The full patch text in an accepted style.' },
40
+ dryRun: { type: 'boolean', description: 'Validate the patch and report what would change, without writing.' },
41
+ },
42
+ output: {
43
+ schema: {
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ properties: {
47
+ format: { type: 'string', required: true, enum: ['unified', 'codex'] },
48
+ applied: { type: 'boolean', required: true },
49
+ summary: { type: 'string', required: true },
50
+ files: {
51
+ type: 'array',
52
+ required: true,
53
+ items: {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ path: { type: 'string', required: true },
58
+ action: { type: 'string', required: true, enum: ['added', 'modified', 'deleted'] },
59
+ from: { type: 'string', description: 'Original path when the file was moved or renamed.' },
60
+ },
61
+ },
62
+ },
63
+ stats: {
64
+ type: 'object',
65
+ additionalProperties: false,
66
+ required: true,
67
+ properties: {
68
+ files: { type: 'number', required: true },
69
+ added: { type: 'number', required: true },
70
+ removed: { type: 'number', required: true },
71
+ },
72
+ },
73
+ wallTimeMs: { type: 'number', required: true },
74
+ diffs: {
75
+ type: 'array',
76
+ required: true,
77
+ items: {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ properties: {
81
+ path: { type: 'string', required: true },
82
+ oldText: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
83
+ newText: { type: 'string', required: true },
84
+ truncated: { type: 'boolean', description: 'Diff content dropped because it exceeded maxDiffBytes.' },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ },
90
+ render: (_args, value) => [{ type: 'text', text: formatResultText(value) }],
91
+ presentationMeta: (_args, value) => ({ diffs: value.diffs }),
92
+ },
93
+ async execute(args, exec) {
94
+ const start = Date.now();
95
+ const patch = args.patch;
96
+ if (typeof patch !== 'string' || patch.trim() === '') {
97
+ throw new PatchError('VALIDATION', 'parameter "patch" must be a non-empty string containing the patch text');
98
+ }
99
+ if (patch.length > cfg.maxPatchBytes) {
100
+ throw limitError('patch size in bytes', patch.length, cfg.maxPatchBytes);
101
+ }
102
+ const format = detectFormat(patch);
103
+ if (format === 'codex' && !cfg.allowCodexPatch)
104
+ throw codexDisabledError();
105
+ if (format === 'unified' && !cfg.allowUnifiedDiff)
106
+ throw unifiedDisabledError();
107
+ const parsed = format === 'codex' ? parseCodexPatch(patch) : parseUnifiedDiff(patch);
108
+ const dryRun = typeof args.dryRun === 'boolean'
109
+ ? args.dryRun
110
+ : cfg.dryRunByDefault;
111
+ const plan = await buildPlan(ctx, exec, parsed, cfg);
112
+ const changes = await verifyPatch(ctx, exec, plan, cfg);
113
+ if (!dryRun) {
114
+ const sandboxPolicy = undefined;
115
+ await applyPatch(ctx, exec, changes, cfg, sandboxPolicy);
116
+ }
117
+ return buildResult(format, !dryRun, changes, cfg, Date.now() - start);
118
+ },
119
+ presentCall: (args) => ({
120
+ card: 'generic',
121
+ title: 'Apply patch',
122
+ kind: 'other',
123
+ rawInput: args.patch,
124
+ }),
125
+ presentResult: (_args, result) => {
126
+ if (result.isError)
127
+ return undefined;
128
+ const diffs = extractDiffs(result.meta);
129
+ if (diffs.length === 0)
130
+ return { card: 'generic', title: 'Applied patch' };
131
+ return { card: 'diff', title: 'Applied patch', diffs };
132
+ },
133
+ });
134
+ return Object.freeze(definition);
135
+ }
136
+ /** Narrow the persisted presentation meta back to diffs (nested dispatch has no meta). */
137
+ function extractDiffs(meta) {
138
+ if (typeof meta !== 'object' || meta === null)
139
+ return [];
140
+ const candidate = meta.diffs;
141
+ if (!Array.isArray(candidate))
142
+ return [];
143
+ const diffs = candidate.filter((item) => typeof item === 'object' && item !== null
144
+ && typeof item.path === 'string'
145
+ && typeof item.newText === 'string');
146
+ return diffs;
147
+ }
148
+ /** Build the canonical result value (grouped Codex-style + capped diffs). */
149
+ function buildResult(format, applied, changes, cfg, wallTimeMs) {
150
+ const files = [];
151
+ let added = 0;
152
+ let removed = 0;
153
+ const diffs = [];
154
+ for (const change of changes) {
155
+ const { op } = change.entry;
156
+ if (op.kind === 'add') {
157
+ added += op.lines.length;
158
+ files.push({ path: op.path, action: 'added' });
159
+ diffs.push(cappedDiff(op.path, null, change.after ?? '', cfg.maxDiffBytes));
160
+ continue;
161
+ }
162
+ if (op.kind === 'delete') {
163
+ removed += change.before === null ? 0 : change.before.split('\n').length;
164
+ files.push({ path: op.path, action: 'deleted' });
165
+ diffs.push(cappedDiff(op.path, change.before ?? '', '', cfg.maxDiffBytes));
166
+ continue;
167
+ }
168
+ const moved = change.entry.moveToTarget !== undefined;
169
+ files.push(moved
170
+ ? { path: op.moveTo ?? op.path, action: 'modified', from: op.path }
171
+ : { path: op.path, action: 'modified' });
172
+ if (change.before !== null && change.after !== null && !(moved && change.after === change.before)) {
173
+ for (const hunk of op.hunks) {
174
+ added += hunk.lines.filter(line => line.kind === 'add').length;
175
+ removed += hunk.lines.filter(line => line.kind === 'delete').length;
176
+ }
177
+ diffs.push(cappedDiff(moved ? op.moveTo ?? op.path : op.path, change.before, change.after, cfg.maxDiffBytes));
178
+ }
179
+ }
180
+ const groups = (action) => files.filter(file => file.action === action);
181
+ const verb = applied ? 'Applied patch' : 'Dry run: the patch applies cleanly';
182
+ const summary = `${verb}: ${files.length} file(s), ${groups('added').length} added, ${groups('modified').length} modified, ${groups('deleted').length} deleted, +${added} -${removed} lines`;
183
+ return { format, applied, summary, files, stats: { files: files.length, added, removed }, wallTimeMs, diffs };
184
+ }
185
+ /** Cap one file diff: over the limit the contents are emptied and the entry marked truncated. */
186
+ function cappedDiff(path, oldText, newText, maxDiffBytes) {
187
+ if (oldText === null && newText === '')
188
+ return { path, oldText: null, newText: '' };
189
+ const size = (oldText?.length ?? 0) + newText.length;
190
+ if (size > maxDiffBytes)
191
+ return { path, oldText: null, newText: '', truncated: true };
192
+ return { path, oldText, newText };
193
+ }
194
+ /** Model-facing text: grouped added → modified → deleted listing. */
195
+ export function formatResultText(value) {
196
+ const lines = [value.summary];
197
+ const group = (action, letter) => {
198
+ for (const file of value.files.filter(f => f.action === action)) {
199
+ lines.push(`${letter} ${file.path}${file.from !== undefined ? ` (from ${file.from})` : ''}`);
200
+ }
201
+ };
202
+ group('added', 'A');
203
+ group('modified', 'M');
204
+ group('deleted', 'D');
205
+ if (!value.applied)
206
+ lines.push('Nothing was written (dry run).');
207
+ return lines.join('\n');
208
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Two-phase atomic application engine.
3
+ *
4
+ * Phase 1 (verify) reads every target and computes the full in-memory result;
5
+ * ANY failure aborts the whole patch with zero bytes written. Phase 2 (apply)
6
+ * only writes what verify already validated.
7
+ * @module dsh-patch-edit-plus/apply/engine
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
11
+ import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
12
+ import type { ResolvedConfig } from '../config.js';
13
+ import type { Hunk } from '../parse/types.js';
14
+ import { consumedLines } from './seek.js';
15
+ import type { PlannedEntry, PlannedPatch } from './plan.js';
16
+ /** One verified, ready-to-write change. */
17
+ export interface VerifiedChange {
18
+ entry: PlannedEntry;
19
+ /** Original content; `null` for adds. */
20
+ before: string | null;
21
+ /** Computed new content; `null` for deletes. */
22
+ after: string | null;
23
+ }
24
+ /**
25
+ * Verify the whole patch: read every file, locate every hunk, compute every
26
+ * result. Throws on the first failure — nothing has been written.
27
+ */
28
+ export declare function verifyPatch(ctx: Context, exec: ToolRunContext, plan: PlannedPatch, cfg: ResolvedConfig): Promise<VerifiedChange[]>;
29
+ /** Apply phase: writes only — verify already settled every decision. */
30
+ export declare function applyPatch(ctx: Context, exec: ToolRunContext, changes: readonly VerifiedChange[], cfg: ResolvedConfig, sandboxPolicy: SandboxExecutionPolicy | undefined): Promise<void>;
31
+ /** Pure-move entries (update with moveTo and no hunks) skip the write. */
32
+ export declare function isPureMove(change: VerifiedChange): boolean;
33
+ /**
34
+ * Apply hunks to raw file content with newline fidelity: CRLF files are
35
+ * normalized for matching and written back in their original style; the
36
+ * trailing-newline state of the file tail is preserved exactly.
37
+ */
38
+ export declare function applyHunksToContent(path: string, before: string, hunks: readonly Hunk[]): string;
39
+ export { consumedLines };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Operation planning: path resolution, workspace containment, symlink policy
3
+ * and duplicate detection. Pure resolution work — no file content is read
4
+ * here (the engine's verify stage does that).
5
+ * @module dsh-patch-edit-plus/apply/plan
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
9
+ import type { FsTarget } from '@deepseek-ai/dsh-fs';
10
+ import type { ResolvedConfig } from '../config.js';
11
+ import type { ParsedPatch, PatchOp } from '../parse/types.js';
12
+ /** One planned, fully-resolved operation. */
13
+ export interface PlannedEntry {
14
+ op: PatchOp;
15
+ /** Resolved target of `op.path`. */
16
+ target: FsTarget;
17
+ /** Resolved move destination, when the operation renames/moves the file. */
18
+ moveToTarget?: FsTarget;
19
+ }
20
+ /** A fully planned patch: every path resolved and validated, nothing touched. */
21
+ export interface PlannedPatch {
22
+ format: 'unified' | 'codex';
23
+ entries: PlannedEntry[];
24
+ }
25
+ /**
26
+ * Plan a parsed patch against the filesystem backend.
27
+ * @throws {@link PatchError} on limits, path escapes, symlinks, missing parents or duplicate targets.
28
+ */
29
+ export declare function buildPlan(ctx: Context, exec: ToolRunContext, parsed: ParsedPatch, cfg: ResolvedConfig): Promise<PlannedPatch>;
30
+ /** True when the operation needs the shell backend (delete or move). */
31
+ export declare function needsShell(op: PatchOp): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Context-line location with three tolerance levels.
3
+ *
4
+ * Levels, tried in order over the whole search window (exact matches always
5
+ * win over trimmed ones): exact equality → `trimEnd` → full `trim`. Only the
6
+ * context/remove lines participate; add lines are insertions and never
7
+ * constrain the position.
8
+ * @module dsh-patch-edit-plus/apply/seek
9
+ */
10
+ import type { PatchLine } from '../parse/types.js';
11
+ /** Locate `expected` inside `fileLines` starting at `from`. Returns the match index or `null`. */
12
+ export declare function findSequence(fileLines: readonly string[], expected: readonly PatchLine[], from: number, opts?: {
13
+ endOfFile?: boolean;
14
+ }): number | null;
15
+ /**
16
+ * Number of old-side lines a hunk consumes at its match position
17
+ * (context + delete lines).
18
+ */
19
+ export declare function consumedLines(hunk: {
20
+ lines: readonly PatchLine[];
21
+ }): number;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Plugin configuration: Schemastery schema + resolved defaults.
3
+ *
4
+ * Every knob the tool honors lives here; the tool body only ever reads the
5
+ * fully-resolved `ResolvedConfig` produced by `resolveConfig`.
6
+ * @module dsh-patch-edit-plus/config
7
+ */
8
+ import z from '@deepseek-ai/schemastery';
9
+ /** What to do when the configured tool name is already taken at registration. */
10
+ export type ConflictPolicy = 'rename' | 'skip' | 'fail';
11
+ /** Backend used for Delete/Move operations (`ctx.fs` has no delete/move). */
12
+ export type DeleteBackend = 'shell' | 'none';
13
+ /** Shell command dialect for the constant delete/move templates. */
14
+ export type ShellDialect = 'auto' | 'posix' | 'pwsh';
15
+ /** Deployment-owned configuration. */
16
+ export interface Config {
17
+ /** Model-facing tool name. Defaults to `apply_patch`. */
18
+ toolName?: string;
19
+ /** Behavior when the tool name is already registered. Defaults to `rename`. */
20
+ conflictPolicy?: ConflictPolicy;
21
+ /** Suffix appended on rename avoidance. Defaults to `_1`. */
22
+ renameSuffix?: string;
23
+ /** Accept git/unified diff patches. Defaults to true. */
24
+ allowUnifiedDiff?: boolean;
25
+ /** Accept Codex `apply_patch` syntax. Defaults to false. */
26
+ allowCodexPatch?: boolean;
27
+ /** Delete/Move backend. Defaults to `shell`. `none` rejects Delete/Move with a structured error. */
28
+ deleteBackend?: DeleteBackend;
29
+ /** Shell dialect for the constant delete/move templates. Defaults to `auto` (pwsh on win32). */
30
+ shellDialect?: ShellDialect;
31
+ /** Custom delete command template; paths still arrive only via env `DSH_PATCH_TARGET`. */
32
+ deleteCommand?: string;
33
+ /** Custom move command template; paths arrive via env `DSH_PATCH_SOURCE`/`DSH_PATCH_TARGET`. */
34
+ moveCommand?: string;
35
+ /** Treat `apply_patch` calls as dry runs unless the call passes `dryRun: false`. Defaults to false. */
36
+ dryRunByDefault?: boolean;
37
+ /** Allow patch paths to traverse symbolic links. Defaults to false (symlinks are rejected). */
38
+ followSymlinks?: boolean;
39
+ /** Maximum file sections per patch. Defaults to 50. */
40
+ maxFiles?: number;
41
+ /** Maximum patch text size in bytes. Defaults to 524288 (512 KiB). */
42
+ maxPatchBytes?: number;
43
+ /** Maximum per-file diff size in bytes carried in the result metadata. Defaults to 16384. */
44
+ maxDiffBytes?: number;
45
+ }
46
+ /** Schemastery schema for Loader defaults and generated configuration docs. */
47
+ export declare const Config: z<Config>;
48
+ /** Fully-resolved configuration used by the tool body. */
49
+ export interface ResolvedConfig {
50
+ toolName: string;
51
+ conflictPolicy: ConflictPolicy;
52
+ renameSuffix: string;
53
+ allowUnifiedDiff: boolean;
54
+ allowCodexPatch: boolean;
55
+ deleteBackend: DeleteBackend;
56
+ shellDialect: ShellDialect;
57
+ deleteCommand?: string;
58
+ moveCommand?: string;
59
+ dryRunByDefault: boolean;
60
+ followSymlinks: boolean;
61
+ maxFiles: number;
62
+ maxPatchBytes: number;
63
+ maxDiffBytes: number;
64
+ }
65
+ /** Fill user config with defaults; `undefined`/null entries fall back too. */
66
+ export declare function resolveConfig(config: Config | undefined): ResolvedConfig;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Patch format detection.
3
+ *
4
+ * Runs BEFORE any parser so a recognized-but-disabled style can produce an
5
+ * actionable hint instead of a generic parse error (Codex syntax is a strong
6
+ * prior for GPT-family models; a silent failure traps them in retry loops).
7
+ * @module dsh-patch-edit-plus/detect
8
+ */
9
+ export type PatchFormat = 'unified' | 'codex';
10
+ /**
11
+ * Classify patch text. Order matters: the `*** Begin Patch` sentinel wins
12
+ * first, then explicit unified-diff markers, then Codex section headers.
13
+ * @throws {@link PatchError} code `PARSE` (with both syntax examples) when the text matches neither supported syntax.
14
+ */
15
+ export declare function detectFormat(patch: string): PatchFormat;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Error taxonomy and model-facing message builders.
3
+ *
4
+ * Every failure surfaces as a `PatchError` with a stable machine code so
5
+ * retry/permission/UI layers can branch without parsing prose. Messages are
6
+ * written for the model to self-correct: they carry the concrete file, hunk,
7
+ * position and excerpt needed to fix the patch (no stacks, no internal paths).
8
+ * @module dsh-patch-edit-plus/errors
9
+ */
10
+ /** Stable, machine-routable failure codes for patch application. */
11
+ export type PatchErrorCode =
12
+ /** The patch text is not a parseable patch in either supported syntax. */
13
+ 'PARSE'
14
+ /** The patch conflicts with on-disk state or with itself. */
15
+ | 'CONFLICT'
16
+ /** Arguments or paths are invalid. */
17
+ | 'VALIDATION'
18
+ /** Filesystem I/O failed. */
19
+ | 'IO'
20
+ /** The requested operation is not available under the current configuration. */
21
+ | 'UNSUPPORTED'
22
+ /** A configured resource limit was exceeded. */
23
+ | 'LIMIT';
24
+ /** Typed patch failure. */
25
+ export declare class PatchError extends Error {
26
+ readonly code: PatchErrorCode;
27
+ constructor(code: PatchErrorCode, message: string);
28
+ }
29
+ /** Syntax examples embedded in "cannot recognize" errors so the model can retry in a valid dialect. */
30
+ export declare const SYNTAX_EXAMPLES: string;
31
+ /** PARSE error for input that matches neither supported syntax. */
32
+ export declare function unrecognizedPatchError(): PatchError;
33
+ /** PARSE error with position information for malformed input. */
34
+ export declare function parseError(lineNo: number, why: string): PatchError;
35
+ /** Structured error when a recognized Codex patch arrives while the style is disabled (actionable, not a parse failure). */
36
+ export declare function codexDisabledError(): PatchError;
37
+ /** Structured error when a unified diff arrives while that style is disabled. */
38
+ export declare function unifiedDisabledError(): PatchError;
39
+ /** CONFLICT error for an Add onto an existing path (Codex semantics). */
40
+ export declare function addExistsError(path: string): PatchError;
41
+ /** CONFLICT error for update/delete of a missing file. */
42
+ export declare function missingFileError(path: string, action: string): PatchError;
43
+ /** CONFLICT error for a Move onto an existing path. */
44
+ export declare function moveTargetExistsError(path: string): PatchError;
45
+ /** CONFLICT error when one resolved file is hit by more than one patch operation. */
46
+ export declare function duplicatePathError(path: string): PatchError;
47
+ /** VALIDATION error for paths escaping the workspace. */
48
+ export declare function pathEscapeError(path: string): PatchError;
49
+ /** VALIDATION error when a symlink is on the patch path (followSymlinks: false). */
50
+ export declare function symlinkRejectedError(path: string, at: string): PatchError;
51
+ /** VALIDATION error when the Add parent directory does not exist (matches the native write tool's behavior). */
52
+ export declare function missingParentError(path: string, parent: string): PatchError;
53
+ /** LIMIT error for exceeding a configured cap. */
54
+ export declare function limitError(what: string, actual: number, limit: number): PatchError;
55
+ /** UNSUPPORTED error when delete/move is disabled or no shell capability exists. */
56
+ export declare function unsupportedShellError(why: string): PatchError;
57
+ interface MismatchInput {
58
+ /** Display path of the file that failed to locate. */
59
+ path: string;
60
+ /** 1-based hunk index within the file section. */
61
+ hunkIndex: number;
62
+ /** 1-based line number where the search started. */
63
+ searchFromLine: number;
64
+ /** The context/delete lines the hunk expected to find. */
65
+ expected: string[];
66
+ /** The file's actual lines around the search origin. */
67
+ actual: string[];
68
+ /** True when the hunk carried an `*** End of File` pin. */
69
+ endOfFile?: boolean;
70
+ }
71
+ /**
72
+ * Four-element mismatch diagnostic: file + hunk index, search origin, expected
73
+ * preview, actual excerpt — plus a targeted hint when the expected lines look
74
+ * like they still carry patch markers (the classic duplicated-prefix mistake).
75
+ */
76
+ export declare function mismatchMessage(input: MismatchInput): string;
77
+ /** IO error wrapping a backend failure. */
78
+ export declare function ioError(what: string, cause: unknown): PatchError;
79
+ export {};
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Filesystem seam: reads plus the official write-intent dance.
3
+ *
4
+ * DSH providers NEVER dispatch `fs/*` events — the tool layer is the only
5
+ * initiator — so a bare `ctx.fs.writeText` would silently bypass the
6
+ * read-before-write gate. Every write here therefore replicates the official
7
+ * `write` tool verbatim:
8
+ * 1. `ctx.waterfall('fs/write-intent', target, exec, () => undefined)`
9
+ * 2. `ctx.fs.writeText(target, content, intent, exec.signal, sandboxPolicy)`
10
+ * 3. `ctx.emit('fs/observed', target, { kind: 'present', version }, exec)`
11
+ * With no policy plugin mounted the waterfall returns `undefined`, which is
12
+ * itself the correct "unconditional write" probe.
13
+ * @module dsh-patch-edit-plus/fsops
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis';
16
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
17
+ import type { FsInfo, FsTarget, FsWriteOutcome } from '@deepseek-ai/dsh-fs';
18
+ import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
19
+ /**
20
+ * Write one file through the full intent dance. Must stay byte-for-byte the
21
+ * official sequence — the verification script asserts it.
22
+ * @throws {@link PatchError} code `IO` when the backend rejects the write.
23
+ */
24
+ export declare function writeFile(ctx: Context, exec: ToolRunContext, target: FsTarget, content: string, sandboxPolicy: SandboxExecutionPolicy | undefined): Promise<FsWriteOutcome>;
25
+ /** Read one file's text (whole file). */
26
+ export declare function readFile(ctx: Context, exec: ToolRunContext, target: FsTarget): Promise<string>;
27
+ /** Stat one resolved target; `undefined` when absent. */
28
+ export declare function statFile(ctx: Context, exec: ToolRunContext, target: FsTarget): Promise<FsInfo | undefined>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * dsh-patch-edit-plus — patch-style file editing for DeepSeek Harness.
3
+ *
4
+ * One `apply_patch` tool accepting git/unified diff (default) and Codex
5
+ * `apply_patch` syntax (opt-in). All-or-nothing application through the
6
+ * official fs write-intent dance; delete/move through the sandbox-aware
7
+ * shell. Compatible with DSH 0.1.2-rc.1 through 0.1.5-rc.2: the tool
8
+ * authoring contract is byte-identical across those versions, so a single
9
+ * code path serves both; only settings registration needs the dual-API
10
+ * fallback (`installSection` first, `register` second).
11
+ *
12
+ * @module dsh-patch-edit-plus
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import { Config } from './config.js';
16
+ import type { Config as PluginConfig } from './config.js';
17
+ /** Cordis plugin name used by Loader diagnostics. */
18
+ export declare const name = "dsh-patch-edit-plus";
19
+ /** Settings namespace (a plain string literal: valid on every target version). */
20
+ export declare const SETTINGS_NAMESPACE = "patch_edit_plus";
21
+ export { Config };
22
+ /** Register the tool and the settings namespace. Every registration is scoped to this plugin. */
23
+ export declare function apply(ctx: Context, config?: PluginConfig): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Codex `apply_patch` syntax parser.
3
+ *
4
+ * Accepts the OpenAI Codex CLI patch grammar:
5
+ * ```
6
+ * *** Begin Patch
7
+ * *** Add File: path
8
+ * +content lines
9
+ * *** Update File: path
10
+ * *** Move to: new-path
11
+ * @@ optional context label
12
+ * context
13
+ * -removed
14
+ * +added
15
+ * *** End of File
16
+ * *** Delete File: path
17
+ * *** End Patch
18
+ * ```
19
+ * A wrapper (heredoc, code fence, prose) around the markers is tolerated by
20
+ * slicing from `*** Begin Patch` to the last `*** End Patch`.
21
+ * @module dsh-patch-edit-plus/parse/codex
22
+ */
23
+ import type { ParsedPatch } from './types.js';
24
+ /**
25
+ * Parse a Codex `apply_patch` patch into the shared IR.
26
+ * @throws {@link PatchError} code `PARSE` for malformed or unrecognized input.
27
+ */
28
+ export declare function parseCodexPatch(patch: string): ParsedPatch;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unified intermediate representation shared by both patch parsers.
3
+ *
4
+ * The unified-diff parser and the Codex `apply_patch` parser both emit
5
+ * `ParsedPatch`; downstream planning and application see a single shape so
6
+ * there is exactly one apply path regardless of the input dialect.
7
+ * @module dsh-patch-edit-plus/parse/types
8
+ */
9
+ /** One line inside an update hunk: keep it (`context`), remove (`delete`) or insert (`add`). */
10
+ export interface PatchLine {
11
+ kind: 'context' | 'delete' | 'add';
12
+ text: string;
13
+ }
14
+ /**
15
+ * One hunk of an update operation. Hunk lines are applied in order at the
16
+ * position located by context matching (unified hunk headers only accelerate
17
+ * the search, they never pin it).
18
+ */
19
+ export interface Hunk {
20
+ /** Codex `@@ <label>` label, when present. */
21
+ contextLabel?: string;
22
+ lines: PatchLine[];
23
+ /**
24
+ * Codex `*** End of File` marker: the hunk must land at end-of-file, so the
25
+ * search window is pinned to the last possible position.
26
+ */
27
+ endOfFile: boolean;
28
+ /** Unified hunk header old-file start line (1-based); a search hint only. */
29
+ oldStart?: number;
30
+ /** `\ No newline` marker seen after a `-` (or context) line: the OLD file tail lacks the newline. */
31
+ oldNoNewline?: boolean;
32
+ /** `\ No newline` marker seen after a `+` line: the NEW file tail lacks the newline. */
33
+ newNoNewline?: boolean;
34
+ }
35
+ export type PatchOp = {
36
+ /** Create a file with the given content. */
37
+ kind: 'add';
38
+ path: string;
39
+ lines: string[];
40
+ trailingNewline: boolean;
41
+ } | {
42
+ /** Remove a file (runs through the shell backend). */
43
+ kind: 'delete';
44
+ path: string;
45
+ } | {
46
+ /** Modify a file in place, optionally moving it (rename or Codex Move to). */
47
+ kind: 'update';
48
+ path: string;
49
+ moveTo?: string;
50
+ hunks: Hunk[];
51
+ };
52
+ /** A fully parsed patch in the shared IR. */
53
+ export interface ParsedPatch {
54
+ format: 'unified' | 'codex';
55
+ ops: PatchOp[];
56
+ }
57
+ /** Model-facing action label for one changed file (Codex output semantics). */
58
+ export type FileAction = 'added' | 'modified' | 'deleted';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Git / unified diff parser.
3
+ *
4
+ * Produces the shared IR (`ParsedPatch`). Accepts full git diffs (with
5
+ * `diff --git` headers, mode lines and rename detection) and bare unified
6
+ * diffs (`---`/`+++` + hunks). Parsing is strict: malformed input raises
7
+ * `PatchError`('PARSE') with a 1-based line number, never a best-effort guess.
8
+ * @module dsh-patch-edit-plus/parse/unified
9
+ */
10
+ import type { ParsedPatch } from './types.js';
11
+ /** Normalize CRLF/CR to LF and split into lines. */
12
+ export declare function normalizeLines(text: string): string[];
13
+ /**
14
+ * Parse a git/unified diff into the shared IR.
15
+ * @throws {@link PatchError} code `PARSE` for any malformed or unrecognized input.
16
+ */
17
+ export declare function parseUnifiedDiff(patch: string): ParsedPatch;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tool registration with three-layer name-conflict protection.
3
+ *
4
+ * Layer 1: probe `ctx.tools.get(name)` before building the definition.
5
+ * Layer 2: `conflictPolicy` — `rename` (default) retries suffixed names,
6
+ * `skip` logs and loads without the tool, `fail` throws.
7
+ * Layer 3: `try/catch` around `register()` because get-then-register races.
8
+ * @module dsh-patch-edit-plus/register
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ import type { ResolvedConfig } from './config.js';
12
+ /** A logger face; falls back to no-ops when the host exposes none. */
13
+ export interface LoggerFace {
14
+ info?(message: string): void;
15
+ warn?(message: string): void;
16
+ error?(message: string): void;
17
+ }
18
+ /**
19
+ * Resolve an unused tool name and register the frozen definition.
20
+ * @returns the registration disposer, or `null` when registration was skipped.
21
+ * @throws {@link PatchError} only under `conflictPolicy: 'fail'`.
22
+ */
23
+ export declare function registerApplyPatchTool(ctx: Context, cfg: ResolvedConfig, logger?: LoggerFace): (() => void) | null;