@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10
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/README.md +33 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +16 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +8 -0
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +719 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1908 -13
- package/dist/core/repl/slash-commands.js +92 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +998 -12
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/lsp.js +206 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/tools/apply-patch.js +495 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +26 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +319 -3
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +96 -12
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +14 -6
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree isolation — α7.7 Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `git worktree add` so a long agent loop (build / consensus
|
|
5
|
+
* review / multi-file refactor) can land its edits into a scratch
|
|
6
|
+
* workspace, run the validators against THAT path, and only then promote
|
|
7
|
+
* the resulting diff back to the operator's main working tree. The
|
|
8
|
+
* primary win is safety: a half-applied refactor never corrupts the
|
|
9
|
+
* operator's branch.
|
|
10
|
+
*
|
|
11
|
+
* Three operations:
|
|
12
|
+
*
|
|
13
|
+
* - `createWorktree(branch)` — spawns `git worktree add --detach`
|
|
14
|
+
* under `.pugi/worktrees/<uuid>` based on the supplied branch (or
|
|
15
|
+
* HEAD when omitted). Returns the absolute path + a `cleanup()`
|
|
16
|
+
* callback. The dir lives under `.pugi/` so the existing `.gitignore`
|
|
17
|
+
* for that subtree applies (no accidental commits of the scratch
|
|
18
|
+
* state to the main repo).
|
|
19
|
+
*
|
|
20
|
+
* - `promoteWorktree(worktreePath, cwd)` — diffs the worktree against
|
|
21
|
+
* its base commit and applies the diff to the main `cwd` via
|
|
22
|
+
* `git apply`. Refuses if the main cwd has staged changes that
|
|
23
|
+
* would conflict; the operator must commit or stash first.
|
|
24
|
+
*
|
|
25
|
+
* - `dropWorktree(worktreePath)` — removes the worktree both from
|
|
26
|
+
* git's bookkeeping (`git worktree remove --force`) and from disk.
|
|
27
|
+
* Idempotent; a partially-removed worktree (`git` already cleaned
|
|
28
|
+
* up but dir survived) is handled.
|
|
29
|
+
*
|
|
30
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
31
|
+
*/
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
|
34
|
+
import { randomUUID } from 'node:crypto';
|
|
35
|
+
import { resolve, sep } from 'node:path';
|
|
36
|
+
import { OperatorAbortedError } from '../../tools/file-tools.js';
|
|
37
|
+
import { applySecurityGate } from './security-gate.js';
|
|
38
|
+
import { extractPatchPaths } from '../../tools/apply-patch.js';
|
|
39
|
+
/**
|
|
40
|
+
* Create a scratch worktree under `.pugi/worktrees/<uuid>`. The path is
|
|
41
|
+
* guaranteed unique (uuid) so multiple agent loops can run in parallel
|
|
42
|
+
* without collision.
|
|
43
|
+
*/
|
|
44
|
+
export function createWorktree(opts) {
|
|
45
|
+
if (opts.cancellation && opts.cancellation.isAborted) {
|
|
46
|
+
return { ok: false, reason: 'operator_aborted', detail: 'createWorktree aborted' };
|
|
47
|
+
}
|
|
48
|
+
// Confirm we're inside a git repo. `git rev-parse --git-dir` is the
|
|
49
|
+
// canonical check and avoids a misleading error message later when
|
|
50
|
+
// `git worktree add` runs in a non-repo.
|
|
51
|
+
const gitDir = runGit(['rev-parse', '--git-dir'], opts.cwd);
|
|
52
|
+
if (gitDir.status !== 0) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: 'not_a_git_repo',
|
|
56
|
+
detail: `not a git repo: ${opts.cwd}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Resolve base SHA. When the operator named a branch we honor it; the
|
|
60
|
+
// default is HEAD. We capture the SHA up-front so `promoteWorktree`
|
|
61
|
+
// can `git diff <baseSha>..HEAD` deterministically even if the main
|
|
62
|
+
// working tree has moved forward since.
|
|
63
|
+
const baseRef = opts.branch ?? 'HEAD';
|
|
64
|
+
const baseShaResult = runGit(['rev-parse', baseRef], opts.cwd);
|
|
65
|
+
if (baseShaResult.status !== 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: 'git_command_failed',
|
|
69
|
+
detail: `cannot resolve base ref ${baseRef}: ${baseShaResult.stderr}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const baseSha = baseShaResult.stdout.trim();
|
|
73
|
+
const worktreeRoot = resolve(opts.cwd, '.pugi', 'worktrees');
|
|
74
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
75
|
+
const worktreePath = resolve(worktreeRoot, randomUUID());
|
|
76
|
+
// `--detach` keeps the worktree on a detached HEAD so we don't
|
|
77
|
+
// collide with branch checkouts on the main tree. The worktree is
|
|
78
|
+
// throwaway — there is no branch name to track.
|
|
79
|
+
const create = runGit(['worktree', 'add', '--detach', worktreePath, baseSha], opts.cwd);
|
|
80
|
+
if (create.status !== 0) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: 'git_command_failed',
|
|
84
|
+
detail: `git worktree add failed: ${create.stderr}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const handle = {
|
|
88
|
+
path: worktreePath,
|
|
89
|
+
baseSha,
|
|
90
|
+
cleanup: () => {
|
|
91
|
+
const r = dropWorktree(worktreePath, opts.cwd);
|
|
92
|
+
if (!r.ok && r.reason !== 'worktree_missing') {
|
|
93
|
+
// Swallow non-fatal cleanup failures so the agent loop doesn't
|
|
94
|
+
// hard-crash on the happy path. The diagnostic still surfaces
|
|
95
|
+
// via the JSON output on the `pugi worktree drop` command.
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return { ok: true, value: handle };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Diff the worktree against its base and apply the diff to the main cwd.
|
|
103
|
+
*
|
|
104
|
+
* Implementation notes:
|
|
105
|
+
*
|
|
106
|
+
* - We run `git diff --binary <baseSha>` inside the worktree (NOT
|
|
107
|
+
* `git diff <worktree>..HEAD` from the main tree — the worktree's
|
|
108
|
+
* HEAD is detached at `baseSha`, so the meaningful diff is the
|
|
109
|
+
* UNCOMMITTED changes the agent wrote into it).
|
|
110
|
+
* - `--binary` ensures non-text files (assets, images) survive the
|
|
111
|
+
* round-trip; without it `git apply` fails on any binary delta.
|
|
112
|
+
* - We always run `git apply --check` first so a refusal does not
|
|
113
|
+
* leave the main tree half-modified.
|
|
114
|
+
*/
|
|
115
|
+
export function promoteWorktree(opts) {
|
|
116
|
+
if (opts.cancellation && opts.cancellation.isAborted) {
|
|
117
|
+
return { ok: false, reason: 'operator_aborted', detail: 'promoteWorktree aborted' };
|
|
118
|
+
}
|
|
119
|
+
if (!existsSync(opts.worktreePath)) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: 'worktree_missing',
|
|
123
|
+
detail: `worktree path does not exist: ${opts.worktreePath}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Capture the diff against the base SHA. `git diff <baseSha>`
|
|
127
|
+
// (no `--cached`) compares the WORKING TREE against the base, which
|
|
128
|
+
// covers both unstaged AND staged changes in a single invocation —
|
|
129
|
+
// anything the working tree shows is included. `--binary` ensures
|
|
130
|
+
// non-text files survive the round-trip.
|
|
131
|
+
//
|
|
132
|
+
// Note: untracked files that were NEVER staged stay invisible — git
|
|
133
|
+
// diff has no native flag to include them. The agent loop must
|
|
134
|
+
// `git add` any new file it wants promoted; the CLI surface
|
|
135
|
+
// documents this explicitly so the contract is not surprising.
|
|
136
|
+
// (Staging is enough to expose the file; the file does not need to
|
|
137
|
+
// be committed.)
|
|
138
|
+
const diffResult = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
|
|
139
|
+
if (diffResult.status !== 0) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: 'git_command_failed',
|
|
143
|
+
detail: `git diff failed: ${diffResult.stderr}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const diffText = diffResult.stdout;
|
|
147
|
+
if (diffText.trim().length === 0) {
|
|
148
|
+
return { ok: true, value: { filesChanged: 0 } };
|
|
149
|
+
}
|
|
150
|
+
// SECURITY GATE (R1 fix 2026-05-26, PR #413 r1) — every path mentioned
|
|
151
|
+
// in the worktree's diff goes through the same `applySecurityGate`
|
|
152
|
+
// chokepoint as the apply_patch + Layer A/B/C applicators. A staged
|
|
153
|
+
// `.env` (or `../../etc/passwd`, or a symlink into a protected target)
|
|
154
|
+
// inside the worktree must NOT slip into the operator's main tree just
|
|
155
|
+
// because the worktree itself was a sandboxed scratch dir. Without
|
|
156
|
+
// this gate, `promoteWorktree` was a clean bypass of every other edit
|
|
157
|
+
// primitive's safety net.
|
|
158
|
+
const diffPaths = extractPatchPaths(diffText);
|
|
159
|
+
const failedPaths = [];
|
|
160
|
+
for (const file of diffPaths) {
|
|
161
|
+
const gate = applySecurityGate(file, { cwd: opts.cwd, toolName: 'layer-c' });
|
|
162
|
+
if (!gate.ok) {
|
|
163
|
+
failedPaths.push(`${file}: ${gate.reason}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (failedPaths.length > 0) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: 'protected_file_in_worktree',
|
|
170
|
+
detail: `worktree diff touches protected/escaping paths: ${failedPaths.join('; ')}`,
|
|
171
|
+
files: failedPaths,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// `git apply --check` validates the diff against the main tree first.
|
|
175
|
+
// Refuse early on conflict so the operator can resolve before we
|
|
176
|
+
// touch any file.
|
|
177
|
+
const check = runGit(['apply', '--check', '-'], opts.cwd, diffText);
|
|
178
|
+
if (check.status !== 0) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: 'apply_conflict',
|
|
182
|
+
detail: `git apply --check rejected: ${check.stderr}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (opts.dryRun) {
|
|
186
|
+
return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
|
|
187
|
+
}
|
|
188
|
+
const apply = runGit(['apply', '-'], opts.cwd, diffText);
|
|
189
|
+
if (apply.status !== 0) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: 'apply_failed',
|
|
193
|
+
detail: `git apply failed: ${apply.stderr}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Drop a worktree both from git's bookkeeping and from disk. Idempotent —
|
|
200
|
+
* a missing path returns `worktree_missing` which the caller can ignore
|
|
201
|
+
* on the cleanup-after-error path.
|
|
202
|
+
*
|
|
203
|
+
* Security (R1 fix 2026-05-26, PR #413 r1): we MUST validate the path is
|
|
204
|
+
* a real subdirectory of `<cwd>/.pugi/worktrees/` BEFORE running either
|
|
205
|
+
* `git worktree remove --force` or `rmSync`. Without this gate, a
|
|
206
|
+
* typo like `pugi worktree drop ../some-dir` recursively deleted an
|
|
207
|
+
* arbitrary directory: `git worktree remove` correctly failed (path not
|
|
208
|
+
* registered), but the `rmSync(worktreePath, recursive: true)` below
|
|
209
|
+
* still fired regardless.
|
|
210
|
+
*
|
|
211
|
+
* We resolve both `cwd` and `worktreePath` through `realpathSync` so a
|
|
212
|
+
* caller passing a symlink that points outside `.pugi/worktrees/` is
|
|
213
|
+
* still rejected. When the worktree path does not exist on disk at all
|
|
214
|
+
* (idempotent re-drop of an already-removed worktree), we fall back to
|
|
215
|
+
* the lexical containment check — the rejection only matters when there
|
|
216
|
+
* is a real directory to delete.
|
|
217
|
+
*/
|
|
218
|
+
export function dropWorktree(worktreePath, cwd) {
|
|
219
|
+
// SECURITY GATE — validate containment under `<cwd>/.pugi/worktrees/`
|
|
220
|
+
// BEFORE any destructive call. Two-tier check:
|
|
221
|
+
// 1. lexical containment using resolved (but not realpath'd) paths,
|
|
222
|
+
// catches the operator-typo + missing-worktree cases.
|
|
223
|
+
// 2. realpath containment when the path exists, catches symlink
|
|
224
|
+
// shenanigans.
|
|
225
|
+
const scratchRootLexical = resolve(cwd, '.pugi', 'worktrees');
|
|
226
|
+
const worktreeLexical = resolve(cwd, worktreePath);
|
|
227
|
+
const insideLexical = worktreeLexical.startsWith(scratchRootLexical + sep) &&
|
|
228
|
+
worktreeLexical !== scratchRootLexical;
|
|
229
|
+
if (!insideLexical) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
reason: 'invalid_worktree_path',
|
|
233
|
+
detail: `worktree path ${worktreePath} is not under ${scratchRootLexical}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (existsSync(worktreeLexical)) {
|
|
237
|
+
try {
|
|
238
|
+
const realScratchRoot = realpathSync(scratchRootLexical);
|
|
239
|
+
const realWorktree = realpathSync(worktreeLexical);
|
|
240
|
+
const insideReal = realWorktree.startsWith(realScratchRoot + sep) &&
|
|
241
|
+
realWorktree !== realScratchRoot;
|
|
242
|
+
if (!insideReal) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: 'invalid_worktree_path',
|
|
246
|
+
detail: `worktree realpath ${realWorktree} escapes ${realScratchRoot}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// realpath failed for a path that exists — surface as
|
|
252
|
+
// invalid_worktree_path so we never recurse into rmSync on an
|
|
253
|
+
// unreadable path.
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
reason: 'invalid_worktree_path',
|
|
257
|
+
detail: `cannot realpath worktree path: ${error instanceof Error ? error.message : String(error)}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// `git worktree remove --force` cleans the metadata in `.git/worktrees`.
|
|
262
|
+
// If the worktree was created by another process and already pruned,
|
|
263
|
+
// git returns non-zero — we still try to `rmSync` the dir to leave the
|
|
264
|
+
// filesystem consistent. Path containment has already been validated
|
|
265
|
+
// above so the rmSync below is bounded to `.pugi/worktrees/`.
|
|
266
|
+
const remove = runGit(['worktree', 'remove', '--force', worktreeLexical], cwd);
|
|
267
|
+
const gitCleanFailed = remove.status !== 0;
|
|
268
|
+
if (existsSync(worktreeLexical)) {
|
|
269
|
+
try {
|
|
270
|
+
rmSync(worktreeLexical, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (gitCleanFailed) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
reason: 'git_command_failed',
|
|
277
|
+
detail: `git worktree remove failed AND rmSync failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (gitCleanFailed && !worktreeLexical.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
|
|
283
|
+
// A worktree that wasn't created by us (path is outside our naming
|
|
284
|
+
// convention) is suspicious — surface the failure so the operator
|
|
285
|
+
// can diagnose.
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: 'git_command_failed',
|
|
289
|
+
detail: `git worktree remove failed: ${remove.stderr}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, value: undefined };
|
|
293
|
+
}
|
|
294
|
+
function countDiffFiles(diff) {
|
|
295
|
+
// Count `diff --git a/... b/...` headers. Cheap and unambiguous.
|
|
296
|
+
let count = 0;
|
|
297
|
+
for (const line of diff.split('\n')) {
|
|
298
|
+
if (line.startsWith('diff --git '))
|
|
299
|
+
count += 1;
|
|
300
|
+
}
|
|
301
|
+
return count;
|
|
302
|
+
}
|
|
303
|
+
function runGit(args, cwd, stdin) {
|
|
304
|
+
return spawnSync('git', args, {
|
|
305
|
+
cwd,
|
|
306
|
+
input: stdin,
|
|
307
|
+
encoding: 'utf8',
|
|
308
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Test-only helper exporting the internal git runner so specs can stub
|
|
313
|
+
* the spawn surface when running on a CI host without a global git.
|
|
314
|
+
*/
|
|
315
|
+
export const __test__ = { runGit, countDiffFiles };
|
|
316
|
+
/**
|
|
317
|
+
* Re-export the abort marker so the worktree CLI surface can fold the
|
|
318
|
+
* exception into a clean exit code without needing to import from the
|
|
319
|
+
* tools layer.
|
|
320
|
+
*/
|
|
321
|
+
export { OperatorAbortedError };
|
|
322
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -241,6 +241,9 @@ export class NativePugiEngineAdapter {
|
|
|
241
241
|
for (const event of buffer)
|
|
242
242
|
yield event;
|
|
243
243
|
// Translate the loop outcome into an EngineResult.
|
|
244
|
+
// `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
|
|
245
|
+
// because the operator chose the outcome, same shape as
|
|
246
|
+
// budget_exhausted / tool_refused.
|
|
244
247
|
const status = outcome.status === 'completed'
|
|
245
248
|
? 'done'
|
|
246
249
|
: outcome.status === 'failed'
|
|
@@ -252,7 +255,9 @@ export class NativePugiEngineAdapter {
|
|
|
252
255
|
? '[budget_exhausted] '
|
|
253
256
|
: outcome.status === 'tool_refused'
|
|
254
257
|
? '[plan_mode_refused] '
|
|
255
|
-
:
|
|
258
|
+
: outcome.status === 'aborted'
|
|
259
|
+
? '[operator_aborted] '
|
|
260
|
+
: '[failed] ';
|
|
256
261
|
const filesChangedList = Array.from(filesChanged).sort();
|
|
257
262
|
appendSessionMirror(sessionEventsPath, {
|
|
258
263
|
type: 'outcome',
|
|
@@ -22,6 +22,14 @@ import { getJobRegistry, summarizeJobsForPrompt, } from '../jobs/registry.js';
|
|
|
22
22
|
const COMMON_LOCAL_FIRST_PREAMBLE = [
|
|
23
23
|
'You are the Pugi CLI agent running locally inside the operator\'s repository.',
|
|
24
24
|
'The local filesystem is the source of truth. Every change you make is committed locally; nothing is uploaded by default (ADR-0037 local-first).',
|
|
25
|
+
// R1 fix (2026-05-26, PR #413 r1, Fix 5 Option B): only advertise the
|
|
26
|
+
// tools currently wired in `tool-bridge.ts::WIRED_TOOLS`. α7.7 ships
|
|
27
|
+
// apply_patch / lsp_* / worktree_* as CLI-only surfaces (`pugi patch`,
|
|
28
|
+
// `pugi lsp`, `pugi worktree`); wiring them into the engine loop is
|
|
29
|
+
// deferred to β2 (apply_patch), β4 (LSP tools), β7 (worktree tools)
|
|
30
|
+
// per the consolidated sprint plan. Advertising them in the system
|
|
31
|
+
// prompt without a matching executor entry caused Mira to attempt
|
|
32
|
+
// calls that returned `unknown_tool` — broken eval surface.
|
|
25
33
|
'You have a tool registry: read, write, edit, grep, glob, bash. Call tools to inspect and modify the workspace.',
|
|
26
34
|
'Cite file paths relative to the workspace root. Keep edits minimal and reversible.',
|
|
27
35
|
'When you are done, return a single final text answer that the operator can read on the CLI.',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
1
|
+
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
2
2
|
import { bashToolSync } from '../../tools/bash.js';
|
|
3
3
|
/**
|
|
4
4
|
* Tool-bridge: turns the abstract tool registry into:
|
|
@@ -147,6 +147,14 @@ export function buildExecutor(input) {
|
|
|
147
147
|
// outcome, not a failure, because plan mode is doing its job.
|
|
148
148
|
throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
149
149
|
}
|
|
150
|
+
// α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
|
|
151
|
+
// hooks fire so a cancelled brief never reaches user-defined
|
|
152
|
+
// hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
|
|
153
|
+
// by `runEngineLoop` as a terminal-cancel signal so the loop
|
|
154
|
+
// returns control to the caller rather than retrying the model.
|
|
155
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
156
|
+
throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
|
|
157
|
+
}
|
|
150
158
|
// Fire PreToolUse hooks. The match grammar takes the tool name and
|
|
151
159
|
// (when extractable) the target path. Each new tool dispatch starts a
|
|
152
160
|
// fresh dedup batch so a hook fires once per dispatch, not once per
|
|
@@ -194,6 +202,30 @@ export function buildExecutor(input) {
|
|
|
194
202
|
return result;
|
|
195
203
|
}
|
|
196
204
|
catch (error) {
|
|
205
|
+
// α6.9: re-shape OperatorAbortedError throws from the
|
|
206
|
+
// file-tools layer into the same `OPERATOR_ABORTED:` sentinel
|
|
207
|
+
// the upstream cancellation gate uses so `runEngineLoop` sees
|
|
208
|
+
// a consistent terminal-cancel signal regardless of whether
|
|
209
|
+
// the abort landed pre-dispatch or mid-tool (e.g. inside the
|
|
210
|
+
// grep file-loop).
|
|
211
|
+
if (error instanceof OperatorAbortedError) {
|
|
212
|
+
if (hooks && sessionId) {
|
|
213
|
+
const path = extractToolPath(name, argsRaw);
|
|
214
|
+
await hooks.fire({
|
|
215
|
+
sessionId,
|
|
216
|
+
event: 'PostToolUseFailure',
|
|
217
|
+
tool: name,
|
|
218
|
+
path,
|
|
219
|
+
payload: {
|
|
220
|
+
tool: name,
|
|
221
|
+
arguments: argsRaw,
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `OPERATOR_ABORTED: ${name}`,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
|
|
228
|
+
}
|
|
197
229
|
if (hooks && sessionId) {
|
|
198
230
|
const path = extractToolPath(name, argsRaw);
|
|
199
231
|
await hooks.fire({
|