@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.
Files changed (43) 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/onboarding/ensure-initialized.js +133 -0
  10. package/dist/core/repl/session.js +370 -9
  11. package/dist/core/repl/slash-commands.js +68 -5
  12. package/dist/core/smoke/headless-driver.js +174 -0
  13. package/dist/core/smoke/orchestrator.js +194 -0
  14. package/dist/core/smoke/runner.js +238 -0
  15. package/dist/core/smoke/scenario-parser.js +316 -0
  16. package/dist/runtime/cli.js +453 -11
  17. package/dist/runtime/commands/cancel.js +231 -0
  18. package/dist/runtime/commands/codegraph-status.js +227 -0
  19. package/dist/runtime/commands/permissions.js +23 -0
  20. package/dist/runtime/commands/redo-blob-store.js +92 -0
  21. package/dist/runtime/commands/redo.js +361 -0
  22. package/dist/runtime/commands/status.js +11 -3
  23. package/dist/runtime/commands/undo.js +32 -0
  24. package/dist/runtime/headless-repl.js +195 -0
  25. package/dist/runtime/version.js +1 -1
  26. package/dist/tui/permissions-picker.js +78 -0
  27. package/dist/tui/render.js +35 -0
  28. package/dist/tui/status-bar.js +1 -1
  29. package/dist/tui/tool-stream-pane.js +45 -3
  30. package/package.json +7 -4
  31. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  32. package/test/scenarios/compact-force.scenario.txt +11 -0
  33. package/test/scenarios/identity.scenario.txt +11 -0
  34. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  35. package/test/scenarios/walkback.scenario.txt +12 -0
  36. package/dist/core/engine/compaction-hook.js +0 -154
  37. package/dist/core/init/scaffold.js +0 -195
  38. package/dist/core/memory/dual-write.spec.js +0 -297
  39. package/dist/core/memory-sync/queue.spec.js +0 -105
  40. package/dist/core/repl/codebase-survey.js +0 -308
  41. package/dist/core/repl/init-interview.js +0 -457
  42. package/dist/core/repl/onboarding-state.js +0 -297
  43. 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
- 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
  }
@@ -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
@@ -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.31');
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.