@pugi/cli 0.1.0-beta.11 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/repl-render.js +109 -1
  48. package/dist/tui/repl.js +7 -1
  49. package/dist/tui/status-bar.js +94 -16
  50. package/dist/tui/update-banner.js +20 -2
  51. 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
- const result = await applyOne(edit, opts);
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
  }