@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.14
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/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/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/engine/anvil-client.js +99 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -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 +859 -269
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 1. `--pr <number>` — uses `gh pr diff <num>` (gh CLI required).
|
|
8
8
|
* 2. `--commit <sha>` — diff of that commit vs its first parent.
|
|
9
|
+
* When `--base <ref>` is ALSO provided, the
|
|
10
|
+
* diff is the range `<base>..<commit>` instead
|
|
11
|
+
* (mirrors `git diff base..commit` — covers the
|
|
12
|
+
* full PR-style payload, not just the tip).
|
|
9
13
|
* 3. `--branch <name>` — diff of HEAD vs `origin/<name>` merge-base.
|
|
10
14
|
* 4. (default) — diff of HEAD vs `origin/main` merge-base
|
|
11
15
|
* covering BOTH committed-since-base AND
|
|
@@ -95,6 +99,16 @@ export function captureDiff(spec) {
|
|
|
95
99
|
return captureFromPr(cwd, spec.pr);
|
|
96
100
|
}
|
|
97
101
|
if (typeof spec.commit === 'string' && spec.commit.length > 0) {
|
|
102
|
+
// When `--base` is supplied alongside `--commit`, callers want the
|
|
103
|
+
// full PR-style range diff (`base..commit`), not just the tip
|
|
104
|
+
// commit's parent diff. This matches the convention used by
|
|
105
|
+
// `git diff <base>..<commit>` everywhere else in the toolchain and
|
|
106
|
+
// is the verified-correct mode for reviewing a PR head ref. Without
|
|
107
|
+
// this branch, `--base` was silently ignored when `--commit` was
|
|
108
|
+
// present — see feedback_pugi_review_use_range_diff_not_worktree.
|
|
109
|
+
if (typeof spec.baseRef === 'string' && spec.baseRef.length > 0) {
|
|
110
|
+
return captureFromRange(cwd, spec.baseRef, spec.commit);
|
|
111
|
+
}
|
|
98
112
|
return captureFromCommit(cwd, spec.commit);
|
|
99
113
|
}
|
|
100
114
|
if (typeof spec.branch === 'string' && spec.branch.length > 0) {
|
|
@@ -192,6 +206,65 @@ function captureFromCommit(cwd, commit) {
|
|
|
192
206
|
},
|
|
193
207
|
};
|
|
194
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Range capture for `--commit <X> --base <Y>` — diff equivalent to
|
|
211
|
+
* `git diff <base>..<commit>`. Used when the operator names BOTH endpoints
|
|
212
|
+
* (typical PR review against a remote head SHA).
|
|
213
|
+
*
|
|
214
|
+
* Critical: this MUST be a pure read-only range diff against named refs.
|
|
215
|
+
* The previous behavior fell through to `captureFromCommit` which only
|
|
216
|
+
* showed the tip commit (`commit~1..commit`) — fine for single-commit
|
|
217
|
+
* review, wrong for multi-commit PRs. Worse, a stale fallback path was
|
|
218
|
+
* sending the working tree diff (`git diff` with no args), which caused
|
|
219
|
+
* every review on 2026-05-27 to surface identical noise from uncommitted
|
|
220
|
+
* `.gitignore` edits instead of the actual PR contents.
|
|
221
|
+
*
|
|
222
|
+
* Working tree integrity: only `git diff <ref>..<ref>` and metadata
|
|
223
|
+
* `log` / `rev-parse` / `name-rev` are used — none of these touch the
|
|
224
|
+
* index, working tree, or HEAD.
|
|
225
|
+
*/
|
|
226
|
+
function captureFromRange(cwd, baseRef, commit) {
|
|
227
|
+
// Resolve both endpoints up front so an unknown ref errors with a
|
|
228
|
+
// clear message before the diff invocation. `rev-parse` is read-only.
|
|
229
|
+
const fullCommit = safeExec(cwd, 'git', ['rev-parse', commit]).trim();
|
|
230
|
+
if (!fullCommit)
|
|
231
|
+
throw new Error(`Unknown commit ref: ${commit}`);
|
|
232
|
+
// Resolve the base: accept already-qualified refs (`origin/main`,
|
|
233
|
+
// `refs/heads/foo`) and bare branch names. If the bare name isn't
|
|
234
|
+
// locally resolvable, retry against `origin/<name>` — the common
|
|
235
|
+
// CI shape where local main is absent but the remote tracking ref is.
|
|
236
|
+
let resolvedBase = safeExecOptional(cwd, 'git', ['rev-parse', baseRef]).trim();
|
|
237
|
+
let effectiveBase = baseRef;
|
|
238
|
+
if (!resolvedBase && !baseRef.includes('/')) {
|
|
239
|
+
const remoteBase = `origin/${baseRef}`;
|
|
240
|
+
resolvedBase = safeExecOptional(cwd, 'git', ['rev-parse', remoteBase]).trim();
|
|
241
|
+
if (resolvedBase)
|
|
242
|
+
effectiveBase = remoteBase;
|
|
243
|
+
}
|
|
244
|
+
if (!resolvedBase)
|
|
245
|
+
throw new Error(`Unknown base ref: ${baseRef}`);
|
|
246
|
+
const diff = safeExec(cwd, 'git', [
|
|
247
|
+
'diff',
|
|
248
|
+
`${resolvedBase}..${fullCommit}`,
|
|
249
|
+
'--',
|
|
250
|
+
'.',
|
|
251
|
+
...PROTECTED_PATHSPEC_EXCLUDES,
|
|
252
|
+
]);
|
|
253
|
+
const cappedDiff = capDiff(diff);
|
|
254
|
+
const subject = safeExec(cwd, 'git', ['log', '-1', '--pretty=%s', fullCommit]).trim();
|
|
255
|
+
const branch = safeExec(cwd, 'git', ['name-rev', '--name-only', fullCommit]).trim() || 'detached';
|
|
256
|
+
const stats = computeStats(cappedDiff);
|
|
257
|
+
return {
|
|
258
|
+
diff: cappedDiff,
|
|
259
|
+
context: {
|
|
260
|
+
branch,
|
|
261
|
+
commit: shortSha(fullCommit),
|
|
262
|
+
title: subject || `commit ${shortSha(fullCommit)}`,
|
|
263
|
+
ref: `range:${effectiveBase}..${shortSha(fullCommit)}`,
|
|
264
|
+
stats,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
195
268
|
function captureFromBranch(cwd, branch, baseRef) {
|
|
196
269
|
const remoteRef = branch.includes('/') ? branch : `origin/${branch}`;
|
|
197
270
|
const mergeBase = safeExec(cwd, 'git', ['merge-base', baseRef, remoteRef]).trim();
|
|
@@ -18,4 +18,11 @@ export { BASELINE_IGNORE_PATTERNS, SECRET_IGNORE_PATTERNS, globalPugiIgnorePath,
|
|
|
18
18
|
export { COLLAPSE_DIR_ENTRIES, MAX_README_LINES, MAX_SKELETON_BYTES, MAX_TREE_DEPTH, MAX_WALK_NODES, TOP_LANGUAGES, buildRepoSkeleton, detectPackageManager, languageForExtension, readGitBranch, readPackageJson, readReadme, renderSkeleton, topLanguages, } from './repo-skeleton.js';
|
|
19
19
|
export { DEFAULT_WORKING_SET_CAPACITY, WorkingSet, } from './working-set.js';
|
|
20
20
|
export { MAX_WATCHED_PATHS, PugiWatcher, THROTTLE_WINDOW_MS, } from './watcher.js';
|
|
21
|
+
/**
|
|
22
|
+
* β5a R4+P5 — per-directory PUGI.md / AGENTS.md / CLAUDE.md / GEMINI.md
|
|
23
|
+
* traverse-up. Loads agent-context markdown at every directory between
|
|
24
|
+
* `cwd` and `workspaceRoot` (workspace root file is owned by
|
|
25
|
+
* `loadMarkdownContext` in `markdown-loader.ts` — no double-load).
|
|
26
|
+
*/
|
|
27
|
+
export { MAX_TRAVERSE_BYTES, MAX_TRAVERSE_PER_FILE_BYTES, MAX_TRAVERSE_DEPTH, TRAVERSE_SOURCES, loadTraversedMarkdown, } from './markdown-traverse.js';
|
|
21
28
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-directory PUGI.md / AGENTS.md / CLAUDE.md / GEMINI.md traverse-up
|
|
3
|
+
* loader — β5a R4+P5.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code, Codex CLI, and Gemini CLI all support a "walk up from
|
|
6
|
+
* cwd to the workspace root, pick up agent-context markdown at every
|
|
7
|
+
* level" pattern. Without this, a `pugi explain` invoked from
|
|
8
|
+
* `apps/admin-api/` cannot see project-local conventions encoded in
|
|
9
|
+
* `apps/admin-api/PUGI.md` (or the cross-CLI shim files) — the
|
|
10
|
+
* existing `loadMarkdownContext` only reads files at `workspaceRoot`.
|
|
11
|
+
*
|
|
12
|
+
* The β5a quality gate (≥80% win-rate vs Claude Code per CEO 2026-05-26)
|
|
13
|
+
* surfaces this gap repeatedly: monorepo-local conventions (NestJS
|
|
14
|
+
* controller style, Prisma migration name format, cabinet brand voice
|
|
15
|
+
* gates) live in per-app context files, and Pugi was blind to them
|
|
16
|
+
* pre-β5a.
|
|
17
|
+
*
|
|
18
|
+
* Contract:
|
|
19
|
+
*
|
|
20
|
+
* - Walk from `cwd` upward until we reach `workspaceRoot` OR cross
|
|
21
|
+
* the filesystem boundary. The workspace root file itself is
|
|
22
|
+
* loaded by the legacy `loadMarkdownContext` so we do NOT include
|
|
23
|
+
* it here (no double-load, no double-budget-charge).
|
|
24
|
+
*
|
|
25
|
+
* - At each intermediate directory, look for the four canonical
|
|
26
|
+
* filenames: `PUGI.md` (native), `AGENTS.md` (cross-CLI shim),
|
|
27
|
+
* `CLAUDE.md` (Claude Code compat), `GEMINI.md` (Gemini CLI compat).
|
|
28
|
+
*
|
|
29
|
+
* - HTML comments stripped, identical to the legacy loader.
|
|
30
|
+
*
|
|
31
|
+
* - `@import` expansion is intentionally NOT performed here — the
|
|
32
|
+
* per-dir surface is "drop a small file with the local
|
|
33
|
+
* conventions"; deep @import chains belong at workspace root.
|
|
34
|
+
* Keeping this surface flat means the per-dir budget cannot be
|
|
35
|
+
* blown out by a runaway @import in an unrelated subtree.
|
|
36
|
+
*
|
|
37
|
+
* - Aggregate budget: `MAX_TRAVERSE_BYTES` across ALL files found
|
|
38
|
+
* in the walk. When exhausted, remaining files are skipped with
|
|
39
|
+
* a `budget_exhausted` warning. Default 32 KB — half the
|
|
40
|
+
* workspace-root budget, because per-dir files are meant to be
|
|
41
|
+
* terse delta conventions, not full project briefs.
|
|
42
|
+
*
|
|
43
|
+
* - The walk is bounded: `MAX_TRAVERSE_DEPTH` levels above
|
|
44
|
+
* workspaceRoot are NEVER traversed (defense against being
|
|
45
|
+
* invoked from a malicious cwd outside the workspace; in
|
|
46
|
+
* practice cwd is always inside workspaceRoot but the symlink
|
|
47
|
+
* case demands belt + suspenders).
|
|
48
|
+
*
|
|
49
|
+
* - Order returned: shallowest-first (workspace root would be
|
|
50
|
+
* first if included, then each level closer to cwd). Closest-
|
|
51
|
+
* to-cwd files are the most specific and the context builder
|
|
52
|
+
* emits them LAST so the model treats them as the highest-
|
|
53
|
+
* priority conventions.
|
|
54
|
+
*
|
|
55
|
+
* This module is pure: no logging, no network, no fs writes. Filesystem
|
|
56
|
+
* reads only.
|
|
57
|
+
*/
|
|
58
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
59
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
60
|
+
import { stripHtmlComments } from './markdown-loader.js';
|
|
61
|
+
/**
|
|
62
|
+
* Per-traverse total byte cap. Half the workspace-root budget — these
|
|
63
|
+
* files are meant to be terse "in this subdir, do X". 32 KB still fits
|
|
64
|
+
* ~8000 words of guidance.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_TRAVERSE_BYTES = 32 * 1024;
|
|
67
|
+
/**
|
|
68
|
+
* Per-file byte cap. A single per-dir file should never dominate; the
|
|
69
|
+
* 8 KB cap keeps any one level honest. Files larger than this are
|
|
70
|
+
* loaded up to the cap and flagged truncated.
|
|
71
|
+
*/
|
|
72
|
+
export const MAX_TRAVERSE_PER_FILE_BYTES = 8 * 1024;
|
|
73
|
+
/**
|
|
74
|
+
* Maximum number of parent directories above workspaceRoot we will
|
|
75
|
+
* traverse. Zero in normal operation — cwd is always inside the
|
|
76
|
+
* workspace; the cap exists so a misconfigured invocation never
|
|
77
|
+
* walks the whole filesystem looking for AGENTS.md.
|
|
78
|
+
*/
|
|
79
|
+
export const MAX_TRAVERSE_DEPTH = 0;
|
|
80
|
+
/**
|
|
81
|
+
* Filenames we look for at every level of the walk. The order here
|
|
82
|
+
* also defines the per-directory load order: PUGI.md first (highest
|
|
83
|
+
* trust), then cross-CLI compat shims. When the same directory has
|
|
84
|
+
* multiple files (e.g. both PUGI.md AND CLAUDE.md), all are loaded
|
|
85
|
+
* — operators sometimes keep both during a tool migration.
|
|
86
|
+
*/
|
|
87
|
+
export const TRAVERSE_SOURCES = ['PUGI.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
88
|
+
/**
|
|
89
|
+
* Walk from `opts.cwd` upward toward `opts.workspaceRoot`, loading
|
|
90
|
+
* every PUGI.md / AGENTS.md / CLAUDE.md / GEMINI.md we encounter at
|
|
91
|
+
* intermediate levels. The workspace root itself is NOT loaded here
|
|
92
|
+
* — `loadMarkdownContext(workspaceRoot)` owns that file.
|
|
93
|
+
*
|
|
94
|
+
* Returned `loaded` is sorted shallowest-first (closest-to-root) so
|
|
95
|
+
* the caller can emit per-dir context in increasing specificity.
|
|
96
|
+
*
|
|
97
|
+
* Safety properties (proven by spec):
|
|
98
|
+
*
|
|
99
|
+
* - Never reads outside the workspace tree (symlinks resolved via
|
|
100
|
+
* realpathSync; off-tree symlinks rejected as
|
|
101
|
+
* `import_escapes_workspace`).
|
|
102
|
+
* - Never reads more than `MAX_TRAVERSE_BYTES` total or more than
|
|
103
|
+
* `MAX_TRAVERSE_PER_FILE_BYTES` per file.
|
|
104
|
+
* - Never walks above the workspace root.
|
|
105
|
+
* - Never visits the workspace root itself (single source of truth
|
|
106
|
+
* for workspace-level docs stays with `loadMarkdownContext`).
|
|
107
|
+
*/
|
|
108
|
+
export async function loadTraversedMarkdown(opts) {
|
|
109
|
+
const warnings = [];
|
|
110
|
+
const loaded = [];
|
|
111
|
+
let budgetRemaining = MAX_TRAVERSE_BYTES;
|
|
112
|
+
let absRoot;
|
|
113
|
+
let absCwd;
|
|
114
|
+
try {
|
|
115
|
+
absRoot = realpathSync(resolve(opts.workspaceRoot));
|
|
116
|
+
absCwd = realpathSync(resolve(opts.cwd));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
warnings.push({
|
|
120
|
+
kind: 'read_error',
|
|
121
|
+
message: `realpath failed for traverse anchor: ${error.message}`,
|
|
122
|
+
});
|
|
123
|
+
return { loaded, warnings, totalBytes: 0 };
|
|
124
|
+
}
|
|
125
|
+
// Containment guard: if cwd is not inside workspaceRoot, refuse
|
|
126
|
+
// to walk. Returning a clean empty result keeps the engine happy
|
|
127
|
+
// and surfaces nothing about the off-tree cwd to the model.
|
|
128
|
+
const relCwd = relative(absRoot, absCwd);
|
|
129
|
+
if (relCwd.startsWith('..') || isAbsolute(relCwd)) {
|
|
130
|
+
warnings.push({
|
|
131
|
+
kind: 'import_escapes_workspace',
|
|
132
|
+
message: `cwd is outside workspaceRoot; per-dir traverse skipped (cwd=${absCwd}, root=${absRoot})`,
|
|
133
|
+
path: absCwd,
|
|
134
|
+
});
|
|
135
|
+
return { loaded, warnings, totalBytes: 0 };
|
|
136
|
+
}
|
|
137
|
+
// Collect the walk: every directory from cwd UP TO (but not
|
|
138
|
+
// including) workspaceRoot.
|
|
139
|
+
const dirsToVisit = [];
|
|
140
|
+
let current = absCwd;
|
|
141
|
+
while (current !== absRoot) {
|
|
142
|
+
dirsToVisit.push(current);
|
|
143
|
+
const parent = dirname(current);
|
|
144
|
+
if (parent === current)
|
|
145
|
+
break; // hit filesystem root before workspaceRoot — defensive
|
|
146
|
+
current = parent;
|
|
147
|
+
if (dirsToVisit.length > 64)
|
|
148
|
+
break; // pathological depth, refuse
|
|
149
|
+
}
|
|
150
|
+
// Walk shallowest-first so we charge the budget in the order
|
|
151
|
+
// that matches what we return.
|
|
152
|
+
dirsToVisit.reverse();
|
|
153
|
+
for (const dir of dirsToVisit) {
|
|
154
|
+
if (budgetRemaining <= 0) {
|
|
155
|
+
warnings.push({
|
|
156
|
+
kind: 'budget_exhausted',
|
|
157
|
+
message: `per-dir traverse budget exhausted before reaching ${dir}`,
|
|
158
|
+
path: dir,
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
for (const source of TRAVERSE_SOURCES) {
|
|
163
|
+
const candidate = resolve(dir, source);
|
|
164
|
+
if (!existsSync(candidate))
|
|
165
|
+
continue;
|
|
166
|
+
// Symlink guard: same realpath check as the workspace-root
|
|
167
|
+
// loader. A symlink inside the workspace that points outside
|
|
168
|
+
// the workspace must NOT be inlined.
|
|
169
|
+
let realCandidate;
|
|
170
|
+
try {
|
|
171
|
+
realCandidate = realpathSync(candidate);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
warnings.push({
|
|
175
|
+
kind: 'read_error',
|
|
176
|
+
message: `realpath failed for ${candidate}: ${error.message}`,
|
|
177
|
+
path: candidate,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const realRel = relative(absRoot, realCandidate);
|
|
182
|
+
if (realRel.startsWith('..') || isAbsolute(realRel)) {
|
|
183
|
+
warnings.push({
|
|
184
|
+
kind: 'import_escapes_workspace',
|
|
185
|
+
message: `traverse file escapes workspace via symlink: ${candidate} -> ${realCandidate}`,
|
|
186
|
+
path: candidate,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
let raw;
|
|
191
|
+
let rawBytes;
|
|
192
|
+
try {
|
|
193
|
+
rawBytes = statSync(candidate).size;
|
|
194
|
+
raw = readFileSync(candidate, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
warnings.push({
|
|
198
|
+
kind: 'read_error',
|
|
199
|
+
message: `could not read ${candidate}: ${error.message}`,
|
|
200
|
+
path: candidate,
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const stripped = stripHtmlComments(raw);
|
|
205
|
+
// Per-file cap first, then global budget.
|
|
206
|
+
const perFileCap = Math.min(MAX_TRAVERSE_PER_FILE_BYTES, budgetRemaining);
|
|
207
|
+
let content = stripped;
|
|
208
|
+
let truncated = false;
|
|
209
|
+
let contentBytes = Buffer.byteLength(content, 'utf8');
|
|
210
|
+
if (contentBytes > perFileCap) {
|
|
211
|
+
// Codepoint-safe slice: convert byte cap to char cap by
|
|
212
|
+
// taking min(byte cap, char-length-up-to-cap). We accept
|
|
213
|
+
// mild over-trim for safety.
|
|
214
|
+
content = content.slice(0, perFileCap);
|
|
215
|
+
truncated = true;
|
|
216
|
+
contentBytes = Buffer.byteLength(content, 'utf8');
|
|
217
|
+
}
|
|
218
|
+
const distance = distanceSegments(absCwd, dir);
|
|
219
|
+
loaded.push({
|
|
220
|
+
source,
|
|
221
|
+
resolvedPath: candidate,
|
|
222
|
+
dir,
|
|
223
|
+
distanceFromCwd: distance,
|
|
224
|
+
rawBytes,
|
|
225
|
+
loadedBytes: contentBytes,
|
|
226
|
+
truncated,
|
|
227
|
+
content,
|
|
228
|
+
});
|
|
229
|
+
budgetRemaining -= contentBytes;
|
|
230
|
+
if (budgetRemaining <= 0)
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
loaded,
|
|
236
|
+
warnings,
|
|
237
|
+
totalBytes: MAX_TRAVERSE_BYTES - budgetRemaining,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* How many path segments separate `from` and `to`. Both must be
|
|
242
|
+
* absolute. `to` is assumed to be an ancestor of (or equal to)
|
|
243
|
+
* `from`; if not, returns -1 so callers can ignore the file.
|
|
244
|
+
*/
|
|
245
|
+
function distanceSegments(from, to) {
|
|
246
|
+
if (from === to)
|
|
247
|
+
return 0;
|
|
248
|
+
const rel = relative(to, from);
|
|
249
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
250
|
+
return -1;
|
|
251
|
+
if (rel.length === 0)
|
|
252
|
+
return 0;
|
|
253
|
+
return rel.split(sep).length;
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=markdown-traverse.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Diff dispatch — α6.6 escalation Phase 1
|
|
2
|
+
* Diff dispatch — α6.6 escalation Phase 1, β1b Pl8 transactional layer
|
|
3
|
+
* (2026-05-26).
|
|
3
4
|
*
|
|
4
5
|
* Reads a raw model response containing one or more SEARCH/REPLACE
|
|
5
6
|
* envelopes, normalises them through `marker-parser`, and routes each
|
|
@@ -21,16 +22,36 @@
|
|
|
21
22
|
* the writeFile. A crash between the two leaves a recoverable trail —
|
|
22
23
|
* the operator (or `pugi resume`) sees the intent and can re-attempt.
|
|
23
24
|
*
|
|
25
|
+
* β1b Pl8 — transactional rollback: when the caller supplies
|
|
26
|
+
* `transactional: { sessionId, taskId, workspaceRoot }`, the dispatcher
|
|
27
|
+
* wraps the multi-file edits in a session journal (see
|
|
28
|
+
* `core/edits/journal.ts`). On non-zero exit / budget kill / partial
|
|
29
|
+
* fail it runs `rollbackDispatch()`:
|
|
30
|
+
*
|
|
31
|
+
* - tracked files that EXISTED before → `git restore -- <file>`
|
|
32
|
+
* - newly created files (existed=false) → `fs.unlink`
|
|
33
|
+
* - untracked files that EXISTED before → restore from in-memory
|
|
34
|
+
* pre-content snapshot (sha256_before validated)
|
|
35
|
+
*
|
|
36
|
+
* Pattern mirrors `tools/apply-patch.ts::rollbackFiles` (PR #413). The
|
|
37
|
+
* journal is the durability layer that lets a process crash recover
|
|
38
|
+
* across PIDs; the in-memory snapshot covers the single-process case
|
|
39
|
+
* where the journal write itself failed.
|
|
40
|
+
*
|
|
24
41
|
* The dispatcher is intentionally side-effect-light: no logging, no
|
|
25
42
|
* stdout writes, no exit-code mutation. The CLI integration layer in
|
|
26
43
|
* `cli.ts` owns operator-facing rendering; the dispatcher returns
|
|
27
44
|
* structured data and lets the caller decide UX.
|
|
28
45
|
*/
|
|
46
|
+
import { spawnSync } from 'node:child_process';
|
|
47
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
48
|
+
import { resolve, sep } from 'node:path';
|
|
29
49
|
import { LayerDDeferredError, applyLayerD } from './layer-d-ast.js';
|
|
30
50
|
import { applyLayerA } from './layer-a-apply.js';
|
|
31
51
|
import { applyLayerB } from './layer-b-apply.js';
|
|
32
52
|
import { applyLayerC } from './layer-c-apply.js';
|
|
33
53
|
import { MarkerParseError, parseMarkers, } from './marker-parser.js';
|
|
54
|
+
import { appendEntry, snapshotForDispatch, } from './journal.js';
|
|
34
55
|
/**
|
|
35
56
|
* Parse `raw` into edits and apply each in order. Aggregate results,
|
|
36
57
|
* preserving order. Never throws — parse failures surface as a single
|
|
@@ -65,13 +86,208 @@ export async function dispatchEdit(raw, opts) {
|
|
|
65
86
|
// render "no edits proposed".
|
|
66
87
|
return [];
|
|
67
88
|
}
|
|
89
|
+
// β1b Pl8 — transactional path. When enabled, snapshot every target
|
|
90
|
+
// file BEFORE the first applicator runs so we can roll back if a
|
|
91
|
+
// later edit fails. Snapshot also drives the journal entry so a
|
|
92
|
+
// post-crash recovery can replay rollback in a fresh process.
|
|
93
|
+
//
|
|
94
|
+
// The journal write itself is best-effort: a disk-full / EACCES
|
|
95
|
+
// failure must NOT block the dispatch. The in-memory snapshot
|
|
96
|
+
// still carries every pre-existing file's content, so single-
|
|
97
|
+
// process rollback degrades cleanly. The operator sees the
|
|
98
|
+
// journal-failed warning via the session events mirror (caller's
|
|
99
|
+
// responsibility to emit).
|
|
100
|
+
let snapshot = null;
|
|
101
|
+
let preContent = null;
|
|
102
|
+
if (opts.transactional && !opts.dryRun) {
|
|
103
|
+
const targets = Array.from(new Set(parsed.map((e) => editFile(e))));
|
|
104
|
+
snapshot = snapshotForDispatch(opts.transactional.workspaceRoot, targets);
|
|
105
|
+
preContent = new Map();
|
|
106
|
+
for (const e of snapshot) {
|
|
107
|
+
if (!e.existed)
|
|
108
|
+
continue;
|
|
109
|
+
const abs = resolve(opts.transactional.workspaceRoot, e.path);
|
|
110
|
+
try {
|
|
111
|
+
preContent.set(e.path, readFileSync(abs));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* file vanished between snapshot + read — treat as not-snapshotted */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
appendEntry(opts.transactional.workspaceRoot, opts.transactional.sessionId, {
|
|
118
|
+
ts: Date.now(),
|
|
119
|
+
taskId: opts.transactional.taskId,
|
|
120
|
+
files: snapshot,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
68
123
|
const out = [];
|
|
124
|
+
let crashError = null;
|
|
69
125
|
for (const edit of parsed) {
|
|
70
126
|
const intent = makeIntent(edit);
|
|
71
127
|
opts.onIntent?.(intent);
|
|
72
|
-
|
|
128
|
+
let result;
|
|
129
|
+
try {
|
|
130
|
+
result = await applyOne(edit, opts);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// applyOne does not throw today (errors are returned as
|
|
134
|
+
// `ok: false`), but a future applicator that does throw —
|
|
135
|
+
// or a budget-kill that arrives mid-write — needs deterministic
|
|
136
|
+
// rollback. Catch + record + break so the rollback below runs.
|
|
137
|
+
crashError = error;
|
|
138
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
139
|
+
result = {
|
|
140
|
+
layer: 'layer-a',
|
|
141
|
+
file: editFile(edit),
|
|
142
|
+
ok: false,
|
|
143
|
+
bytesWritten: 0,
|
|
144
|
+
reason: 'apply_error',
|
|
145
|
+
detail: `dispatch threw: ${msg}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
73
148
|
out.push(result);
|
|
74
149
|
opts.onResult?.(result);
|
|
150
|
+
if (!result.ok && opts.transactional && snapshot && preContent) {
|
|
151
|
+
// Rollback every snapshotted file then break out — partial
|
|
152
|
+
// success is unacceptable in transactional mode.
|
|
153
|
+
const rollback = rollbackDispatch(opts.transactional.workspaceRoot, snapshot, preContent);
|
|
154
|
+
if (!rollback.ok) {
|
|
155
|
+
// Surface the rollback failure as an additional synthetic
|
|
156
|
+
// result so the caller can render the operator-facing
|
|
157
|
+
// message without losing the original failure context.
|
|
158
|
+
const failure = {
|
|
159
|
+
layer: 'layer-a',
|
|
160
|
+
file: '',
|
|
161
|
+
ok: false,
|
|
162
|
+
bytesWritten: 0,
|
|
163
|
+
reason: 'rollback_failed',
|
|
164
|
+
detail: rollback.detail,
|
|
165
|
+
};
|
|
166
|
+
out.push(failure);
|
|
167
|
+
opts.onResult?.(failure);
|
|
168
|
+
}
|
|
169
|
+
if (crashError) {
|
|
170
|
+
// Re-throw post-rollback so the caller learns the dispatch
|
|
171
|
+
// crashed (vs returned ok: false). Rollback already completed
|
|
172
|
+
// so the workspace is consistent.
|
|
173
|
+
throw crashError;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Workspace-relative path for a parsed edit, regardless of layer.
|
|
182
|
+
* Hoisted because the snapshot + intent both need the same answer.
|
|
183
|
+
*/
|
|
184
|
+
function editFile(edit) {
|
|
185
|
+
switch (edit.kind) {
|
|
186
|
+
case 'layer-a':
|
|
187
|
+
case 'layer-b':
|
|
188
|
+
case 'layer-c':
|
|
189
|
+
case 'layer-d':
|
|
190
|
+
return edit.edit.file;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Roll the workspace back to the pre-dispatch state captured in
|
|
195
|
+
* `snapshot`. Mirrors `tools/apply-patch.ts::rollbackFiles`:
|
|
196
|
+
*
|
|
197
|
+
* - existed-before + git-tracked → `git restore -- <file>` (cheap
|
|
198
|
+
* + atomic against the index).
|
|
199
|
+
* - existed-before + untracked → restore from the in-memory
|
|
200
|
+
* preContent buffer (sha256_before validates the snapshot still
|
|
201
|
+
* matches what we hold; if not we punt with `partial_rollback`).
|
|
202
|
+
* - newly created → fs.unlink (force).
|
|
203
|
+
*
|
|
204
|
+
* Best-effort: every per-file failure is collected into the detail
|
|
205
|
+
* string so the operator can manually fix the residual state. The
|
|
206
|
+
* dispatcher does not abort on the first error so an unrelated
|
|
207
|
+
* permission glitch on one file doesn't strand the others.
|
|
208
|
+
*
|
|
209
|
+
* Exported for the spec suite + a future operator command
|
|
210
|
+
* (`pugi resume --rollback <taskId>` ships in β2).
|
|
211
|
+
*/
|
|
212
|
+
export function rollbackDispatch(workspaceRoot, snapshot, preContent) {
|
|
213
|
+
if (snapshot.length === 0)
|
|
214
|
+
return { ok: true };
|
|
215
|
+
// Filter to workspace-internal paths only. A snapshot entry that
|
|
216
|
+
// escaped the workspace would already have aborted upstream; the
|
|
217
|
+
// filter is belt + braces against a future bug.
|
|
218
|
+
const safe = snapshot.filter((e) => {
|
|
219
|
+
const abs = resolve(workspaceRoot, e.path);
|
|
220
|
+
return abs === workspaceRoot || abs.startsWith(workspaceRoot + sep);
|
|
221
|
+
});
|
|
222
|
+
const failures = [];
|
|
223
|
+
const tracked = listTrackedFiles(workspaceRoot, safe.map((e) => e.path));
|
|
224
|
+
for (const entry of safe) {
|
|
225
|
+
const abs = resolve(workspaceRoot, entry.path);
|
|
226
|
+
if (!entry.existed) {
|
|
227
|
+
try {
|
|
228
|
+
// β1b r1: `recursive: true` so rollback handles the case where
|
|
229
|
+
// the dispatcher created an intermediate directory (e.g. a new
|
|
230
|
+
// `src/feature/` tree). Without it the unlink fails on a dir
|
|
231
|
+
// and the journal-replay leaves an orphan workspace path.
|
|
232
|
+
rmSync(abs, { force: true, recursive: true });
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
failures.push(`${entry.path}: unlink failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (tracked.has(entry.path)) {
|
|
240
|
+
const result = spawnSync('git', ['restore', '--', entry.path], {
|
|
241
|
+
cwd: workspaceRoot,
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
});
|
|
244
|
+
if (result.status !== 0) {
|
|
245
|
+
failures.push(`${entry.path}: git restore failed: ${(result.stderr ?? '').trim() || 'non-zero exit'}`);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Untracked-but-existed: write back from memory.
|
|
250
|
+
const buf = preContent.get(entry.path);
|
|
251
|
+
if (!buf) {
|
|
252
|
+
failures.push(`${entry.path}: pre-content snapshot missing (partial rollback)`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
writeFileSync(abs, buf);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
failures.push(`${entry.path}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (failures.length === 0)
|
|
263
|
+
return { ok: true };
|
|
264
|
+
return { ok: false, detail: failures.join('; ') };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Ask git which of `paths` are tracked. A single `git ls-files
|
|
268
|
+
* --error-unmatch` call would fail-fast on the first untracked path,
|
|
269
|
+
* so we use `git ls-files -- <paths...>` which lists only tracked
|
|
270
|
+
* matches. Returns a Set of workspace-relative paths.
|
|
271
|
+
*
|
|
272
|
+
* Pure-stdlib fallback when git is unavailable: returns an empty
|
|
273
|
+
* Set — every "existed" entry then routes to the untracked-restore
|
|
274
|
+
* path via the in-memory preContent map. Slower per file but still
|
|
275
|
+
* correct.
|
|
276
|
+
*/
|
|
277
|
+
function listTrackedFiles(cwd, paths) {
|
|
278
|
+
if (paths.length === 0)
|
|
279
|
+
return new Set();
|
|
280
|
+
const result = spawnSync('git', ['ls-files', '--', ...paths], {
|
|
281
|
+
cwd,
|
|
282
|
+
encoding: 'utf8',
|
|
283
|
+
});
|
|
284
|
+
if (result.status !== 0)
|
|
285
|
+
return new Set();
|
|
286
|
+
const out = new Set();
|
|
287
|
+
for (const line of (result.stdout ?? '').split('\n')) {
|
|
288
|
+
const trimmed = line.trim();
|
|
289
|
+
if (trimmed.length > 0)
|
|
290
|
+
out.add(trimmed);
|
|
75
291
|
}
|
|
76
292
|
return out;
|
|
77
293
|
}
|