@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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 (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `agent` tool — β2 S3 (2026-05-26).
3
+ *
4
+ * Exposes the subagent spawn primitive as a first-class tool call so
5
+ * the root Mira persona (or any orchestrator-capable parent loop) can
6
+ * delegate a brief to a specialist child via the standard tool-use
7
+ * grammar instead of via the legacy `<pugi-delegate>` XML sidechannel.
8
+ *
9
+ * Grammar:
10
+ *
11
+ * {
12
+ * "role": "coder" | "verifier" | "reviewer" | "researcher" | ...,
13
+ * "brief": "one-paragraph task description",
14
+ * "isolation": "worktree" | "shared_fs" | "auto" // optional, default "auto"
15
+ * }
16
+ *
17
+ * Returns a JSON envelope:
18
+ *
19
+ * {
20
+ * "ok": true,
21
+ * "taskId": "subagent-<uuid>",
22
+ * "role": "coder",
23
+ * "personaSlug": "dev",
24
+ * "status": "shipped" | "blocked" | "failed",
25
+ * "summary": "...",
26
+ * "filesChanged": ["src/...", "src/..."],
27
+ * "toolCallCount": N,
28
+ * "tokensIn": N,
29
+ * "tokensOut": N,
30
+ * "durationMs": N,
31
+ * "worktreePath": "/path/.pugi/worktrees/<uuid>" // only when worktree isolation used
32
+ * }
33
+ *
34
+ * Why expose this as a tool rather than baking it into the engine
35
+ * loop directly:
36
+ *
37
+ * - The model's existing tool-use grammar is what every modern Anvil
38
+ * provider speaks natively. Wrapping delegation as a tool means the
39
+ * model can decide WHEN to spawn a child the same way it decides
40
+ * when to read/edit/bash — no special-case prompt engineering.
41
+ * - The `agent` tool is gated by the isolation-matrix capability map
42
+ * (only `orchestrator`-class roles see it in their tools schema).
43
+ * A coder/reviewer/verifier cannot recursively spawn grandchildren
44
+ * because they never see the `agent` tool in the first place.
45
+ * - The audit log threads cleanly: parent's `tool_call: agent(...)`
46
+ * pairs with the child's `subagent.spawned/tool_call/completed`
47
+ * events, and a single SSE replay yields the full tree.
48
+ */
49
+ import { z } from 'zod';
50
+ import { randomUUID } from 'node:crypto';
51
+ import { relative as relativePath } from 'node:path';
52
+ import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
53
+ /**
54
+ * Argument schema. `isolation: 'auto'` defers to the role-default
55
+ * isolation tier (set by `isolationForRole` in dispatcher.ts). The
56
+ * explicit `worktree` opt-in forces worktree isolation even for roles
57
+ * whose default is `shared_fs_serialized`; `shared_fs` does the
58
+ * inverse (forces shared-fs even for roles whose default is `worktree`).
59
+ *
60
+ * The role enum mirrors the SDK's SubagentRole — keep both in lockstep.
61
+ *
62
+ * Leak P0 L2 (2026-05-27): `z.strictObject` rejects ANY additional or
63
+ * aliased fields at parse time. Matches the openclaude FileEditTool /
64
+ * FileWriteTool posture (research memo §1.1). The model-facing JSON
65
+ * schema already declares `additionalProperties: false`; the strict
66
+ * Zod variant is defense-in-depth — if the bridge ever bypasses the
67
+ * model-side gate (raw test fixture, internal dispatch), the runtime
68
+ * still refuses unknown keys instead of silently dropping them.
69
+ */
70
+ export const agentToolArgsSchema = z.strictObject({
71
+ role: z.enum([
72
+ 'orchestrator',
73
+ 'architect',
74
+ 'coder',
75
+ 'verifier',
76
+ 'reviewer',
77
+ 'researcher',
78
+ 'release',
79
+ 'devops',
80
+ 'design_qa',
81
+ ]).describe('SubagentRole — selects persona + isolation tier.'),
82
+ brief: z
83
+ .string()
84
+ .min(1, 'brief must not be empty')
85
+ .max(8000, 'brief must be ≤ 8000 chars')
86
+ .describe('One-paragraph task description forwarded to the child as the user prompt. '
87
+ + 'Be concrete: include filenames, expected behavior, and acceptance criteria.'),
88
+ isolation: z
89
+ .enum(['worktree', 'shared_fs', 'auto'])
90
+ .optional()
91
+ .describe('Optional override. `worktree` forces a scratch git worktree for write isolation; '
92
+ + '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.'),
93
+ });
94
+ /**
95
+ * Dispatch a subagent via the `agent` tool. Returns the JSON envelope
96
+ * the executor wraps into the tool result frame. Throws when the
97
+ * arguments fail schema validation — the executor catches and feeds
98
+ * the message back to the model so it can correct itself.
99
+ */
100
+ export async function agentTool(args, ctx) {
101
+ const validated = agentToolArgsSchema.parse(args);
102
+ if (!ctx.engineClient) {
103
+ // Hard refusal: the `agent` tool is real-backend-only. Surfacing a
104
+ // structured envelope (instead of throwing) lets the model decide
105
+ // whether to abandon the delegation or to fall back to in-process
106
+ // work. Throwing here would terminate the parent loop on a tool
107
+ // error frame, which is the wrong UX when the issue is config.
108
+ return {
109
+ ok: false,
110
+ taskId: `subagent-rejected-${randomUUID()}`,
111
+ role: validated.role,
112
+ personaSlug: '',
113
+ status: 'failed',
114
+ summary: 'agent tool unavailable: no engine client wired through the parent dispatch. '
115
+ + 'Run pugi via the standard CLI entrypoints; the in-memory test harness does '
116
+ + 'not currently support real subagent spawn.',
117
+ filesChanged: [],
118
+ toolCallCount: 0,
119
+ tokensIn: 0,
120
+ tokensOut: 0,
121
+ durationMs: 0,
122
+ };
123
+ }
124
+ // β2 S10 pre-flight (best-effort): refuse the spawn if the child's
125
+ // role-default token budget exceeds the parent's remaining budget.
126
+ // The check is conservative — it uses the child's DEFAULT envelope
127
+ // because we do not know the actual run cost ahead of time. Roles
128
+ // can downscale via SubagentTask.budget overrides; this gate just
129
+ // catches the gross case (parent has 5k left, child default 80k).
130
+ if (ctx.parentBudgetRemaining?.tokens !== undefined) {
131
+ const { budgetForRole } = await import('../core/subagents/dispatcher.js');
132
+ const childDefault = budgetForRole(validated.role, undefined);
133
+ if (childDefault.tokens > ctx.parentBudgetRemaining.tokens) {
134
+ return {
135
+ ok: false,
136
+ taskId: `subagent-budget-refused-${randomUUID()}`,
137
+ role: validated.role,
138
+ personaSlug: '',
139
+ status: 'blocked',
140
+ summary: `agent spawn refused: child '${validated.role}' default budget is ${childDefault.tokens} tokens `
141
+ + `but parent has only ${ctx.parentBudgetRemaining.tokens} tokens remaining. `
142
+ + 'Tighten the child task budget or finish the parent first.',
143
+ filesChanged: [],
144
+ toolCallCount: 0,
145
+ tokensIn: 0,
146
+ tokensOut: 0,
147
+ durationMs: 0,
148
+ };
149
+ }
150
+ }
151
+ const task = {
152
+ id: `subagent-${randomUUID()}`,
153
+ role: validated.role,
154
+ prompt: validated.brief,
155
+ // `auto` permission mode matches the parent loop's default; the
156
+ // isolation-matrix capability gate provides the load-bearing
157
+ // restriction layer regardless of permissionMode.
158
+ permissionMode: 'auto',
159
+ };
160
+ const useWorktree = validated.isolation === 'worktree'
161
+ ? true
162
+ : validated.isolation === 'shared_fs'
163
+ ? false
164
+ : undefined; // 'auto' → defer to role default
165
+ const outcome = await spawnSubagentWithOutcome(task, ctx.session, {
166
+ engineClient: ctx.engineClient,
167
+ ...(useWorktree !== undefined ? { useWorktreeIsolation: useWorktree } : {}),
168
+ });
169
+ const envelope = {
170
+ // `ok` = subagent did not crash. Both `shipped` (real work) and
171
+ // `replied` (text-only completion, added 2026-05-26) count as
172
+ // non-crash outcomes; the caller can branch on the explicit
173
+ // `status` field below if it needs to distinguish them.
174
+ ok: outcome.result.status === 'shipped' || outcome.result.status === 'replied',
175
+ taskId: outcome.result.taskId,
176
+ role: outcome.result.role,
177
+ personaSlug: outcome.result.personaSlug,
178
+ status: outcome.result.status,
179
+ summary: outcome.result.summary,
180
+ filesChanged: outcome.result.filesChanged,
181
+ toolCallCount: outcome.result.toolCallCount,
182
+ tokensIn: outcome.result.tokensIn,
183
+ tokensOut: outcome.result.tokensOut,
184
+ durationMs: outcome.result.durationMs,
185
+ };
186
+ if (outcome.worktreeHandle) {
187
+ // β2a r2 (Codex P1, 2026-05-26): emit the worktree path RELATIVE to
188
+ // the parent session's workspace root. The envelope is JSON-stringified
189
+ // into the parent loop's tool_result frame and from there flows to the
190
+ // provider on every subsequent assistant turn — shipping the absolute
191
+ // path (`/Users/<operator>/Web/.../.pugi/worktrees/<uuid>`) leaks the
192
+ // operator's home directory to the upstream provider on every spawn.
193
+ //
194
+ // The composeSummary path (dispatcher-real.ts §β2a r1) already scrubs
195
+ // the summary text via the same `relative()` wrapping; this is the
196
+ // matching fix for the structured envelope field that r1 missed.
197
+ // The relative form (`.pugi/worktrees/<uuid>`) is enough for the
198
+ // operator's local `pugi worktree promote/drop` commands which run
199
+ // resolved against ctx.session.root anyway.
200
+ const relPath = relativePath(ctx.session.root, outcome.worktreeHandle.path)
201
+ || outcome.worktreeHandle.path;
202
+ envelope.worktreePath = relPath;
203
+ }
204
+ return envelope;
205
+ }
206
+ //# sourceMappingURL=agent-tool.js.map
@@ -43,6 +43,7 @@
43
43
  * Brand voice: ASCII only, no emoji, no banned words.
44
44
  */
45
45
  import { spawnSync } from 'node:child_process';
46
+ import { existsSync, rmSync } from 'node:fs';
46
47
  import { resolve, sep } from 'node:path';
47
48
  import { applySecurityGate } from '../core/edits/security-gate.js';
48
49
  import { gateOnCancellation, OperatorAbortedError } from './file-tools.js';
@@ -53,6 +54,19 @@ import { recordToolCall, recordToolResult, recordFileMutation } from '../core/se
53
54
  * `+++ b/<path>` lines that plain `diff -u` emits. The full set of
54
55
  * touched paths feeds the security gate — EVERY file goes through
55
56
  * `applySecurityGate` before we trust `git apply` to do anything.
57
+ *
58
+ * Security (R1 fix 2026-05-26, PR #413 r1): git emits C-style quoted
59
+ * path headers when a path contains "unusual" bytes (high bits, control
60
+ * chars, double-quote, backslash) and `core.quotePath` is true (the
61
+ * default). The literal header looks like
62
+ * `diff --git "a/.env" "b/.env"`. Before this fix the regex captured
63
+ * the literal `"b/.env"` string and the security gate's basename match
64
+ * never saw `.env` — `basename('"b/.env"')` is `'.env"'` (note the
65
+ * trailing quote) which does NOT match the `.env` protected pattern.
66
+ * `git apply` then de-quoted the header and happily landed on the real
67
+ * `.env`. We strip the surrounding quotes + decode the C-style escapes
68
+ * via `unquoteGitPath` BEFORE passing to the security gate so the
69
+ * basename matcher sees the real target.
56
70
  */
57
71
  export function extractPatchPaths(patch) {
58
72
  const paths = new Set();
@@ -62,12 +76,24 @@ export function extractPatchPaths(patch) {
62
76
  // quoted by git's own diff machinery (rare). The robust extractor
63
77
  // matches the `b/...` half because rename diffs carry the new
64
78
  // name there.
79
+ // Two variants: unquoted (`a/foo b/bar`) and C-style quoted
80
+ // (`"a/foo" "b/bar"`). We try the quoted form first because the
81
+ // unquoted regex below would accept the literal quote as part of
82
+ // the path otherwise.
83
+ const quoted = line.match(/^diff --git "a\/(.+)" "b\/(.+)"$/);
84
+ if (quoted) {
85
+ if (quoted[1])
86
+ paths.add(unquoteGitPath(quoted[1]));
87
+ if (quoted[2])
88
+ paths.add(unquoteGitPath(quoted[2]));
89
+ continue;
90
+ }
65
91
  const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
66
92
  if (match) {
67
93
  if (match[1])
68
- paths.add(match[1]);
94
+ paths.add(unquoteGitPath(match[1]));
69
95
  if (match[2])
70
- paths.add(match[2]);
96
+ paths.add(unquoteGitPath(match[2]));
71
97
  }
72
98
  continue;
73
99
  }
@@ -75,22 +101,131 @@ export function extractPatchPaths(patch) {
75
101
  const after = line.slice(4).trim();
76
102
  if (after === '/dev/null')
77
103
  continue;
78
- const trimmed = after.startsWith('b/') ? after.slice(2) : after;
79
- if (trimmed)
80
- paths.add(stripTimestampSuffix(trimmed));
104
+ const stripped = stripQuotedHalf(after, 'b/');
105
+ if (stripped)
106
+ paths.add(stripTimestampSuffix(stripped));
81
107
  continue;
82
108
  }
83
109
  if (line.startsWith('--- ')) {
84
110
  const after = line.slice(4).trim();
85
111
  if (after === '/dev/null')
86
112
  continue;
87
- const trimmed = after.startsWith('a/') ? after.slice(2) : after;
88
- if (trimmed)
89
- paths.add(stripTimestampSuffix(trimmed));
113
+ const stripped = stripQuotedHalf(after, 'a/');
114
+ if (stripped)
115
+ paths.add(stripTimestampSuffix(stripped));
90
116
  }
91
117
  }
92
118
  return Array.from(paths);
93
119
  }
120
+ /**
121
+ * Strip the leading `a/` or `b/` prefix from a `---` / `+++` line,
122
+ * handling both unquoted (`b/.env`) and C-style quoted (`"b/.env"`)
123
+ * variants. The returned path is fully de-quoted so the security gate
124
+ * sees the real basename. Returns null when the line does not parse.
125
+ */
126
+ function stripQuotedHalf(after, prefix) {
127
+ // Quoted form: `"b/path with \"escapes\""`. Detect surrounding quotes
128
+ // first, strip them, then peel the prefix, then unquote the inner
129
+ // C-style escapes.
130
+ if (after.startsWith('"') && after.endsWith('"') && after.length >= 2) {
131
+ const inner = after.slice(1, -1);
132
+ const peeled = inner.startsWith(prefix) ? inner.slice(prefix.length) : inner;
133
+ return unquoteGitPath(peeled);
134
+ }
135
+ const trimmed = after.startsWith(prefix) ? after.slice(prefix.length) : after;
136
+ return trimmed;
137
+ }
138
+ /**
139
+ * Decode git's C-style path quoting. When `core.quotePath` is true
140
+ * (default) git writes paths with high-bit / control / quote bytes as
141
+ * C-string escapes inside double quotes:
142
+ *
143
+ * `"\.env"` -> `.env` (backslash before . is just a literal)
144
+ * `"a\"b"` -> `a"b` (escaped double-quote)
145
+ * `"a\\b"` -> `a\b` (escaped backslash)
146
+ * `"a\tb"` -> `a` + TAB + `b`
147
+ * `"a\341\210\264"` -> `a` + UTF-8 bytes 0xe1 0x88 0xb4
148
+ *
149
+ * Accepts a path that is EITHER already unquoted (passed through) OR an
150
+ * inner string previously stripped of its surrounding quotes. The
151
+ * function is idempotent on already-clean ASCII paths.
152
+ *
153
+ * Reference: git source `quote.c::unquote_c_style`.
154
+ */
155
+ export function unquoteGitPath(s) {
156
+ // If the caller passed us a wrapped string (`"foo"`), peel it now.
157
+ if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) {
158
+ s = s.slice(1, -1);
159
+ }
160
+ // Fast path: no backslash means no C-style escapes, return as-is.
161
+ if (!s.includes('\\'))
162
+ return s;
163
+ const out = [];
164
+ for (let i = 0; i < s.length; i += 1) {
165
+ const ch = s[i];
166
+ if (ch !== '\\') {
167
+ // Single-byte ASCII or multi-byte JS string char; the byte we
168
+ // emit must match its UTF-8 encoding so the security gate sees
169
+ // the same bytes the filesystem will. JS strings are UTF-16; we
170
+ // bounce through Buffer to get the canonical UTF-8 bytes.
171
+ const bytes = Buffer.from(ch ?? '', 'utf8');
172
+ for (const b of bytes)
173
+ out.push(b);
174
+ continue;
175
+ }
176
+ const next = s[i + 1];
177
+ if (next === undefined) {
178
+ // Trailing backslash with no follower — emit literal.
179
+ out.push(0x5c);
180
+ continue;
181
+ }
182
+ // Three-digit octal escape: `\NNN` (each digit 0-7).
183
+ if (next >= '0' && next <= '7' && i + 3 < s.length + 1) {
184
+ const oct = s.slice(i + 1, i + 4);
185
+ if (/^[0-7]{3}$/.test(oct)) {
186
+ out.push(Number.parseInt(oct, 8));
187
+ i += 3;
188
+ continue;
189
+ }
190
+ }
191
+ switch (next) {
192
+ case 'a':
193
+ out.push(0x07);
194
+ break;
195
+ case 'b':
196
+ out.push(0x08);
197
+ break;
198
+ case 't':
199
+ out.push(0x09);
200
+ break;
201
+ case 'n':
202
+ out.push(0x0a);
203
+ break;
204
+ case 'v':
205
+ out.push(0x0b);
206
+ break;
207
+ case 'f':
208
+ out.push(0x0c);
209
+ break;
210
+ case 'r':
211
+ out.push(0x0d);
212
+ break;
213
+ case '"':
214
+ out.push(0x22);
215
+ break;
216
+ case '\\':
217
+ out.push(0x5c);
218
+ break;
219
+ default:
220
+ // Unknown escape — emit the escape char as a literal so we
221
+ // don't silently drop bytes. Mirrors git's own permissive
222
+ // behaviour.
223
+ out.push(next.charCodeAt(0));
224
+ }
225
+ i += 1;
226
+ }
227
+ return Buffer.from(out).toString('utf8');
228
+ }
94
229
  /**
95
230
  * `diff -u` (non-git) emits trailing tab-prefixed timestamps after the
96
231
  * path: `--- foo.ts\t2026-05-25 10:00:00`. Strip those so the security
@@ -126,6 +261,25 @@ export function applyPatch(ctx, patch, opts = {}) {
126
261
  recordToolResult(ctx.session, toolCallId, 'error', 'empty_patch');
127
262
  return result;
128
263
  }
264
+ // β7 L4: pre-flight conflict-marker check. A patch that still carries
265
+ // unresolved `<<<<<<<`/`=======`/`>>>>>>>` lines is almost always
266
+ // operator error (copy-pasted a half-resolved merge instead of the
267
+ // clean diff). `git apply` would reject it with a confusing
268
+ // "corrupt patch" message; the dedicated reason makes the failure
269
+ // obvious. We only check at body line starts so a legitimate diff
270
+ // that adds a string literal containing `<<<<<<<` for tests still
271
+ // applies.
272
+ if (containsConflictMarkers(patch)) {
273
+ const result = {
274
+ ok: false,
275
+ filesChanged: [],
276
+ reason: 'conflict_markers',
277
+ detail: 'patch body contains unresolved git conflict markers (<<<<<<<, =======, >>>>>>>). ' +
278
+ 'Resolve the conflict first or use --3way with --base=<sha> to defer to git.',
279
+ };
280
+ recordToolResult(ctx.session, toolCallId, 'error', 'conflict_markers');
281
+ return result;
282
+ }
129
283
  const paths = extractPatchPaths(patch);
130
284
  if (paths.length === 0) {
131
285
  const result = {
@@ -179,20 +333,20 @@ export function applyPatch(ctx, patch, opts = {}) {
179
333
  if (check.status !== 0) {
180
334
  // Decide whether this is the "already applied" case or a real
181
335
  // failure. `git apply --check` rejects an already-applied patch
182
- // with stderr containing `error: patch failed` and at least one
183
- // hunk that mentions "patch does not apply" or "already exists".
184
- // We use a conservative heuristic: when EVERY targeted file in the
185
- // diff is present and the patch's pre-image is missing, treat it
186
- // as already_applied. The simpler signal is the stderr string
187
- // containing `already exists in working directory` (git's own
188
- // message for a creating patch landing twice) or `does not apply`.
336
+ // with stderr containing patterns like "patch does not apply" or
337
+ // "already exists in working directory". The simpler signal is
338
+ // the stderr string containing `already exists in working directory`
339
+ // (git's own message for a creating patch landing twice) that's
340
+ // the only path we treat as `already_applied` here. Other stderr
341
+ // surfaces fall through to `check_failed` so the operator sees the
342
+ // raw reason.
189
343
  const stderr = check.stderr.toLowerCase();
190
- if (stderr.includes('already exists in working directory') || isLikelyAlreadyApplied(check.stderr, patch)) {
344
+ if (stderr.includes('already exists in working directory')) {
191
345
  const result = {
192
346
  ok: false,
193
347
  filesChanged: [],
194
348
  reason: 'already_applied',
195
- detail: 'patch pre-image does not match working treepatch was likely already applied',
349
+ detail: 'patch creates a path that already exists — likely already applied',
196
350
  };
197
351
  recordToolResult(ctx.session, toolCallId, 'error', 'already_applied');
198
352
  return result;
@@ -214,6 +368,17 @@ export function applyPatch(ctx, patch, opts = {}) {
214
368
  recordToolResult(ctx.session, toolCallId, 'success', `dry-run ok, ${paths.length} files`);
215
369
  return result;
216
370
  }
371
+ // R1 fix (2026-05-26, PR #413 r1, Fix 6): snapshot which paths exist
372
+ // BEFORE the apply so rollbackFiles can decide between
373
+ // `git checkout -- <file>` (for files that existed) and `fs.rmSync`
374
+ // (for files the patch was creating that may have been half-written
375
+ // before the failure). Without this snapshot, `git checkout`
376
+ // gracefully no-ops on a never-tracked file and the partial creation
377
+ // is left behind.
378
+ const preExisting = new Map();
379
+ for (const p of paths) {
380
+ preExisting.set(p, existsSync(resolve(ctx.root, p)));
381
+ }
217
382
  const applyArgs = ['apply'];
218
383
  if (opts.baseSha)
219
384
  applyArgs.push('--3way');
@@ -223,7 +388,7 @@ export function applyPatch(ctx, patch, opts = {}) {
223
388
  // Apply failed AFTER --check passed. This is almost always a TOCTOU
224
389
  // (another writer touched a file between the two git calls).
225
390
  // Rollback ANY partial mutation so the workspace stays consistent.
226
- const rollback = rollbackFiles(ctx.root, paths);
391
+ const rollback = rollbackFiles(ctx.root, paths, preExisting);
227
392
  const detail = apply.stderr.trim() || 'git apply failed after passing --check';
228
393
  if (!rollback.ok) {
229
394
  const result = {
@@ -258,28 +423,26 @@ export function applyPatch(ctx, patch, opts = {}) {
258
423
  recordToolResult(ctx.session, toolCallId, 'success', `applied ${paths.length} files`);
259
424
  return { ok: true, filesChanged: paths };
260
425
  }
261
- /**
262
- * Heuristic for the "patch already applied" case beyond git's explicit
263
- * `already exists` string. When `git apply --check` rejects with
264
- * `patch does not apply` AND every hunk's target file exists with
265
- * matching post-image lines, the patch is effectively a no-op repeat.
266
- * We keep the heuristic minimal because the explicit string covers the
267
- * dominant case; full hunk inspection would require parsing the patch
268
- * (which is what `git apply` does in the first place).
269
- */
270
- function isLikelyAlreadyApplied(stderr, _patch) {
271
- return stderr.toLowerCase().includes('patch does not apply') === false ? false : false;
272
- // Intentionally conservative for now — the explicit `already exists`
273
- // case above covers the only path that's reliably distinguishable
274
- // from a malformed-patch failure. A richer detector can land in
275
- // α7.7b when we have a corpus of real "already_applied" stderr samples.
276
- }
277
426
  /**
278
427
  * Roll back any partial mutation by checking files out from HEAD. Used
279
428
  * only on the rare path where `git apply` fails AFTER `git apply --check`
280
429
  * passed.
430
+ *
431
+ * R1 fix (2026-05-26, PR #413 r1, Fix 6): a multi-file patch that
432
+ * creates new files leaves them on disk when `git apply` fails partway —
433
+ * `git checkout -- <file>` does NOT delete a path that was never tracked
434
+ * (the file was created by the failed apply). We split paths into two
435
+ * groups using the pre-apply snapshot:
436
+ *
437
+ * - existed-before -> `git checkout -- <file>` restores tracked content.
438
+ * - created-by-apply -> `fs.rmSync(file, { force: true })` removes the
439
+ * half-written file so the workspace ends up identical to its
440
+ * pre-apply state.
441
+ *
442
+ * This keeps the dispatcher's invariant: a tool result of `ok: false`
443
+ * means the workspace is unchanged.
281
444
  */
