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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -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
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Edit journal — β1b Pl8 (2026-05-26).
3
+ *
4
+ * Per-session append-only log of file-mutation INTENT before the
5
+ * dispatcher runs. Lives at `<root>/.pugi/sessions/<sessionId>/journal.jsonl`,
6
+ * one JSON entry per multi-file dispatch. On a non-zero exit / budget
7
+ * kill / OOM crash, the journal lets `git restore` / `fs.unlink` revert
8
+ * the workspace to its pre-dispatch state.
9
+ *
10
+ * Shape:
11
+ *
12
+ * ```ts
13
+ * {
14
+ * ts: number, // ms epoch — opens chronological replay
15
+ * taskId: string, // engine task id (`<kind>-<ts>`) for correlation
16
+ * files: [{
17
+ * path: string, // workspace-relative
18
+ * existed: boolean, // existed BEFORE the dispatch
19
+ * sha256_before?: string, // present iff existed === true
20
+ * }],
21
+ * }
22
+ * ```
23
+ *
24
+ * The journal records ONLY intent: it does not enumerate the post-state
25
+ * (a per-edit `applied` event already lives in `events.jsonl`). On
26
+ * rollback we need the pre-state to know:
27
+ * - whether to `git restore` (the file existed before and is git-
28
+ * tracked) OR `fs.unlink` (the file did NOT exist before — it was
29
+ * created by the failed dispatch).
30
+ * - whether to fall back to writing the cached `sha256_before` source
31
+ * when neither git nor fs can recover (untracked file that existed
32
+ * before — the dispatcher MUST have cached the pre-content; we
33
+ * surface a `partial_rollback` reason and let the operator decide).
34
+ *
35
+ * Why a separate journal vs reusing `events.jsonl`:
36
+ * - events.jsonl mixes status + tool_call + tool_result + outcome
37
+ * records from the engine loop. A crash mid-dispatch leaves the
38
+ * file in a state where reconstructing "files we MIGHT have
39
+ * touched" requires correlating multiple event types. The journal
40
+ * hoists the rollback-relevant subset into one line per dispatch
41
+ * so the recovery code is grep-able + audit-readable.
42
+ * - The journal is also forward-compatible with a future
43
+ * `pugi resume --rollback <taskId>` operator command — single
44
+ * source of truth for "what would I undo".
45
+ *
46
+ * Best-effort writes: a journal failure (disk full, .pugi unwritable)
47
+ * must NEVER block the actual edit. The dispatcher proceeds with
48
+ * `journalEntryId: null` and surfaces a warning in the session's
49
+ * status events. Rollback then degrades to apply-patch-style
50
+ * pre-existing snapshot (the dispatcher keeps an in-memory copy too).
51
+ *
52
+ * Brand voice: ASCII only, no banned words. Operator-facing tail
53
+ * (jq friendly).
54
+ */
55
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, } from 'node:fs';
56
+ import { createHash } from 'node:crypto';
57
+ import { join, resolve } from 'node:path';
58
+ /**
59
+ * Compute the on-disk journal path for a session. The directory is
60
+ * created lazily by `appendEntry` so a read against a missing session
61
+ * does not silently mkdir.
62
+ */
63
+ export function journalPath(workspaceRoot, sessionId) {
64
+ return resolve(workspaceRoot, '.pugi', 'sessions', sessionId, 'journal.jsonl');
65
+ }
66
+ /**
67
+ * sha256 of a file's bytes. Used by `snapshotForDispatch` to capture
68
+ * the pre-content fingerprint so rollback can detect "file was changed
69
+ * between intent + rollback" cases (rare but real — a concurrent
70
+ * external editor + the dispatcher hitting the same path).
71
+ *
72
+ * Returns null when the file cannot be hashed (missing / unreadable);
73
+ * caller treats absent fingerprint as `existed=false`.
74
+ */
75
+ export function sha256File(absPath) {
76
+ try {
77
+ const buf = readFileSync(absPath);
78
+ const h = createHash('sha256');
79
+ h.update(buf);
80
+ return h.digest('hex');
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Build a snapshot of the pre-dispatch state for every workspace-relative
88
+ * path the dispatcher is about to touch. Pure read; no writes.
89
+ *
90
+ * `paths` are workspace-relative. The journal records the SAME shape so
91
+ * the rollback path can re-resolve against the same `workspaceRoot`
92
+ * without ambiguity.
93
+ */
94
+ export function snapshotForDispatch(workspaceRoot, paths) {
95
+ const out = [];
96
+ for (const rel of paths) {
97
+ const abs = resolve(workspaceRoot, rel);
98
+ if (existsSync(abs)) {
99
+ // Hash only on regular files; sha256File returns null for
100
+ // directories or unreadable inodes and we degrade cleanly.
101
+ try {
102
+ const st = statSync(abs);
103
+ if (st.isFile()) {
104
+ const sha = sha256File(abs);
105
+ out.push({ path: rel, existed: true, ...(sha ? { sha256_before: sha } : {}) });
106
+ continue;
107
+ }
108
+ }
109
+ catch {
110
+ /* fall through — treat as non-existent for rollback */
111
+ }
112
+ }
113
+ out.push({ path: rel, existed: false });
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Append one journal entry. Best-effort: any I/O failure returns false
119
+ * and the dispatcher proceeds without journal-backed rollback. The
120
+ * in-memory `JournalFileEntry[]` snapshot still drives the immediate
121
+ * post-crash rollback inside the same process.
122
+ */
123
+ export function appendEntry(workspaceRoot, sessionId, entry) {
124
+ const path = journalPath(workspaceRoot, sessionId);
125
+ try {
126
+ mkdirSync(join(workspaceRoot, '.pugi', 'sessions', sessionId), {
127
+ recursive: true,
128
+ mode: 0o700,
129
+ });
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ try {
135
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 });
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ /**
143
+ * Read every journal entry for a session, oldest-first. Malformed
144
+ * lines are dropped silently — a single bad line should not nuke
145
+ * recovery. Returns `[]` when the file is missing.
146
+ *
147
+ * Synchronous (mirrors `recordToolCall` etc) because the operator-
148
+ * facing rollback path needs to be deterministic; a partially-
149
+ * streamed read would race the very crash recovery it is supporting.
150
+ */
151
+ export function readEntries(workspaceRoot, sessionId) {
152
+ const path = journalPath(workspaceRoot, sessionId);
153
+ if (!existsSync(path))
154
+ return [];
155
+ let raw;
156
+ try {
157
+ raw = readFileSync(path, 'utf8');
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ const out = [];
163
+ for (const line of raw.split('\n')) {
164
+ const trimmed = line.trim();
165
+ if (trimmed.length === 0)
166
+ continue;
167
+ try {
168
+ const parsed = JSON.parse(trimmed);
169
+ if (!parsed ||
170
+ typeof parsed !== 'object' ||
171
+ typeof parsed.taskId !== 'string' ||
172
+ typeof parsed.ts !== 'number' ||
173
+ !Array.isArray(parsed.files)) {
174
+ continue;
175
+ }
176
+ // β1b r1 defense-in-depth: validate every `files[]` entry too.
177
+ // A malformed file entry (missing `path`, wrong `existed` type)
178
+ // would silently feed garbage into the rollback path; better to
179
+ // drop the whole journal line than to attempt restore against a
180
+ // bogus snapshot.
181
+ const candidate = parsed;
182
+ const allFilesValid = candidate.files.every((f) => f !== null &&
183
+ typeof f === 'object' &&
184
+ typeof f.path === 'string' &&
185
+ f.path.length > 0 &&
186
+ typeof f.existed === 'boolean' &&
187
+ (f.sha256_before === undefined ||
188
+ typeof f.sha256_before === 'string'));
189
+ if (!allFilesValid)
190
+ continue;
191
+ out.push(candidate);
192
+ }
193
+ catch {
194
+ /* drop malformed line */
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ //# sourceMappingURL=journal.js.map