@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36

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 (45) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/mcp/orchestrator-tools.js +595 -0
  10. package/dist/core/onboarding/ensure-initialized.js +133 -0
  11. package/dist/core/repl/session.js +370 -9
  12. package/dist/core/repl/slash-commands.js +68 -5
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +453 -11
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/mcp.js +66 -11
  21. package/dist/runtime/commands/permissions.js +23 -0
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Redo blob store — Wave 6 (2026-05-27).
3
+ *
4
+ * `/undo` walks the most recent successful mutating tool result and
5
+ * reverts each file on disk. `/redo` reapplies that change. To reapply,
6
+ * we need the post-mutation content — but the event log records only
7
+ * sha256 hashes, not content (see core/file-cache.ts comment).
8
+ *
9
+ * Solution: a content-addressable sidecar store at
10
+ * `<workspaceRoot>/.pugi/undo-blobs/<sha256>`. The undo runner captures
11
+ * each file's CURRENT content (which equals the original AFTER state)
12
+ * into the store BEFORE reverting on disk. The redo runner reads the
13
+ * blob keyed by `beforeHash` of the inverse mutation (which records
14
+ * the pre-revert hash = the original AFTER hash) and writes it back.
15
+ *
16
+ * The store is deliberately untracked (`.pugi/` already lives in
17
+ * .gitignore) and self-cleaning — after a successful redo we delete
18
+ * the blob so a second redo without a fresh undo is a noop. Stale
19
+ * blobs left behind by an interrupted undo are reaped after 7 days
20
+ * by the existing `.pugi/cleanup` cadence (best-effort; not a
21
+ * correctness requirement).
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
24
+ import { resolve } from 'node:path';
25
+ import { hashContent } from '../../core/file-cache.js';
26
+ /** Sha256-keyed blob path under `<root>/.pugi/undo-blobs/`. */
27
+ export function blobPathFor(root, sha) {
28
+ return resolve(root, '.pugi/undo-blobs', sha);
29
+ }
30
+ /** Ensure the blob directory exists. Idempotent. */
31
+ function ensureBlobDir(root) {
32
+ const dir = resolve(root, '.pugi/undo-blobs');
33
+ if (!existsSync(dir)) {
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ }
36
+ return dir;
37
+ }
38
+ /**
39
+ * Write `content` into the blob store. Returns the resolved blob path.
40
+ * Atomic tmp+rename so partial writes never present a half-blob к
41
+ * future redo invocations.
42
+ *
43
+ * Idempotent: if a blob with the same content already exists, the
44
+ * second write is a noop (the rename target already matches).
45
+ */
46
+ export function writeBlob(root, content) {
47
+ ensureBlobDir(root);
48
+ const sha = hashContent(content);
49
+ const dst = blobPathFor(root, sha);
50
+ if (existsSync(dst)) {
51
+ // Same content already cached; nothing to do.
52
+ return { sha, path: dst };
53
+ }
54
+ const tmp = `${dst}.tmp-${process.pid}-${Date.now()}`;
55
+ writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
56
+ renameSync(tmp, dst);
57
+ return { sha, path: dst };
58
+ }
59
+ /**
60
+ * Read a blob by sha. Returns `undefined` when the blob is missing
61
+ * (cleanup ran, repo cloned fresh, blob never captured). Callers
62
+ * MUST treat undefined as "redo not available" rather than crashing.
63
+ */
64
+ export function readBlob(root, sha) {
65
+ const path = blobPathFor(root, sha);
66
+ if (!existsSync(path))
67
+ return undefined;
68
+ try {
69
+ return readFileSync(path, 'utf8');
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ /**
76
+ * Best-effort blob deletion. Used by the redo runner after a successful
77
+ * reapply so the blob does not get reused on a second redo (which would
78
+ * be incorrect — once redone, the next undo must capture fresh state).
79
+ * Missing-file errors are swallowed — the store self-heals.
80
+ */
81
+ export function deleteBlob(root, sha) {
82
+ const path = blobPathFor(root, sha);
83
+ if (!existsSync(path))
84
+ return;
85
+ try {
86
+ unlinkSync(path);
87
+ }
88
+ catch {
89
+ // Best-effort.
90
+ }
91
+ }
92
+ //# sourceMappingURL=redo-blob-store.js.map
@@ -0,0 +1,361 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { hashContent } from '../../core/file-cache.js';
4
+ import { recordFileMutation, recordToolCall, recordToolResult, } from '../../core/session.js';
5
+ import { deleteBlob, readBlob } from './redo-blob-store.js';
6
+ /**
7
+ * `pugi redo` — counterpart to `pugi undo` (Wave 6, 2026-05-27).
8
+ *
9
+ * Reapplies the file mutations that the most recent `/undo` invocation
10
+ * reverted. Stack-based: each successful redo consumes one undo entry
11
+ * from the event log, so calling /redo twice after two /undo calls
12
+ * walks back through the original mutation history in reverse order.
13
+ *
14
+ * Walk strategy:
15
+ * 1. Read `.pugi/events.jsonl` line by line into an array.
16
+ * 2. Walk backwards. Find the most recent `tool_result` whose
17
+ * `status === 'success'` and whose linked `tool_call` tool is
18
+ * `undo` AND for which no later `tool_result` with tool `redo`
19
+ * has already consumed it. (We track the consumed-by-redo set
20
+ * by walking events forward once and tagging undo results that
21
+ * a subsequent redo references.)
22
+ * 3. Gather every `file_mutation` event that shares the same undo
23
+ * `toolCallId`. These are the INVERSE mutations:
24
+ * - operation: 'delete' + beforeHash=<original-after-sha>
25
+ * → original op was `create`; redo = recreate file from blob
26
+ * - operation: 'update' + beforeHash=<original-after-sha>,
27
+ * afterHash=<original-before-sha>
28
+ * → original op was `update`; redo = restore content from blob
29
+ * - operation: 'update' + beforeHash=undefined,
30
+ * afterHash=<original-before-sha>
31
+ * → original op was `delete`; redo = delete the file again
32
+ *
33
+ * Reapply strategy:
34
+ * For each step we re-apply with a strict safety gate. If the file
35
+ * was modified externally since the undo (current sha ≠ undo's
36
+ * `afterHash`) we refuse to overwrite operator work and abort the
37
+ * whole redo atomically — no partial reapply per spec parity with
38
+ * the undo runner.
39
+ *
40
+ * - inverse `delete` (recreate from create-undo): read blob keyed
41
+ * by `beforeHash`, write at path. Path must not exist OR must
42
+ * match the post-undo (afterHash = undefined, file deleted).
43
+ * - inverse `update` with afterHash present (update-undo or
44
+ * delete-undo restoring HEAD): file must currently match
45
+ * `afterHash`. Reapply by writing blob content (update case) or
46
+ * by unlinking the file (delete case — no blob exists, the
47
+ * `beforeHash === undefined` discriminator routes here).
48
+ * - any other shape → unsafe → abort.
49
+ *
50
+ * After a successful redo:
51
+ * - Emit a `tool_call` event with tool `redo` and inputSummary
52
+ * `replay <undo-toolCallId>` so the next /undo invocation walks
53
+ * PAST it (the redo creates fresh mutations that show up as the
54
+ * most recent successful mutating result).
55
+ * - Emit one `file_mutation` per reapplied step describing the
56
+ * re-applied operation (create / update / delete) so a subsequent
57
+ * /undo can reverse the redo if the operator changes their mind.
58
+ * - Best-effort delete the consumed blobs from `.pugi/undo-blobs/`
59
+ * so a second /redo without a fresh /undo is a noop instead of
60
+ * re-applying stale content.
61
+ */
62
+ const UNDO_TOOL = 'undo';
63
+ export async function runRedoCommand(_args, ctx) {
64
+ const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
65
+ if (!existsSync(eventsPath)) {
66
+ ctx.writeOutput({ command: 'redo', status: 'noop', reason: 'no_session' }, 'No session events found. Nothing to redo.');
67
+ return;
68
+ }
69
+ const events = parseEvents(eventsPath);
70
+ const target = findReplayTarget(events);
71
+ if (!target) {
72
+ ctx.writeOutput({ command: 'redo', status: 'noop', reason: 'no_undo' }, 'No /undo to redo. Run an undo first, then /redo will replay it.');
73
+ return;
74
+ }
75
+ if (target.mutations.length === 0) {
76
+ ctx.writeOutput({
77
+ command: 'redo',
78
+ status: 'noop',
79
+ reason: 'no_inverse_mutations',
80
+ undoToolCallId: target.undoToolCallId,
81
+ }, `Undo ${target.undoToolCallId} recorded no inverse mutations. Nothing to redo.`);
82
+ return;
83
+ }
84
+ // Pre-flight: every step must be reversible before touching disk.
85
+ const plan = planReplays(ctx.workspaceRoot, target.mutations);
86
+ if (plan.aborted) {
87
+ ctx.writeOutput({
88
+ command: 'redo',
89
+ status: 'aborted',
90
+ reason: plan.reason,
91
+ undoToolCallId: target.undoToolCallId,
92
+ unsafe: plan.unsafe,
93
+ }, `Refusing to redo ${target.undoToolCallId}: ${plan.reason}`);
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const replayed = [];
98
+ for (const step of plan.steps) {
99
+ try {
100
+ executeReplay(ctx.workspaceRoot, step);
101
+ replayed.push({ path: step.path, operation: step.replayOperation });
102
+ }
103
+ catch (error) {
104
+ // Mid-flight failure after pre-flight said it was safe. Surface
105
+ // the error and bail — parity with the undo runner's "no partial
106
+ // state on failure" contract.
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ ctx.writeOutput({
109
+ command: 'redo',
110
+ status: 'failed',
111
+ reason: message,
112
+ undoToolCallId: target.undoToolCallId,
113
+ replayed,
114
+ failedAt: step.path,
115
+ }, `Redo failed mid-flight on ${step.path}: ${message}`);
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ }
120
+ // Audit the redo so a future /undo walks back through it. Each
121
+ // replayed step gets a fresh `file_mutation` event so the next
122
+ // /undo's walk picks this up as the most-recent successful
123
+ // mutating tool result.
124
+ const toolCallId = recordToolCall(ctx.session, 'redo', `replay ${target.undoToolCallId}`);
125
+ for (const step of plan.steps) {
126
+ recordFileMutation(ctx.session, {
127
+ toolCallId,
128
+ path: step.path,
129
+ operation: step.replayOperation,
130
+ beforeHash: step.preReplayHash,
131
+ afterHash: step.postReplayHash,
132
+ });
133
+ }
134
+ recordToolResult(ctx.session, toolCallId, 'success', `Redid ${replayed.length} mutation(s) from undo ${target.undoToolCallId}`);
135
+ // Best-effort blob cleanup. After redo the blob is no longer needed
136
+ // — a future /undo will capture fresh after-state from disk if the
137
+ // operator un-does this redo.
138
+ for (const step of plan.steps) {
139
+ if (step.blobSha)
140
+ deleteBlob(ctx.workspaceRoot, step.blobSha);
141
+ }
142
+ ctx.writeOutput({
143
+ command: 'redo',
144
+ status: 'ok',
145
+ undoToolCallId: target.undoToolCallId,
146
+ replayed,
147
+ }, [
148
+ `Redid ${replayed.length} mutation(s) from undo ${target.undoToolCallId}:`,
149
+ ...replayed.map((entry) => ` ${entry.operation.padEnd(7)} ${entry.path}`),
150
+ 'Use /undo to revert.',
151
+ ].join('\n'));
152
+ }
153
+ function parseEvents(eventsPath) {
154
+ const raw = readFileSync(eventsPath, 'utf8');
155
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
156
+ const out = [];
157
+ for (const line of lines) {
158
+ try {
159
+ const parsed = JSON.parse(line);
160
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
161
+ out.push(parsed);
162
+ }
163
+ }
164
+ catch {
165
+ // Partial-write tolerance — same approach as undo.ts.
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ /**
171
+ * Find the most recent successful `undo` tool result that has NOT
172
+ * been consumed by a later `redo` invocation.
173
+ *
174
+ * Consumption tracking: we collect every `redo` tool_call's
175
+ * `inputSummary` of the shape `replay <toolCallId>`. Any matching
176
+ * undo toolCallId is treated as already-redone — calling /redo a
177
+ * second time without a fresh /undo must be a noop, not a re-apply
178
+ * (re-apply would double-write the same content and corrupt the
179
+ * audit trail).
180
+ */
181
+ function findReplayTarget(events) {
182
+ const toolCalls = new Map();
183
+ const results = [];
184
+ const consumed = new Set();
185
+ for (let i = 0; i < events.length; i += 1) {
186
+ const event = events[i];
187
+ if (!event)
188
+ continue;
189
+ if (event.type === 'tool_call' && typeof event.id === 'string' && typeof event.tool === 'string') {
190
+ toolCalls.set(event.id, { id: event.id, tool: event.tool, index: i });
191
+ if (event.tool === 'redo' && typeof event.inputSummary === 'string') {
192
+ const match = /^replay\s+(\S+)/.exec(event.inputSummary);
193
+ if (match && match[1])
194
+ consumed.add(match[1]);
195
+ }
196
+ }
197
+ else if (event.type === 'tool_result' &&
198
+ typeof event.id === 'string' &&
199
+ typeof event.toolCallId === 'string' &&
200
+ (event.status === 'success' || event.status === 'error' || event.status === 'cancelled')) {
201
+ results.push({
202
+ id: event.id,
203
+ toolCallId: event.toolCallId,
204
+ status: event.status,
205
+ index: i,
206
+ });
207
+ }
208
+ }
209
+ // Newest → oldest walk so the stack pops in LIFO order.
210
+ for (let i = results.length - 1; i >= 0; i -= 1) {
211
+ const result = results[i];
212
+ if (result.status !== 'success')
213
+ continue;
214
+ const call = toolCalls.get(result.toolCallId);
215
+ if (!call)
216
+ continue;
217
+ if (call.tool !== UNDO_TOOL)
218
+ continue;
219
+ if (consumed.has(result.toolCallId))
220
+ continue;
221
+ const mutations = [];
222
+ for (const event of events) {
223
+ if (event.type !== 'file_mutation')
224
+ continue;
225
+ if (event.toolCallId !== result.toolCallId)
226
+ continue;
227
+ if (typeof event.path !== 'string')
228
+ continue;
229
+ if (event.operation !== 'create' &&
230
+ event.operation !== 'update' &&
231
+ event.operation !== 'delete' &&
232
+ event.operation !== 'move') {
233
+ continue;
234
+ }
235
+ mutations.push({
236
+ toolCallId: result.toolCallId,
237
+ path: event.path,
238
+ operation: event.operation,
239
+ beforeHash: typeof event.beforeHash === 'string' ? event.beforeHash : undefined,
240
+ afterHash: typeof event.afterHash === 'string' ? event.afterHash : undefined,
241
+ });
242
+ }
243
+ return { undoToolCallId: result.toolCallId, mutations };
244
+ }
245
+ return null;
246
+ }
247
+ function planReplays(root, mutations) {
248
+ const steps = [];
249
+ const unsafe = [];
250
+ for (const mutation of mutations) {
251
+ const abs = resolve(root, mutation.path);
252
+ // The undo emitted three inverse shapes — decode the original op
253
+ // from the (operation, beforeHash, afterHash) triple.
254
+ if (mutation.operation === 'delete' && mutation.beforeHash) {
255
+ // Original op was `create`. Redo = recreate from blob.
256
+ if (existsSync(abs)) {
257
+ unsafe.push(`${mutation.path}: post-undo expected file to be absent, found existing content`);
258
+ continue;
259
+ }
260
+ const content = readBlob(root, mutation.beforeHash);
261
+ if (content === undefined) {
262
+ unsafe.push(`${mutation.path}: no captured AFTER content blob (sha=${mutation.beforeHash.slice(0, 12)}…) — redo not available`);
263
+ continue;
264
+ }
265
+ if (hashContent(content) !== mutation.beforeHash) {
266
+ unsafe.push(`${mutation.path}: blob sha mismatch (store may have been tampered with) — refusing to write`);
267
+ continue;
268
+ }
269
+ steps.push({
270
+ path: mutation.path,
271
+ replayOperation: 'create',
272
+ preReplayHash: undefined,
273
+ postReplayHash: mutation.beforeHash,
274
+ content,
275
+ blobSha: mutation.beforeHash,
276
+ });
277
+ continue;
278
+ }
279
+ if (mutation.operation === 'update' && mutation.beforeHash && mutation.afterHash) {
280
+ // Original op was `update`. Post-undo file matches afterHash
281
+ // (= original beforeHash). Redo = restore beforeHash content
282
+ // from blob (= original afterHash content).
283
+ if (!existsSync(abs)) {
284
+ unsafe.push(`${mutation.path}: file expected to exist for update redo, not found`);
285
+ continue;
286
+ }
287
+ const current = readFileSync(abs, 'utf8');
288
+ if (hashContent(current) !== mutation.afterHash) {
289
+ unsafe.push(`${mutation.path}: modified externally since /undo — refusing to overwrite operator work`);
290
+ continue;
291
+ }
292
+ const content = readBlob(root, mutation.beforeHash);
293
+ if (content === undefined) {
294
+ unsafe.push(`${mutation.path}: no captured AFTER content blob (sha=${mutation.beforeHash.slice(0, 12)}…) — redo not available`);
295
+ continue;
296
+ }
297
+ if (hashContent(content) !== mutation.beforeHash) {
298
+ unsafe.push(`${mutation.path}: blob sha mismatch (store may have been tampered with) — refusing to write`);
299
+ continue;
300
+ }
301
+ steps.push({
302
+ path: mutation.path,
303
+ replayOperation: 'update',
304
+ preReplayHash: mutation.afterHash,
305
+ postReplayHash: mutation.beforeHash,
306
+ content,
307
+ blobSha: mutation.beforeHash,
308
+ });
309
+ continue;
310
+ }
311
+ if (mutation.operation === 'update' && !mutation.beforeHash && mutation.afterHash) {
312
+ // Original op was `delete`. Post-undo file matches afterHash
313
+ // (= original beforeHash, restored from git HEAD). Redo = delete
314
+ // the file again. No blob lookup needed.
315
+ if (!existsSync(abs)) {
316
+ // Already gone — nothing to redo for this entry.
317
+ continue;
318
+ }
319
+ const current = readFileSync(abs, 'utf8');
320
+ if (hashContent(current) !== mutation.afterHash) {
321
+ unsafe.push(`${mutation.path}: modified externally since /undo — refusing to delete operator work`);
322
+ continue;
323
+ }
324
+ steps.push({
325
+ path: mutation.path,
326
+ replayOperation: 'delete',
327
+ preReplayHash: mutation.afterHash,
328
+ postReplayHash: undefined,
329
+ });
330
+ continue;
331
+ }
332
+ // Anything else (move undo, malformed entry) — refuse rather than
333
+ // partial-replay. Matches the undo runner's posture.
334
+ unsafe.push(`${mutation.path}: redo of inverse op '${mutation.operation}' is not supported in this build`);
335
+ }
336
+ if (unsafe.length > 0) {
337
+ return {
338
+ aborted: true,
339
+ reason: 'one or more files are unsafe to redo',
340
+ unsafe,
341
+ steps: [],
342
+ };
343
+ }
344
+ return { aborted: false, steps };
345
+ }
346
+ function executeReplay(root, step) {
347
+ const abs = resolve(root, step.path);
348
+ if (step.replayOperation === 'delete') {
349
+ if (existsSync(abs))
350
+ unlinkSync(abs);
351
+ return;
352
+ }
353
+ if (step.content === undefined) {
354
+ throw new Error(`internal: replay content missing for ${step.path}`);
355
+ }
356
+ // Atomic tmp+rename — same pattern as undo's executeRevert.
357
+ const tmp = `${abs}.pugi-redo-${Date.now()}`;
358
+ writeFileSync(tmp, step.content, { encoding: 'utf8', mode: 0o600 });
359
+ renameSync(tmp, abs);
360
+ }
361
+ //# sourceMappingURL=redo.js.map
@@ -108,6 +108,8 @@ export async function runStatusCommand(ctx) {
108
108
  liveTokensUsed: ctx.liveTokensUsed ?? null,
109
109
  lastCommand: ctx.lastCommand ?? null,
110
110
  lastCommandAtEpochMs: ctx.lastCommandAtEpochMs ?? null,
111
+ liveApiUrl: ctx.liveApiUrl ?? null,
112
+ workspaceLabel: ctx.workspaceLabel ?? null,
111
113
  fs: ctx.fs ?? DEFAULT_FS,
112
114
  resolveCredential: ctx.resolveCredential ?? (() => defaultResolveCredential(ctx.env, ctx.home)),
113
115
  resolvePermissionMode: ctx.resolvePermissionMode ?? (() => permissionMode),
@@ -155,13 +157,19 @@ export async function runStatusCommand(ctx) {
155
157
  * narrow terminals stay legible without a layout library.
156
158
  */
157
159
  export function renderStatusTable(snapshot) {
158
- const labelWidth = Math.max('Label'.length, ...snapshot.fields.map((f) => f.label.length));
160
+ // Row syntax: `${Label}: ${value}` with exactly ONE space after the
161
+ // colon, then the value verbatim. The colon doubles as a visual
162
+ // anchor and the REPL spec assertions match on the single-space
163
+ // form (`Backend: https://api.pugi.io`) — multi-space column
164
+ // padding broke `.includes('Backend: https://...')` substring
165
+ // checks. Operators who want a column layout can run the Ink
166
+ // renderer (`<StatusTable>`); the plain-text fallback stays
167
+ // narrow-terminal friendly without padding columns.
159
168
  const lines = [];
160
169
  lines.push('Pugi status');
161
170
  lines.push('═'.repeat(50));
162
171
  for (const field of snapshot.fields) {
163
- const labelPart = field.label.padEnd(labelWidth, ' ');
164
- lines.push(`${labelPart} ${field.value}`);
172
+ lines.push(`${field.label}: ${field.value}`);
165
173
  }
166
174
  lines.push('');
167
175
  lines.push(`CLI ${snapshot.meta.cliVersion} Node ${snapshot.meta.nodeVersion} cwd ${snapshot.meta.cwd}`);
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from
3
3
  import { resolve } from 'node:path';
4
4
  import { hashContent } from '../../core/file-cache.js';
5
5
  import { recordFileMutation, recordToolCall, recordToolResult, } from '../../core/session.js';
6
+ import { writeBlob } from './redo-blob-store.js';
6
7
  /**
7
8
  * `pugi undo` — revert the file mutations from the most recent successful
8
9
  * `write` / `edit` / `multi_edit` tool result.
@@ -81,6 +82,37 @@ export async function runUndoCommand(_args, ctx) {
81
82
  const restored = [];
82
83
  for (const step of plan.steps) {
83
84
  try {
85
+ // Wave 6 (2026-05-27): snapshot the AFTER state into the redo
86
+ // blob store BEFORE we revert the file on disk. /redo reads this
87
+ // blob keyed by `step.beforeHash` (= original afterHash) to
88
+ // reapply the change. We only snapshot for operations that have
89
+ // on-disk AFTER content: `create` (file exists, about to be
90
+ // deleted) and `update` (file exists, about to be overwritten).
91
+ // For `delete` reverts (file was deleted by Pugi, the "after" is
92
+ // nothing) redo replays the delete itself — no content needed.
93
+ // Best-effort: a blob-store failure must not abort the undo, so
94
+ // the writeBlob call is wrapped и any error swallowed.
95
+ if (step.operation === 'create' || step.operation === 'update') {
96
+ try {
97
+ const abs = resolve(ctx.workspaceRoot, step.path);
98
+ if (existsSync(abs) && step.beforeHash) {
99
+ const current = readFileSync(abs, 'utf8');
100
+ // Defensive: only snapshot if the current sha matches the
101
+ // pre-revert hash the planner verified. The planner already
102
+ // gated this, but a TOCTOU between plan + execute would
103
+ // produce a wrong blob — silently dropping it is safer than
104
+ // shipping content under the wrong sha.
105
+ if (hashContent(current) === step.beforeHash) {
106
+ writeBlob(ctx.workspaceRoot, current);
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // Best-effort. Failure to snapshot means /redo will report
112
+ // "no captured content" — operator can re-run the mutation
113
+ // manually. Better than aborting the undo.
114
+ }
115
+ }
84
116
  executeRevert(ctx.workspaceRoot, step);
85
117
  restored.push({ path: step.path, operation: step.operation });
86
118
  }