@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.35
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/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/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,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
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless REPL — `pugi --headless` (BIG TRACK 10 Phase 1, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Long-form rationale lives in the spec PR; the short version: every
|
|
5
|
+
* CLI publish gets manually smoke-tested today (CEO directive
|
|
6
|
+
* `feedback_live_console_test_every_publish`) and that toil must be
|
|
7
|
+
* automated. `pugi --headless` is the I/O surface that lets a scripted
|
|
8
|
+
* harness drive Pugi multi-turn — one stdin line in, one or more JSON
|
|
9
|
+
* envelopes out on stdout, exit when stdin closes. This is the
|
|
10
|
+
* machine-facing peer to the human-facing Ink REPL.
|
|
11
|
+
*
|
|
12
|
+
* Envelope shape (Phase 1):
|
|
13
|
+
*
|
|
14
|
+
* { "kind": "user-turn" | "persona-turn" | "tool-call" | "error"
|
|
15
|
+
* | "session-start" | "session-end" | "system",
|
|
16
|
+
* "body": "<string>",
|
|
17
|
+
* "ts": <epoch ms> }
|
|
18
|
+
*
|
|
19
|
+
* One JSON object per line. Stdout stays pure envelopes; stderr gets
|
|
20
|
+
* any human-readable trace. Discipline mirrors the existing
|
|
21
|
+
* `runHeadlessPrint` in `headless.ts`.
|
|
22
|
+
*
|
|
23
|
+
* Phase 1 engine wiring — the headless REPL emits the user-turn
|
|
24
|
+
* envelope verbatim, then asks the engine adapter for ONE turn, then
|
|
25
|
+
* emits the persona-turn / tool-call envelopes. Multi-turn state is
|
|
26
|
+
* accumulated in a single in-process session so consecutive lines see
|
|
27
|
+
* the same persona history. When a credential is absent (a common CI
|
|
28
|
+
* state) we fall through to a deterministic stub responder so the
|
|
29
|
+
* smoke harness can still exercise the I/O contract WITHOUT requiring
|
|
30
|
+
* an api.pugi.io reachability dependency.
|
|
31
|
+
*/
|
|
32
|
+
import { createInterface } from 'node:readline';
|
|
33
|
+
import { resolve as resolvePath } from 'node:path';
|
|
34
|
+
import { resolveActiveCredential } from '../core/credentials.js';
|
|
35
|
+
/**
|
|
36
|
+
* Run the headless REPL loop. Returns the desired process exit code:
|
|
37
|
+
*
|
|
38
|
+
* 0 Stdin closed cleanly after at least one successful turn.
|
|
39
|
+
* 0 Stdin closed with no input (empty pipe — harmless, exit clean).
|
|
40
|
+
* 1 Fatal error from the turn handler.
|
|
41
|
+
*
|
|
42
|
+
* The caller (cli.ts) sets `process.exitCode`; we never call
|
|
43
|
+
* `process.exit` so an embedded driver can reuse the function.
|
|
44
|
+
*/
|
|
45
|
+
export async function runHeadlessRepl(opts) {
|
|
46
|
+
const cwd = resolvePath(opts.cwd);
|
|
47
|
+
const stdoutWrite = opts.stdoutWrite ?? ((chunk) => process.stdout.write(chunk));
|
|
48
|
+
const stderrWrite = opts.stderrWrite ?? ((chunk) => process.stderr.write(chunk));
|
|
49
|
+
const now = opts.now ?? Date.now;
|
|
50
|
+
const turnHandler = opts.turnHandler ?? buildDefaultTurnHandler();
|
|
51
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
52
|
+
emit(stdoutWrite, {
|
|
53
|
+
kind: 'session-start',
|
|
54
|
+
body: JSON.stringify({ cwd, cliVersion: 'phase1' }),
|
|
55
|
+
ts: now(),
|
|
56
|
+
});
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: stdin,
|
|
59
|
+
crlfDelay: Infinity,
|
|
60
|
+
terminal: false,
|
|
61
|
+
});
|
|
62
|
+
let turnIndex = 0;
|
|
63
|
+
let fatal = false;
|
|
64
|
+
for await (const rawLine of rl) {
|
|
65
|
+
const line = rawLine.replace(/\r$/, '');
|
|
66
|
+
if (line.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
emit(stdoutWrite, {
|
|
69
|
+
kind: 'user-turn',
|
|
70
|
+
body: line,
|
|
71
|
+
ts: now(),
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const out = await turnHandler({ line, turnIndex, cwd });
|
|
75
|
+
for (const env of out) {
|
|
76
|
+
emit(stdoutWrite, { kind: env.kind, body: env.body, ts: now() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
emit(stdoutWrite, {
|
|
82
|
+
kind: 'error',
|
|
83
|
+
body: JSON.stringify({ message }),
|
|
84
|
+
ts: now(),
|
|
85
|
+
});
|
|
86
|
+
stderrWrite(`pugi --headless: turn handler threw: ${message}\n`);
|
|
87
|
+
fatal = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
turnIndex += 1;
|
|
91
|
+
}
|
|
92
|
+
emit(stdoutWrite, {
|
|
93
|
+
kind: 'session-end',
|
|
94
|
+
body: JSON.stringify({ turns: turnIndex }),
|
|
95
|
+
ts: now(),
|
|
96
|
+
});
|
|
97
|
+
return fatal ? 1 : 0;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Emit a single envelope to stdout. Always terminates with `\n` so a
|
|
101
|
+
* line-buffered reader (the smoke runner, jq, etc.) sees each
|
|
102
|
+
* envelope atomically.
|
|
103
|
+
*/
|
|
104
|
+
function emit(write, envelope) {
|
|
105
|
+
write(`${JSON.stringify(envelope)}\n`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the default turn handler. When a Pugi credential is resolvable
|
|
109
|
+
* we'll plumb to the engine adapter in Phase 2; for Phase 1 the
|
|
110
|
+
* default responder is a deterministic stub. Smoke scenarios that
|
|
111
|
+
* exercise the engine path use a Phase-2-only flag (`PUGI_HEADLESS_LIVE=1`)
|
|
112
|
+
* to opt into the real engine; the default keeps CI offline-safe.
|
|
113
|
+
*/
|
|
114
|
+
function buildDefaultTurnHandler() {
|
|
115
|
+
const credential = resolveActiveCredential();
|
|
116
|
+
if (credential && process.env.PUGI_HEADLESS_LIVE === '1') {
|
|
117
|
+
// Phase 2: route through `NativePugiEngineAdapter` here. Left as a
|
|
118
|
+
// stub so the Phase 1 PR stays narrowly scoped to the I/O surface.
|
|
119
|
+
// The smoke corpus does NOT exercise this branch in Phase 1.
|
|
120
|
+
return stubResponder('live engine path not yet wired (Phase 2)');
|
|
121
|
+
}
|
|
122
|
+
return stubResponder('pugi headless stub: no credential or PUGI_HEADLESS_LIVE!=1; echoing input');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Tiny deterministic responder used by Phase 1 smoke tests. Echoes the
|
|
126
|
+
* input back as a `persona-turn` envelope with a stable prefix so
|
|
127
|
+
* scenarios can author assertions against it. The contract is:
|
|
128
|
+
*
|
|
129
|
+
* - Input "ты кто?" → persona-turn "Pugi: ты кто? (stub)"
|
|
130
|
+
* - Input "create FILE with content X"
|
|
131
|
+
* → tool-call Write file=FILE +
|
|
132
|
+
* persona-turn "Pugi: wrote FILE"
|
|
133
|
+
* - Any other input → persona-turn "Pugi: ...(stub)"
|
|
134
|
+
*
|
|
135
|
+
* Real engine routing is Phase 2. The stub exists to validate the
|
|
136
|
+
* envelope contract end-to-end in CI without depending on api.pugi.io.
|
|
137
|
+
*/
|
|
138
|
+
function stubResponder(banner) {
|
|
139
|
+
return async (input) => {
|
|
140
|
+
const text = input.line.trim();
|
|
141
|
+
const envelopes = [];
|
|
142
|
+
const createMatch = /^create\s+(\S+)\s+with\s+content\s+['"]([^'"]+)['"]\s*$/i.exec(text);
|
|
143
|
+
if (createMatch) {
|
|
144
|
+
const file = createMatch[1] ?? 'unknown';
|
|
145
|
+
const body = createMatch[2] ?? '';
|
|
146
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
147
|
+
const { dirname } = await import('node:path');
|
|
148
|
+
const absolute = resolvePath(input.cwd, file);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
151
|
+
writeFileSync(absolute, body, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
envelopes.push({
|
|
155
|
+
kind: 'error',
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
message: `write failed: ${error.message}`,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
return envelopes;
|
|
161
|
+
}
|
|
162
|
+
envelopes.push({
|
|
163
|
+
kind: 'tool-call',
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
tool: 'Write',
|
|
166
|
+
args: { file, content: body },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
envelopes.push({
|
|
170
|
+
kind: 'persona-turn',
|
|
171
|
+
body: `Pugi: wrote ${file}`,
|
|
172
|
+
});
|
|
173
|
+
return envelopes;
|
|
174
|
+
}
|
|
175
|
+
if (/^ты\s+кто/i.test(text) || /^who\s+are\s+you/i.test(text)) {
|
|
176
|
+
envelopes.push({
|
|
177
|
+
kind: 'persona-turn',
|
|
178
|
+
body: 'Pugi: я Pugi, твой co-pilot. (Пуджи, stub)',
|
|
179
|
+
});
|
|
180
|
+
return envelopes;
|
|
181
|
+
}
|
|
182
|
+
envelopes.push({
|
|
183
|
+
kind: 'persona-turn',
|
|
184
|
+
body: `Pugi: ${text} (stub)`,
|
|
185
|
+
});
|
|
186
|
+
// One-time banner on the first turn so operators see why the stub
|
|
187
|
+
// is firing. Suppressed on subsequent turns to keep the envelope
|
|
188
|
+
// stream noise-free.
|
|
189
|
+
if (input.turnIndex === 0) {
|
|
190
|
+
envelopes.push({ kind: 'system', body: banner });
|
|
191
|
+
}
|
|
192
|
+
return envelopes;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=headless-repl.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.35');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|