@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.
- package/dist/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- 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
|
-
|
|
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
|
-
|
|
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
|
}
|