@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.
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- 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
|
|
79
|
-
if (
|
|
80
|
-
paths.add(stripTimestampSuffix(
|
|
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
|
|
88
|
-
if (
|
|
89
|
-
paths.add(stripTimestampSuffix(
|
|
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
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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')
|
|
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
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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,
|
|
555
|
+
export const __test__ = { extractPatchPaths, runGit, unquoteGitPath, containsConflictMarkers };
|
|
314
556
|
//# sourceMappingURL=apply-patch.js.map
|