localptp 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.
Files changed (186) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +159 -0
  3. package/dist/cli.d.ts +24 -0
  4. package/dist/cli.js +344 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.d.ts +14 -0
  7. package/dist/commands/config.js +65 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/context.d.ts +12 -0
  10. package/dist/commands/context.js +176 -0
  11. package/dist/commands/context.js.map +1 -0
  12. package/dist/commands/doctor.d.ts +16 -0
  13. package/dist/commands/doctor.js +54 -0
  14. package/dist/commands/doctor.js.map +1 -0
  15. package/dist/commands/index.d.ts +13 -0
  16. package/dist/commands/index.js +70 -0
  17. package/dist/commands/index.js.map +1 -0
  18. package/dist/commands/init.d.ts +15 -0
  19. package/dist/commands/init.js +46 -0
  20. package/dist/commands/init.js.map +1 -0
  21. package/dist/commands/plan.d.ts +17 -0
  22. package/dist/commands/plan.js +170 -0
  23. package/dist/commands/plan.js.map +1 -0
  24. package/dist/commands/resume.d.ts +17 -0
  25. package/dist/commands/resume.js +75 -0
  26. package/dist/commands/resume.js.map +1 -0
  27. package/dist/commands/review.d.ts +17 -0
  28. package/dist/commands/review.js +67 -0
  29. package/dist/commands/review.js.map +1 -0
  30. package/dist/commands/run.d.ts +24 -0
  31. package/dist/commands/run.js +65 -0
  32. package/dist/commands/run.js.map +1 -0
  33. package/dist/commands/step.d.ts +44 -0
  34. package/dist/commands/step.js +50 -0
  35. package/dist/commands/step.js.map +1 -0
  36. package/dist/commands/summarize.d.ts +17 -0
  37. package/dist/commands/summarize.js +276 -0
  38. package/dist/commands/summarize.js.map +1 -0
  39. package/dist/commands/task.d.ts +13 -0
  40. package/dist/commands/task.js +53 -0
  41. package/dist/commands/task.js.map +1 -0
  42. package/dist/core/activePointer.d.ts +28 -0
  43. package/dist/core/activePointer.js +84 -0
  44. package/dist/core/activePointer.js.map +1 -0
  45. package/dist/core/approval.d.ts +12 -0
  46. package/dist/core/approval.js +34 -0
  47. package/dist/core/approval.js.map +1 -0
  48. package/dist/core/configManager.d.ts +32 -0
  49. package/dist/core/configManager.js +177 -0
  50. package/dist/core/configManager.js.map +1 -0
  51. package/dist/core/contextBuilder.d.ts +40 -0
  52. package/dist/core/contextBuilder.js +406 -0
  53. package/dist/core/contextBuilder.js.map +1 -0
  54. package/dist/core/memoryLoader.d.ts +4 -0
  55. package/dist/core/memoryLoader.js +35 -0
  56. package/dist/core/memoryLoader.js.map +1 -0
  57. package/dist/core/memoryManager.d.ts +41 -0
  58. package/dist/core/memoryManager.js +181 -0
  59. package/dist/core/memoryManager.js.map +1 -0
  60. package/dist/core/memoryPolicy.d.ts +23 -0
  61. package/dist/core/memoryPolicy.js +73 -0
  62. package/dist/core/memoryPolicy.js.map +1 -0
  63. package/dist/core/modelClient.d.ts +37 -0
  64. package/dist/core/modelClient.js +160 -0
  65. package/dist/core/modelClient.js.map +1 -0
  66. package/dist/core/patchManager.d.ts +89 -0
  67. package/dist/core/patchManager.js +801 -0
  68. package/dist/core/patchManager.js.map +1 -0
  69. package/dist/core/plannerJson.d.ts +23 -0
  70. package/dist/core/plannerJson.js +118 -0
  71. package/dist/core/plannerJson.js.map +1 -0
  72. package/dist/core/promptManager.d.ts +16 -0
  73. package/dist/core/promptManager.js +35 -0
  74. package/dist/core/promptManager.js.map +1 -0
  75. package/dist/core/prompts.d.ts +8 -0
  76. package/dist/core/prompts.js +18 -0
  77. package/dist/core/prompts.js.map +1 -0
  78. package/dist/core/repoIndexer.d.ts +21 -0
  79. package/dist/core/repoIndexer.js +557 -0
  80. package/dist/core/repoIndexer.js.map +1 -0
  81. package/dist/core/reviewEngine.d.ts +53 -0
  82. package/dist/core/reviewEngine.js +229 -0
  83. package/dist/core/reviewEngine.js.map +1 -0
  84. package/dist/core/runLoop.d.ts +26 -0
  85. package/dist/core/runLoop.js +103 -0
  86. package/dist/core/runLoop.js.map +1 -0
  87. package/dist/core/runStep.d.ts +42 -0
  88. package/dist/core/runStep.js +586 -0
  89. package/dist/core/runStep.js.map +1 -0
  90. package/dist/core/safetyManager.d.ts +7 -0
  91. package/dist/core/safetyManager.js +202 -0
  92. package/dist/core/safetyManager.js.map +1 -0
  93. package/dist/core/sessionManager.d.ts +35 -0
  94. package/dist/core/sessionManager.js +265 -0
  95. package/dist/core/sessionManager.js.map +1 -0
  96. package/dist/core/stopConditions.d.ts +24 -0
  97. package/dist/core/stopConditions.js +18 -0
  98. package/dist/core/stopConditions.js.map +1 -0
  99. package/dist/core/taskManager.d.ts +26 -0
  100. package/dist/core/taskManager.js +312 -0
  101. package/dist/core/taskManager.js.map +1 -0
  102. package/dist/core/testRunner.d.ts +27 -0
  103. package/dist/core/testRunner.js +71 -0
  104. package/dist/core/testRunner.js.map +1 -0
  105. package/dist/prompts/coder.d.ts +3 -0
  106. package/dist/prompts/coder.js +46 -0
  107. package/dist/prompts/coder.js.map +1 -0
  108. package/dist/prompts/planner.d.ts +3 -0
  109. package/dist/prompts/planner.js +44 -0
  110. package/dist/prompts/planner.js.map +1 -0
  111. package/dist/prompts/reviewer.d.ts +3 -0
  112. package/dist/prompts/reviewer.js +41 -0
  113. package/dist/prompts/reviewer.js.map +1 -0
  114. package/dist/prompts/summarizer.d.ts +3 -0
  115. package/dist/prompts/summarizer.js +65 -0
  116. package/dist/prompts/summarizer.js.map +1 -0
  117. package/dist/prompts/testFixer.d.ts +3 -0
  118. package/dist/prompts/testFixer.js +33 -0
  119. package/dist/prompts/testFixer.js.map +1 -0
  120. package/dist/repl/approver.d.ts +15 -0
  121. package/dist/repl/approver.js +21 -0
  122. package/dist/repl/approver.js.map +1 -0
  123. package/dist/repl/dispatch.d.ts +48 -0
  124. package/dist/repl/dispatch.js +164 -0
  125. package/dist/repl/dispatch.js.map +1 -0
  126. package/dist/repl/loop.d.ts +14 -0
  127. package/dist/repl/loop.js +124 -0
  128. package/dist/repl/loop.js.map +1 -0
  129. package/dist/repl/tokenize.d.ts +13 -0
  130. package/dist/repl/tokenize.js +56 -0
  131. package/dist/repl/tokenize.js.map +1 -0
  132. package/dist/templates/memory.d.ts +13 -0
  133. package/dist/templates/memory.js +32 -0
  134. package/dist/templates/memory.js.map +1 -0
  135. package/dist/types/config.d.ts +235 -0
  136. package/dist/types/config.js +66 -0
  137. package/dist/types/config.js.map +1 -0
  138. package/dist/types/context.d.ts +78 -0
  139. package/dist/types/context.js +141 -0
  140. package/dist/types/context.js.map +1 -0
  141. package/dist/types/index.d.ts +117 -0
  142. package/dist/types/index.js +24 -0
  143. package/dist/types/index.js.map +1 -0
  144. package/dist/types/model.d.ts +35 -0
  145. package/dist/types/model.js +16 -0
  146. package/dist/types/model.js.map +1 -0
  147. package/dist/types/patch.d.ts +38 -0
  148. package/dist/types/patch.js +11 -0
  149. package/dist/types/patch.js.map +1 -0
  150. package/dist/types/plan.d.ts +86 -0
  151. package/dist/types/plan.js +27 -0
  152. package/dist/types/plan.js.map +1 -0
  153. package/dist/types/review.d.ts +33 -0
  154. package/dist/types/review.js +24 -0
  155. package/dist/types/review.js.map +1 -0
  156. package/dist/types/run.d.ts +34 -0
  157. package/dist/types/run.js +2 -0
  158. package/dist/types/run.js.map +1 -0
  159. package/dist/types/session.d.ts +21 -0
  160. package/dist/types/session.js +2 -0
  161. package/dist/types/session.js.map +1 -0
  162. package/dist/types/summary.d.ts +65 -0
  163. package/dist/types/summary.js +26 -0
  164. package/dist/types/summary.js.map +1 -0
  165. package/dist/types/task.d.ts +31 -0
  166. package/dist/types/task.js +10 -0
  167. package/dist/types/task.js.map +1 -0
  168. package/dist/types/test.d.ts +16 -0
  169. package/dist/types/test.js +2 -0
  170. package/dist/types/test.js.map +1 -0
  171. package/dist/utils/fs.d.ts +13 -0
  172. package/dist/utils/fs.js +65 -0
  173. package/dist/utils/fs.js.map +1 -0
  174. package/dist/utils/gitRoot.d.ts +5 -0
  175. package/dist/utils/gitRoot.js +21 -0
  176. package/dist/utils/gitRoot.js.map +1 -0
  177. package/dist/utils/logger.d.ts +15 -0
  178. package/dist/utils/logger.js +60 -0
  179. package/dist/utils/logger.js.map +1 -0
  180. package/dist/utils/paths.d.ts +13 -0
  181. package/dist/utils/paths.js +24 -0
  182. package/dist/utils/paths.js.map +1 -0
  183. package/dist/utils/tokenEstimate.d.ts +5 -0
  184. package/dist/utils/tokenEstimate.js +8 -0
  185. package/dist/utils/tokenEstimate.js.map +1 -0
  186. package/package.json +44 -0