282
- function rollbackFiles(cwd, paths) {
445
+ function rollbackFiles(cwd, paths, preExisting) {
283
446
  if (paths.length === 0)
284
447
  return { ok: true };
285
448
  // We only attempt to roll back files that are inside the workspace
@@ -291,24 +454,103 @@ function rollbackFiles(cwd, paths) {
291
454
  });
292
455
  if (safePaths.length === 0)
293
456
  return { ok: true };
294
- const result = runGit(['checkout', '--', ...safePaths], cwd);
295
- if (result.status !== 0) {
296
- return { ok: false, detail: result.stderr.trim() };
457
+ const toCheckout = [];
458
+ const toRemove = [];
459
+ for (const p of safePaths) {
460
+ if (preExisting.get(p))
461
+ toCheckout.push(p);
462
+ else
463
+ toRemove.push(p);
464
+ }
465
+ // Unlink files that the patch was creating. `force: true` swallows
466
+ // ENOENT so a creation that never got far enough to write the file
467
+ // is a no-op. We record every unlink failure but keep going so a
468
+ // single permission error on one file doesn't strand the others.
469
+ const removeFailures = [];
470
+ for (const p of toRemove) {
471
+ const abs = resolve(cwd, p);
472
+ try {
473
+ rmSync(abs, { force: true });
474
+ }
475
+ catch (error) {
476
+ removeFailures.push(`${p}: ${error instanceof Error ? error.message : String(error)}`);
477
+ }
478
+ }
479
+ if (toCheckout.length > 0) {
480
+ const result = runGit(['checkout', '--', ...toCheckout], cwd);
481
+ if (result.status !== 0) {
482
+ const detail = [result.stderr.trim(), ...removeFailures].filter(Boolean).join('; ');
483
+ return { ok: false, detail };
484
+ }
485
+ }
486
+ if (removeFailures.length > 0) {
487
+ return { ok: false, detail: `rollback unlink failed: ${removeFailures.join('; ')}` };
297
488
  }
298
489
  return { ok: true };
299
490
  }
300
491
  function runGit(args, cwd, stdin) {
492
+ // R1 fix (2026-05-26, PR #413 r1, P2 #13): force the English C locale
493
+ // for the git child process. The `already_applied` reason-coding
494
+ // below greps stderr for the literal English string
495
+ // "already exists in working directory"; on a host where git was
496
+ // installed with a translated message catalog (de_DE / ru_RU / etc.)
497
+ // the substring match would silently miss and the operator would see
498
+ // `check_failed` instead of `already_applied`. C locale (also
499
+ // LC_ALL) guarantees the canonical message regardless of host env.
301
500
  return spawnSync('git', args, {
302
501
  cwd,
303
502
  input: stdin,
304
503
  encoding: 'utf8',
305
504
  maxBuffer: 64 * 1024 * 1024,
505
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
306
506
  });
307
507
  }
508
+ /**
509
+ * β7 L4: detect unresolved git conflict markers in a patch body.
510
+ *
511
+ * Conflict markers in a unified diff are a sign of operator error —
512
+ * someone copy-pasted a half-merged file instead of the clean diff.
513
+ * `git apply` would reject the patch with a confusing parse error
514
+ * ("corrupt patch at line N"). We check at the START of body lines so
515
+ * a legitimate diff that adds a string literal containing `<<<<<<<`
516
+ * (rare but legitimate for tests) still applies.
517
+ *
518
+ * Conflict marker bytes in a unified diff body look like:
519
+ *
520
+ * +<<<<<<< HEAD
521
+ * +=======
522
+ * +>>>>>>> branch
523
+ *
524
+ * The `+` prefix is the unified-diff line-add marker. We strip it
525
+ * before the marker check; without the strip, an INVERSE diff that
526
+ * REMOVES a real conflict marker (legitimate cleanup commit) would be
527
+ * a false positive.
528
+ *
529
+ * Returns true when ANY conflict marker is detected.
530
+ */
531
+ export function containsConflictMarkers(patch) {
532
+ for (const line of patch.split('\n')) {
533
+ // Only inspect body lines (start with `+` or `-` — the diff add/del
534
+ // markers). Header lines (`diff --git`, `+++`, `---`, `@@`) are
535
+ // skipped because the marker tokens cannot appear in those positions.
536
+ if (!(line.startsWith('+') || line.startsWith('-')))
537
+ continue;
538
+ // Skip diff header lines (`+++ b/foo` / `--- a/foo`).
539
+ if (line.startsWith('+++') || line.startsWith('---'))
540
+ continue;
541
+ const body = line.slice(1);
542
+ if (body.startsWith('<<<<<<<') ||
543
+ body.startsWith('>>>>>>>') ||
544
+ body === '=======') {
545
+ return true;
546
+ }
547
+ }
548
+ return false;
549
+ }
308
550
  /**
309
551
  * Test-only surface for the apply-patch heuristics. Specs poke
310
552
  * `extractPatchPaths` directly to assert on the path-parsing layer
311
553
  * without paying for a real git invocation.
312
554
  */
313
- export const __test__ = { extractPatchPaths, isLikelyAlreadyApplied, runGit };
555
+ export const __test__ = { extractPatchPaths, runGit, unquoteGitPath, containsConflictMarkers };
314
556
  //# sourceMappingURL=apply-patch.js.map