@@ -0,0 +1,801 @@
1
+ /**
2
+ * Patch Manager (HLD-SRD §3.10).
3
+ *
4
+ * Turns raw model output into an applied, audited patch — safely and never
5
+ * partially. The pipeline the `step` command drives:
6
+ * extractUnifiedDiff(raw) → parsePatch(diff) → validate(plan,config,root)
7
+ * → apply(plan,root) [git apply --check then git apply; new-file fallback]
8
+ * → savePatch(diff,stepId,orchestratorDir).
9
+ *
10
+ * Invariants enforced here:
11
+ * - extraction is tolerant of fences/prose but yields null for empty output
12
+ * and for a `needs_context` JSON response (so the command never tries to
13
+ * apply a non-diff);
14
+ * - validation refuses an `add` over an existing file (never overwrite), a
15
+ * missing modify/delete target, an ignored/generated file, and any path
16
+ * that escapes the repo root — including escape *through* a symlinked
17
+ * directory (realpath/lstat, not just `path.resolve`);
18
+ * - `apply` pre-flights with `git apply --check`; a failing check never
19
+ * mutates the tree. In a non-git dir only an add-only patch applies (via a
20
+ * controlled write); any existing-file edit there is refused (no rollback
21
+ * layer).
22
+ */
23
+ import { promises as fs } from "node:fs";
24
+ import path from "node:path";
25
+ import { execa } from "execa";
26
+ import { simpleGit } from "simple-git";
27
+ import { ensureDir } from "../utils/fs.js";
28
+ // ---------------------------------------------------------------------------
29
+ // Errors
30
+ // ---------------------------------------------------------------------------
31
+ /** A path/target/ignore validation failure (§3.10, §13). Carries an exit code. */
32
+ export class PatchValidationError extends Error {
33
+ exitCode = 1;
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = "PatchValidationError";
37
+ }
38
+ }
39
+ /** A patch that does not cleanly apply, or an apply mechanism failure (§12.3). */
40
+ export class PatchApplyError extends Error {
41
+ exitCode = 1;
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "PatchApplyError";
45
+ }
46
+ }
47
+ /** The working tree is not safe to apply onto (e.g. mid-merge/rebase) (§11.2). */
48
+ export class WorkingTreeUnsafeError extends Error {
49
+ exitCode = 1;
50
+ constructor(message) {
51
+ super(message);
52
+ this.name = "WorkingTreeUnsafeError";
53
+ }
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // extractUnifiedDiff
57
+ // ---------------------------------------------------------------------------
58
+ /** A line that begins a unified diff. */
59
+ function isDiffStartLine(line) {
60
+ return /^diff --git /.test(line) || /^--- /.test(line);
61
+ }
62
+ /**
63
+ * Tolerantly extract a unified diff from raw model output. Strips code fences
64
+ * and surrounding prose, returning the diff block (from its first `diff --git`
65
+ * or `---` line through its last diff-shaped line). Returns null when there is
66
+ * no diff — empty/whitespace output, a `needs_context` JSON response, or plain
67
+ * prose.
68
+ */
69
+ export function extractUnifiedDiff(raw) {
70
+ if (raw === undefined || raw === null)
71
+ return null;
72
+ const trimmed = raw.trim();
73
+ if (trimmed.length === 0)
74
+ return null;
75
+ // A `needs_context` JSON response is explicitly not a diff. Detect it tolerant
76
+ // of fences/prose by scanning for the status token in the raw text — the
77
+ // command handles the structured parse; here we only need to NOT treat it as
78
+ // a diff.
79
+ if (/"status"\s*:\s*"needs_context"/.test(trimmed))
80
+ return null;
81
+ // Drop a single fenced block's fences if the diff lives inside one. We scan
82
+ // line-by-line instead of regex-extracting so prose outside the fence is
83
+ // discarded and the fence markers never leak into the diff body.
84
+ const lines = raw.split(/\r?\n/);
85
+ let start = -1;
86
+ for (let i = 0; i < lines.length; i++) {
87
+ if (isDiffStartLine(lines[i])) {
88
+ start = i;
89
+ break;
90
+ }
91
+ }
92
+ if (start === -1)
93
+ return null;
94
+ // Collect from the first diff line to the last line that still looks like part
95
+ // of a unified diff. Diff body lines start with one of: `diff `, `index `,
96
+ // `--- `, `+++ `, `@@ `, `+`, `-`, ` ` (context), `\` (no-newline marker),
97
+ // `new file`, `deleted file`, `old mode`, `new mode`, `similarity`, `rename`,
98
+ // `copy`, `GIT binary patch`, or base85 binary literal lines. We stop at the
99
+ // first line after `start` that is clearly prose or a closing fence and is not
100
+ // a diff body line — but only after we have seen at least one hunk/header, so
101
+ // a blank line inside a diff does not prematurely end it.
102
+ const out = [];
103
+ for (let i = start; i < lines.length; i++) {
104
+ const line = lines[i];
105
+ if (/^```/.test(line))
106
+ break; // closing fence ends the diff block
107
+ out.push(line);
108
+ }
109
+ // Trim trailing blank lines and any trailing prose that slipped in after the
110
+ // diff (a line that is not a plausible diff body line, scanning from the end).
111
+ while (out.length > 0) {
112
+ const last = out[out.length - 1];
113
+ if (last.trim().length === 0) {
114
+ out.pop();
115
+ continue;
116
+ }
117
+ if (isDiffBodyLine(last))
118
+ break;
119
+ out.pop();
120
+ }
121
+ if (out.length === 0)
122
+ return null;
123
+ return out.join("\n") + "\n";
124
+ }
125
+ /** Whether a line is plausibly part of a unified-diff body (for trailing trim). */
126
+ function isDiffBodyLine(line) {
127
+ return (/^diff /.test(line) ||
128
+ /^index /.test(line) ||
129
+ /^--- /.test(line) ||
130
+ /^\+\+\+ /.test(line) ||
131
+ /^@@ /.test(line) ||
132
+ /^[+\- ]/.test(line) ||
133
+ /^\\ No newline/.test(line) ||
134
+ /^(new|deleted) file mode /.test(line) ||
135
+ /^(old|new) mode /.test(line) ||
136
+ /^(similarity|dissimilarity) index /.test(line) ||
137
+ /^(rename|copy) (from|to) /.test(line) ||
138
+ /^GIT binary patch/.test(line) ||
139
+ /^Binary files .* differ$/.test(line) ||
140
+ /^(literal|delta) \d+/.test(line) ||
141
+ /^[0-9A-Za-z]{1,}$/.test(line) // base85 binary literal line
142
+ );
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // parsePatch
146
+ // ---------------------------------------------------------------------------
147
+ function toPosix(p) {
148
+ return p.replace(/\\/g, "/");
149
+ }
150
+ /**
151
+ * Decode git's C-style quoted path form (`"a/x y"`, `"a/\303\251.txt"`). Git
152
+ * quotes a header path when it contains a space or a special byte; leaving the
153
+ * quotes/escapes in place would make the safety checks inspect a different path
154
+ * than `git apply` actually writes. Returns the input unchanged when it is not
155
+ * quoted.
156
+ */
157
+ function unquoteGitPath(p) {
158
+ const t = p.trim();
159
+ if (!(t.startsWith('"') && t.endsWith('"') && t.length >= 2))
160
+ return t;
161
+ const body = t.slice(1, -1);
162
+ // Accumulate raw bytes so a run of octal escapes (git emits one escape per
163
+ // UTF-8 byte, e.g. `\303\251` for `é`) decodes as the original multi-byte
164
+ // character, not per-byte mojibake. Plain ASCII chars are pushed as their
165
+ // single byte; non-ASCII source chars (already decoded) are encoded to UTF-8.
166
+ const bytes = [];
167
+ const pushChar = (ch) => {
168
+ for (const b of Buffer.from(ch, "utf8"))
169
+ bytes.push(b);
170
+ };
171
+ const simple = {
172
+ n: "\n",
173
+ t: "\t",
174
+ r: "\r",
175
+ '"': '"',
176
+ "\\": "\\",
177
+ };
178
+ for (let i = 0; i < body.length; i++) {
179
+ if (body[i] !== "\\") {
180
+ pushChar(body[i]);
181
+ continue;
182
+ }
183
+ const next = body[i + 1];
184
+ // Octal escape (\NNN) — git emits one per raw byte.
185
+ if (next !== undefined && next >= "0" && next <= "7") {
186
+ const m = /^[0-7]{1,3}/.exec(body.slice(i + 1));
187
+ if (m) {
188
+ bytes.push(parseInt(m[0], 8) & 0xff);
189
+ i += m[0].length;
190
+ continue;
191
+ }
192
+ }
193
+ if (next !== undefined && next in simple) {
194
+ pushChar(simple[next]);
195
+ i += 1;
196
+ continue;
197
+ }
198
+ if (next !== undefined)
199
+ pushChar(next);
200
+ i += 1;
201
+ }
202
+ return Buffer.from(bytes).toString("utf8");
203
+ }
204
+ /** Strip a leading `a/` or `b/` diff prefix; map `/dev/null` to undefined. */
205
+ function stripPrefix(p) {
206
+ const posix = toPosix(unquoteGitPath(p));
207
+ if (posix === "/dev/null")
208
+ return undefined;
209
+ return posix.replace(/^[ab]\//, "");
210
+ }
211
+ /**
212
+ * Parse the two paths from a `diff --git <a> <b>` header tail. Handles both the
213
+ * unquoted form (`a/x b/x`) and git's quoted form for paths with spaces/special
214
+ * bytes (`"a/x y" "b/x y"`). Returns prefix-stripped, unquoted `[a, b]`. A naive
215
+ * whitespace split is wrong for spaced paths — and getting the path wrong here
216
+ * (for a metadata-only empty-file section that has no `---`/`+++` lines) would
217
+ * leave the file out of the plan entirely, skipping every plan-level safety
218
+ * gate while `git apply` still writes it.
219
+ */
220
+ function parseGitHeaderPaths(tail) {
221
+ const t = tail.trim();
222
+ // Quoted first path: `"a/..." "b/..."` (either or both sides may be quoted).
223
+ const m = /^("(?:\\.|[^"\\])*"|\S+)\s+("(?:\\.|[^"\\])*"|.+)$/.exec(t);
224
+ if (!m)
225
+ return [undefined, undefined];
226
+ return [stripPrefix(m[1]), stripPrefix(m[2])];
227
+ }
228
+ /**
229
+ * Parse a unified diff into a classified `PatchPlan`. Each `diff --git` (or, for
230
+ * a header-less diff, each `---`/`+++` pair) is one file section. Classification:
231
+ * - `new file mode` / `--- /dev/null` → add
232
+ * - `deleted file mode` / `+++ /dev/null` → delete
233
+ * - otherwise → modify
234
+ * `GIT binary patch` in any section sets `isBinary`.
235
+ */
236
+ export function parsePatch(diff) {
237
+ const lines = diff.split(/\r?\n/);
238
+ const adds = [];
239
+ const modifies = [];
240
+ const deletes = [];
241
+ let isBinary = false;
242
+ const sections = [];
243
+ let cur;
244
+ const startSection = (fromGit, a, b) => {
245
+ cur = {
246
+ gitA: a,
247
+ gitB: b,
248
+ isNew: false,
249
+ isDeleted: false,
250
+ minusDevNull: false,
251
+ plusDevNull: false,
252
+ binary: false,
253
+ sawMinus: false,
254
+ sawHunk: false,
255
+ fromGit,
256
+ };
257
+ sections.push(cur);
258
+ };
259
+ for (let li = 0; li < lines.length; li++) {
260
+ const line = lines[li];
261
+ // Binary markers set the global flag regardless of whether a section is
262
+ // open: a binary diff with a quoted/spaced `diff --git` path (which the
263
+ // header regex below cannot match) must still be flagged binary so the
264
+ // safety gate refuses it rather than letting `git apply` apply it.
265
+ if (/^GIT binary patch/.test(line) || /^Binary files .* differ$/.test(line)) {
266
+ isBinary = true;
267
+ if (cur)
268
+ cur.binary = true;
269
+ continue;
270
+ }
271
+ if (/^diff --git /.test(line)) {
272
+ const [a, b] = parseGitHeaderPaths(line.slice("diff --git ".length));
273
+ startSection(true, a, b);
274
+ continue;
275
+ }
276
+ // A `--- ` / `+++ ` line is only a FILE HEADER before the section's first
277
+ // hunk. Once inside a hunk, an added/removed content line such as `+++ x`
278
+ // (the `+` marker plus literal `++ x` content) must NOT be mistaken for a
279
+ // header — that would let hunk content overwrite the section's real path and
280
+ // bypass the ignore/risky-path checks. EXCEPTIONS that DO start a new
281
+ // header-less section even mid-hunk:
282
+ // - a `--- /dev/null` (unambiguous: a deletion content line is `-…`, never
283
+ // that sentinel), and
284
+ // - a `--- <path>` immediately followed by a `+++ <path>` line AND THEN a
285
+ // `@@ ` hunk line: that triple is the header of the NEXT file in a
286
+ // header-less multi-file diff. Matching only `--- `+`+++ ` is NOT enough
287
+ // — hunk CONTENT can look like that pair (a removed line whose text is
288
+ // `-- x` renders as `--- x`, and an added `++ y` renders as `+++ y`), so
289
+ // we also require a bare `@@ ` hunk header on the third line. A bare
290
+ // `@@ ` at column 0 only ever occurs as a real hunk header (inside a hunk
291
+ // it would be prefixed by `+`/`-`/space), so the triple disambiguates a
292
+ // true next-file header from look-alike content. Without this, a
293
+ // header-less diff's 2nd+ files were swallowed as hunk content and
294
+ // bypassed every plan-level safety gate while `git apply` still wrote
295
+ // them.
296
+ const minusP = /^--- /.test(line) ? line.slice(4) : undefined;
297
+ const minusIsDevNull = minusP !== undefined && toPosix(minusP.trim()) === "/dev/null";
298
+ const nextIsHeaderTriple = minusP !== undefined &&
299
+ /^\+\+\+ /.test(lines[li + 1] ?? "") &&
300
+ /^@@ /.test(lines[li + 2] ?? "");
301
+ if (minusP !== undefined &&
302
+ (minusIsDevNull || nextIsHeaderTriple || !(cur && cur.sawHunk))) {
303
+ // Attach to the current section unless none exists, its `--- ` line was
304
+ // already consumed, or we are mid-hunk on a new `--- /dev/null` (each of
305
+ // those begins a new header-less section).
306
+ if (!cur || cur.sawMinus || cur.sawHunk) {
307
+ startSection(false);
308
+ }
309
+ cur.sawMinus = true;
310
+ if (minusIsDevNull) {
311
+ cur.minusDevNull = true;
312
+ }
313
+ else {
314
+ cur.minus = stripPrefix(minusP);
315
+ }
316
+ continue;
317
+ }
318
+ if (!cur)
319
+ continue;
320
+ if (/^\+\+\+ /.test(line) && !cur.sawHunk) {
321
+ const p = line.slice(4);
322
+ if (toPosix(p.trim()) === "/dev/null") {
323
+ cur.plusDevNull = true;
324
+ }
325
+ else {
326
+ cur.plus = stripPrefix(p);
327
+ }
328
+ continue;
329
+ }
330
+ if (/^@@ /.test(line)) {
331
+ cur.sawHunk = true;
332
+ continue;
333
+ }
334
+ if (/^new file mode /.test(line)) {
335
+ cur.isNew = true;
336
+ continue;
337
+ }
338
+ if (/^deleted file mode /.test(line)) {
339
+ cur.isDeleted = true;
340
+ continue;
341
+ }
342
+ }
343
+ for (const s of sections) {
344
+ if (s.binary)
345
+ isBinary = true;
346
+ // Resolve the canonical repo-relative path for this section.
347
+ const path_ = s.isDeleted || s.plusDevNull
348
+ ? s.minus ?? s.gitA ?? s.gitB
349
+ : s.plus ?? s.gitB ?? s.gitA ?? s.minus;
350
+ if (path_ === undefined)
351
+ continue;
352
+ if (s.isNew || s.minusDevNull) {
353
+ adds.push(path_);
354
+ }
355
+ else if (s.isDeleted || s.plusDevNull) {
356
+ deletes.push(path_);
357
+ }
358
+ else {
359
+ modifies.push(path_);
360
+ }
361
+ }
362
+ const touchedFiles = [...new Set([...adds, ...modifies, ...deletes])];
363
+ return { touchedFiles, adds, modifies, deletes, isBinary, diff };
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Path / ignore helpers (shared by validate and the Safety Manager)
367
+ // ---------------------------------------------------------------------------
368
+ /**
369
+ * Normalize an ALREADY-prefix-stripped touched path (the paths in a `PatchPlan`
370
+ * have had their `a/`/`b/` diff prefix removed by `parsePatch`) to repo-relative
371
+ * POSIX form: convert Windows `\` separators and drop a `./` prefix. It does NOT
372
+ * re-strip an `a/`/`b/` prefix — doing so would corrupt a legitimate path whose
373
+ * first segment is literally `a` or `b` (e.g. `a/secure.txt`), causing the
374
+ * ignore / risky-path checks to inspect the wrong path. Absolute markers are
375
+ * preserved for the escape check.
376
+ */
377
+ export function normalizeTouchedPath(p) {
378
+ return toPosix(p).replace(/^\.\//, "");
379
+ }
380
+ /** Is `rel` matched by the config ignore list (same semantics as the indexer)? */
381
+ function isIgnored(rel, config) {
382
+ const parts = rel.split("/");
383
+ const basename = parts[parts.length - 1];
384
+ // Baseline well-known generated/dependency trees at any depth.
385
+ const baseline = new Set([
386
+ ".git",
387
+ "node_modules",
388
+ "dist",
389
+ "build",
390
+ ".next",
391
+ "coverage",
392
+ ".ai-orchestrator",
393
+ ]);
394
+ for (const seg of parts) {
395
+ if (baseline.has(seg))
396
+ return true;
397
+ }
398
+ for (const pat of config.ignore) {
399
+ if (rel === pat)
400
+ return true;
401
+ if (rel.startsWith(pat + "/"))
402
+ return true;
403
+ if (basename === pat)
404
+ return true;
405
+ // A directory name anywhere in the path (e.g. `dist` matching `pkg/dist/x`).
406
+ if (parts.includes(pat))
407
+ return true;
408
+ }
409
+ return false;
410
+ }
411
+ /**
412
+ * Whether `rel` (a touched path string from the diff) escapes `root`.
413
+ *
414
+ * Refuses outright: an absolute path, or a path whose lexical normalization
415
+ * leaves the root (a leading `..`). Then, to catch escape *through* a symlinked
416
+ * directory, it realpaths the deepest existing ancestor of the target and
417
+ * verifies the resolved location is still inside the realpath'd root.
418
+ */
419
+ async function escapesRoot(rel, root) {
420
+ const raw = toPosix(rel);
421
+ // Absolute paths (POSIX `/x`, Windows `C:/x`) are refused outright.
422
+ if (path.isAbsolute(rel) || /^[A-Za-z]:[\\/]/.test(rel) || raw.startsWith("/")) {
423
+ return true;
424
+ }
425
+ // Lexical escape: normalize and check for a leading `..`.
426
+ const normalized = path.posix.normalize(raw);
427
+ if (normalized === ".." || normalized.startsWith("../"))
428
+ return true;
429
+ // Symlink escape: realpath the deepest existing ancestor.
430
+ const rootReal = await fs.realpath(root);
431
+ const target = path.resolve(root, normalized);
432
+ let probe = target;
433
+ // Walk up to the first existing ancestor.
434
+ for (;;) {
435
+ try {
436
+ const real = await fs.realpath(probe);
437
+ // `real` is the resolved existing ancestor. The remaining (non-existent)
438
+ // suffix cannot itself be a symlink, so containment of `real` decides.
439
+ const relToRoot = path.relative(rootReal, real);
440
+ if (relToRoot === "")
441
+ return false;
442
+ // Use `== ".." || startsWith("../")` (not a bare `startsWith("..")`) so a
443
+ // legitimate in-root name that merely begins with `..` (e.g. `..cache`) is
444
+ // not mistaken for a parent-directory escape.
445
+ const posixRel = toPosix(relToRoot);
446
+ if (posixRel === ".." ||
447
+ posixRel.startsWith("../") ||
448
+ path.isAbsolute(relToRoot)) {
449
+ return true;
450
+ }
451
+ return false;
452
+ }
453
+ catch (err) {
454
+ if (err.code === "ENOENT") {
455
+ const parent = path.dirname(probe);
456
+ if (parent === probe)
457
+ return true; // reached fs root without resolving
458
+ probe = parent;
459
+ continue;
460
+ }
461
+ throw err;
462
+ }
463
+ }
464
+ }
465
+ // ---------------------------------------------------------------------------
466
+ // validate
467
+ // ---------------------------------------------------------------------------
468
+ async function pathExists(p) {
469
+ try {
470
+ await fs.lstat(p);
471
+ return true;
472
+ }
473
+ catch {
474
+ return false;
475
+ }
476
+ }
477
+ /**
478
+ * Validate a parsed plan against the repo root + config. Throws
479
+ * `PatchValidationError` on the first violation:
480
+ * - any touched path escaping the root (absolute, `../`, or symlink escape);
481
+ * - any touched path matching the ignore list;
482
+ * - a modify/delete whose target does not exist;
483
+ * - an add whose target already exists (never overwrite).
484
+ * Resolves (undefined) when the plan is structurally safe to apply.
485
+ */
486
+ export async function validate(plan, config, root) {
487
+ for (const raw of plan.touchedFiles) {
488
+ const rel = normalizeTouchedPath(raw);
489
+ if (await escapesRoot(raw, root)) {
490
+ throw new PatchValidationError(`Refusing patch: path escapes the repository root: ${raw}`);
491
+ }
492
+ if (isIgnored(rel, config)) {
493
+ throw new PatchValidationError(`Refusing patch: touches an ignored/generated file: ${rel}`);
494
+ }
495
+ }
496
+ for (const raw of plan.modifies) {
497
+ const rel = normalizeTouchedPath(raw);
498
+ if (!(await pathExists(path.join(root, rel)))) {
499
+ throw new PatchValidationError(`Refusing patch: modify target does not exist: ${rel}`);
500
+ }
501
+ }
502
+ for (const raw of plan.deletes) {
503
+ const rel = normalizeTouchedPath(raw);
504
+ if (!(await pathExists(path.join(root, rel)))) {
505
+ throw new PatchValidationError(`Refusing patch: delete target does not exist: ${rel}`);
506
+ }
507
+ }
508
+ for (const raw of plan.adds) {
509
+ const rel = normalizeTouchedPath(raw);
510
+ if (await pathExists(path.join(root, rel))) {
511
+ throw new PatchValidationError(`Refusing patch: add target already exists (never overwrite): ${rel}`);
512
+ }
513
+ }
514
+ }
515
+ // ---------------------------------------------------------------------------
516
+ // Git detection + working-tree safety
517
+ // ---------------------------------------------------------------------------
518
+ /** Is `root` the working tree of a git repo? */
519
+ async function isGitRepo(root) {
520
+ try {
521
+ return await simpleGit(root).checkIsRepo();
522
+ }
523
+ catch {
524
+ return false;
525
+ }
526
+ }
527
+ /**
528
+ * Refuse to apply onto an unsafe working tree (§11.2). Unsafe = a repo in the
529
+ * middle of a merge / rebase / cherry-pick / revert / bisect (detected by the
530
+ * marker files / dirs git writes). Unrelated dirty files are tolerated — the
531
+ * `git apply --check` pre-flight gates the exact apply. A non-git dir has no
532
+ * merge state, so this is a no-op there.
533
+ */
534
+ export async function assertWorkingTreeSafe(root) {
535
+ if (!(await isGitRepo(root)))
536
+ return;
537
+ const gitDir = await resolveGitDir(root);
538
+ const markers = [
539
+ "MERGE_HEAD",
540
+ "rebase-merge",
541
+ "rebase-apply",
542
+ "CHERRY_PICK_HEAD",
543
+ "REVERT_HEAD",
544
+ "BISECT_LOG",
545
+ ];
546
+ for (const m of markers) {
547
+ if (await pathExists(path.join(gitDir, m))) {
548
+ throw new WorkingTreeUnsafeError(`Refusing to apply: the repository is mid-operation (${m}). ` +
549
+ "Finish or abort it first (§11.2).");
550
+ }
551
+ }
552
+ }
553
+ /** Resolve the repo's git dir (handles worktrees where `.git` is a file). */
554
+ async function resolveGitDir(root) {
555
+ try {
556
+ const out = (await simpleGit(root).revparse(["--git-dir"])).trim();
557
+ return path.isAbsolute(out) ? out : path.join(root, out);
558
+ }
559
+ catch {
560
+ return path.join(root, ".git");
561
+ }
562
+ }
563
+ // ---------------------------------------------------------------------------
564
+ // apply
565
+ // ---------------------------------------------------------------------------
566
+ /** True when every touched file is a brand-new add (no modify/delete). */
567
+ function isAddOnly(plan) {
568
+ return (plan.adds.length > 0 &&
569
+ plan.modifies.length === 0 &&
570
+ plan.deletes.length === 0);
571
+ }
572
+ /**
573
+ * Pre-flight a diff with `git apply --check` (no mutation). Throws
574
+ * `PatchApplyError` when the patch would not apply cleanly. Git repos only —
575
+ * the command skips this for the non-git add-only path.
576
+ */
577
+ export async function gitApplyCheck(plan, root) {
578
+ try {
579
+ await execa("git", ["apply", "--check", "--whitespace=nowarn", "-"], {
580
+ cwd: root,
581
+ input: plan.diff,
582
+ });
583
+ }
584
+ catch (err) {
585
+ throw new PatchApplyError("The patch does not apply cleanly (`git apply --check` failed). " +
586
+ "Nothing was applied (§12.3). " +
587
+ (err instanceof Error ? err.message : String(err)));
588
+ }
589
+ }
590
+ /**
591
+ * Apply a parsed plan to the working tree, never partially.
592
+ *
593
+ * Git repo: `git apply --check` then `git apply` (atomic per call). A failing
594
+ * check leaves the tree untouched.
595
+ *
596
+ * Non-git dir: only an add-only patch is applied, via a controlled write of the
597
+ * new files (the Git pre-check is skipped). Any existing-file modify/delete in a
598
+ * non-git dir is refused with `PatchApplyError` — there is no rollback layer.
599
+ */
600
+ export async function apply(plan, root) {
601
+ const git = await isGitRepo(root);
602
+ if (git) {
603
+ await gitApplyCheck(plan, root);
604
+ try {
605
+ await execa("git", ["apply", "--whitespace=nowarn", "-"], {
606
+ cwd: root,
607
+ input: plan.diff,
608
+ });
609
+ }
610
+ catch (err) {
611
+ throw new PatchApplyError("`git apply` failed after a passing --check. Nothing was applied. " +
612
+ (err instanceof Error ? err.message : String(err)));
613
+ }
614
+ return;
615
+ }
616
+ // Non-git: add-only via controlled write; anything else is refused.
617
+ if (!isAddOnly(plan)) {
618
+ throw new PatchApplyError("Refusing to modify or delete files in a non-git directory: there is no " +
619
+ "Git rollback layer. Initialize a repo (`git init`) first.");
620
+ }
621
+ await controlledWriteAdds(plan, root);
622
+ }
623
+ /**
624
+ * Write the new files of an add-only plan by reconstructing each file's content
625
+ * from its `+` hunk lines. Used only for the non-git add-only fallback. Refuses
626
+ * to clobber an existing file (defense in depth; `validate` already checked).
627
+ *
628
+ * The non-git path has no Git rollback layer, so this enforces the "never apply
629
+ * a partial patch" invariant itself: it first refuses if ANY target already
630
+ * exists (before writing anything), then, if a later write fails mid-batch, it
631
+ * removes the files it already created so a multi-file add never leaves a partial
632
+ * result behind.
633
+ */
634
+ async function controlledWriteAdds(plan, root) {
635
+ const files = reconstructAdds(plan.diff);
636
+ // Pre-flight: refuse the whole batch up front if any target already exists, so
637
+ // a collision on a later file never leaves an earlier file written.
638
+ for (const [rel] of files) {
639
+ if (await pathExists(path.join(root, rel))) {
640
+ throw new PatchApplyError(`Refusing to overwrite an existing file via the non-git fallback: ${rel}`);
641
+ }
642
+ }
643
+ const written = [];
644
+ try {
645
+ for (const [rel, content] of files) {
646
+ // Re-check containment IMMEDIATELY before the write (not just in `validate`,
647
+ // which ran before the approval prompt): a symlinked ancestor could have
648
+ // been swapped in during the await window, redirecting the write outside
649
+ // root. `escapesRoot` realpaths the deepest existing ancestor, so a parent
650
+ // now pointing outside root is caught here. (§13 path-escape invariant.)
651
+ if (await escapesRoot(rel, root)) {
652
+ throw new PatchApplyError(`Refusing to write outside the repository root (containment changed since validation): ${rel}`);
653
+ }
654
+ const full = path.join(root, rel);
655
+ await ensureDir(path.dirname(full));
656
+ // `flag: "wx"` keeps each write a never-overwrite create (defense in depth
657
+ // against a TOCTOU create between the pre-flight check and this write).
658
+ await fs.writeFile(full, content, { encoding: "utf8", flag: "wx" });
659
+ written.push(full);
660
+ }
661
+ }
662
+ catch (err) {
663
+ // Roll back the files this batch already created so the add is all-or-nothing.
664
+ for (const full of written.reverse()) {
665
+ try {
666
+ await fs.rm(full, { force: true });
667
+ }
668
+ catch {
669
+ // Best-effort cleanup; surface the original failure below regardless.
670
+ }
671
+ }
672
+ if (err instanceof PatchApplyError)
673
+ throw err;
674
+ throw new PatchApplyError("Failed to write the new files of a non-git add-only patch; rolled back " +
675
+ "the files already created. " +
676
+ (err instanceof Error ? err.message : String(err)));
677
+ }
678
+ }
679
+ /**
680
+ * Reconstruct the content of each added file from its `+` hunk lines. Returns a
681
+ * map of repo-relative POSIX path → file content. Only handles new-file
682
+ * sections (`--- /dev/null` or `new file mode`).
683
+ */
684
+ function reconstructAdds(diff) {
685
+ const lines = diff.split(/\r?\n/);
686
+ const out = new Map();
687
+ let curPath; // from `+++` (most precise)
688
+ let headerPath; // from `diff --git` gitB (fallback)
689
+ let inHunk = false;
690
+ let buf = [];
691
+ let isNewFile = false;
692
+ let sawMinus = false; // a `--- ` header has been consumed for the current file
693
+ let trailingNewline = true; // false when a `` follows
694
+ const flush = () => {
695
+ const target = curPath ?? headerPath;
696
+ // Emit an entry for every new-file section, even one with no hunk/`+++`
697
+ // (a metadata-only empty-file add): otherwise the controlled write would
698
+ // silently skip a file the plan promised to create.
699
+ if (target !== undefined && isNewFile) {
700
+ const body = buf.join("\n");
701
+ out.set(target, body + (buf.length > 0 && trailingNewline ? "\n" : ""));
702
+ }
703
+ buf = [];
704
+ inHunk = false;
705
+ isNewFile = false;
706
+ sawMinus = false;
707
+ curPath = undefined;
708
+ headerPath = undefined;
709
+ trailingNewline = true;
710
+ };
711
+ for (const line of lines) {
712
+ if (/^diff --git /.test(line)) {
713
+ flush();
714
+ // Capture the `b/` path as a fallback when there is no `+++` line
715
+ // (handles quoted/spaced paths via the shared header parser).
716
+ const [, b] = parseGitHeaderPaths(line.slice("diff --git ".length));
717
+ headerPath = b;
718
+ continue;
719
+ }
720
+ if (/^new file mode /.test(line)) {
721
+ isNewFile = true;
722
+ continue;
723
+ }
724
+ // A `--- /dev/null` line unambiguously starts a NEW added-file section (a
725
+ // hunk content line is `-…`, never the literal `--- /dev/null` sentinel), so
726
+ // flush the previous file even mid-hunk — otherwise a multi-file headerless
727
+ // add concatenates every file's content into the last path. A non-dev/null
728
+ // `--- ` is only treated as a header before the first hunk (mid-hunk it is
729
+ // content, mirroring the Q1 guard in parsePatch).
730
+ const isMinus = /^--- /.test(line);
731
+ const isMinusDevNull = isMinus && toPosix(line.slice(4).trim()) === "/dev/null";
732
+ if (isMinusDevNull || (isMinus && !inHunk)) {
733
+ if (sawMinus || curPath !== undefined || inHunk)
734
+ flush();
735
+ sawMinus = true;
736
+ if (isMinusDevNull)
737
+ isNewFile = true;
738
+ continue;
739
+ }
740
+ if (/^\+\+\+ /.test(line) && !inHunk) {
741
+ // Header `+++ ` line only before the hunk; once in a hunk a `+++ x` line
742
+ // is added CONTENT (`+` marker + `++ x`), captured by the `+` branch below.
743
+ const p = stripPrefix(line.slice(4));
744
+ if (p !== undefined)
745
+ curPath = p;
746
+ continue;
747
+ }
748
+ if (/^@@ /.test(line)) {
749
+ inHunk = true;
750
+ continue;
751
+ }
752
+ if (/^\/.test(line)) {
753
+ trailingNewline = false;
754
+ continue;
755
+ }
756
+ if (inHunk && line.startsWith("+")) {
757
+ buf.push(line.slice(1));
758
+ continue;
759
+ }
760
+ // Ignore context/`-` lines (an add has only `+` content).
761
+ }
762
+ flush();
763
+ return out;
764
+ }
765
+ // ---------------------------------------------------------------------------
766
+ // savePatch
767
+ // ---------------------------------------------------------------------------
768
+ function pad(n) {
769
+ return String(n).padStart(2, "0");
770
+ }
771
+ /**
772
+ * Save the raw diff as a patch artifact under
773
+ * `<orchestratorDir>/patches/YYYY-MM-DD_HHMM_<step-id>.patch`. Returns the
774
+ * absolute path written. Never overwrites: a name collision appends `-2`, `-3`…
775
+ */
776
+ export async function savePatch(diff, stepId, orchestratorDir, opts = {}) {
777
+ const now = opts.now ?? new Date();
778
+ const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
779
+ `_${pad(now.getHours())}${pad(now.getMinutes())}`;
780
+ const safeId = stepId.replace(/[^A-Za-z0-9._-]/g, "_");
781
+ const patchesDir = path.join(orchestratorDir, "patches");
782
+ await ensureDir(patchesDir);
783
+ const base = `${stamp}_${safeId}`;
784
+ let attempt = 0;
785
+ for (;;) {
786
+ const name = attempt === 0 ? `${base}.patch` : `${base}-${attempt + 1}.patch`;
787
+ const full = path.join(patchesDir, name);
788
+ try {
789
+ await fs.writeFile(full, diff, { encoding: "utf8", flag: "wx" });
790
+ return full;
791
+ }
792
+ catch (err) {
793
+ if (err.code === "EEXIST") {
794
+ attempt += 1;
795
+ continue;
796
+ }
797
+ throw err;
798
+ }
799
+ }
800
+ }
801
+ //# sourceMappingURL=patchManager.js.map