@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -0,0 +1,231 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { loadMcpRegistry } from '../../core/mcp/registry.js';
6
+ import { listMcpTrust, setMcpTrust, } from '../../core/mcp/trust.js';
7
+ import { trustWorkspace } from '../../core/trust.js';
8
+ /**
9
+ * `pugi config` — operator-level configuration surface.
10
+ *
11
+ * Subcommands:
12
+ * - `pugi config get <key>` read a value from `~/.pugi/config.json`
13
+ * - `pugi config set <key> <value>` write a value
14
+ * - `pugi config list` dump all values
15
+ * - `pugi config trust .` trust the current workspace (delegates to core/trust.ts)
16
+ * - `pugi config mcp trust <name>` flip MCP server to trusted
17
+ * - `pugi config mcp deny <name>` flip MCP server to denied
18
+ * - `pugi config mcp list` show declared servers + their trust state
19
+ *
20
+ * Schema (pugi-config-v1):
21
+ * {
22
+ * "permissionMode": "ask" | "acceptEdits" | "auto" | "plan" | "dontAsk" | "bypassPermissions",
23
+ * "privacy": "local-only" | "metadata" | "full",
24
+ * "model": "<id>" | null,
25
+ * "preferredEndpoint": "https://api.pugi.io"
26
+ * }
27
+ *
28
+ * The config file lives at `~/.pugi/config.json` (PUGI_HOME-aware) and uses
29
+ * mode 0o600. Unknown keys are rejected by `set` so a typo never silently
30
+ * persists.
31
+ */
32
+ const configSchema = z
33
+ .object({
34
+ permissionMode: z
35
+ .enum(['plan', 'ask', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions'])
36
+ .optional(),
37
+ privacy: z.enum(['local-only', 'metadata', 'full']).optional(),
38
+ model: z.string().nullable().optional(),
39
+ preferredEndpoint: z.string().url().optional(),
40
+ })
41
+ .strict();
42
+ const CONFIG_KEYS = ['permissionMode', 'privacy', 'model', 'preferredEndpoint'];
43
+ export async function runConfigCommand(args, ctx) {
44
+ const sub = args[0];
45
+ if (!sub || sub === '--help' || sub === '-h') {
46
+ ctx.writeOutput({
47
+ command: 'config',
48
+ usage: [
49
+ 'pugi config get <key>',
50
+ 'pugi config set <key> <value>',
51
+ 'pugi config list',
52
+ 'pugi config trust .',
53
+ 'pugi config mcp trust <name>',
54
+ 'pugi config mcp deny <name>',
55
+ 'pugi config mcp list',
56
+ ],
57
+ }, [
58
+ 'Usage:',
59
+ ' pugi config get <key> Read a config value.',
60
+ ' pugi config set <key> <value> Write a config value.',
61
+ ' pugi config list Show all config values.',
62
+ ' pugi config trust . Trust the current workspace for hooks + MCP.',
63
+ ' pugi config mcp trust <name> Mark an MCP server as trusted.',
64
+ ' pugi config mcp deny <name> Block an MCP server.',
65
+ ' pugi config mcp list Show declared MCP servers + trust state.',
66
+ ].join('\n'));
67
+ return;
68
+ }
69
+ switch (sub) {
70
+ case 'get':
71
+ return runConfigGet(args.slice(1), ctx);
72
+ case 'set':
73
+ return runConfigSet(args.slice(1), ctx);
74
+ case 'list':
75
+ return runConfigList(ctx);
76
+ case 'trust':
77
+ return runConfigTrust(args.slice(1), ctx);
78
+ case 'mcp':
79
+ return runConfigMcp(args.slice(1), ctx);
80
+ default:
81
+ throw new Error(`Unknown sub-command "pugi config ${sub}". Expected get, set, list, trust, or mcp.`);
82
+ }
83
+ }
84
+ function configPath() {
85
+ const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
86
+ return resolve(home, 'config.json');
87
+ }
88
+ export function readConfig() {
89
+ const path = configPath();
90
+ if (!existsSync(path))
91
+ return {};
92
+ const raw = readFileSync(path, 'utf8');
93
+ if (raw.trim() === '')
94
+ return {};
95
+ const parsed = JSON.parse(raw);
96
+ return configSchema.parse(parsed);
97
+ }
98
+ function writeConfig(config) {
99
+ const path = configPath();
100
+ mkdirSync(dirname(path), { recursive: true });
101
+ // 0o600 — `preferredEndpoint` could be a private self-hosted URL; the
102
+ // config does not contain secrets, but file mode parity with the trust
103
+ // ledger keeps the audit surface uniform.
104
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
105
+ encoding: 'utf8',
106
+ mode: 0o600,
107
+ });
108
+ }
109
+ function isConfigKey(value) {
110
+ return CONFIG_KEYS.includes(value);
111
+ }
112
+ function runConfigGet(args, ctx) {
113
+ const key = args[0];
114
+ if (!key)
115
+ throw new Error('pugi config get requires a key.');
116
+ if (!isConfigKey(key)) {
117
+ throw new Error(`Unknown config key "${key}". Allowed: ${CONFIG_KEYS.join(', ')}.`);
118
+ }
119
+ const config = readConfig();
120
+ const value = config[key] ?? null;
121
+ ctx.writeOutput({ command: 'config.get', key, value }, value === null || value === undefined ? `${key} = (unset)` : `${key} = ${String(value)}`);
122
+ }
123
+ function runConfigSet(args, ctx) {
124
+ const key = args[0];
125
+ const value = args.slice(1).join(' ');
126
+ if (!key)
127
+ throw new Error('pugi config set requires a key.');
128
+ if (value.length === 0)
129
+ throw new Error('pugi config set requires a value.');
130
+ if (!isConfigKey(key)) {
131
+ throw new Error(`Unknown config key "${key}". Allowed: ${CONFIG_KEYS.join(', ')}.`);
132
+ }
133
+ const current = readConfig();
134
+ // Build the candidate and validate via the schema so an invalid value
135
+ // (e.g. `permissionMode = nonsense`) is rejected before persistence.
136
+ const candidate = { ...current };
137
+ candidate[key] = coerceValue(key, value);
138
+ const validated = configSchema.parse(candidate);
139
+ writeConfig(validated);
140
+ ctx.writeOutput({
141
+ command: 'config.set',
142
+ key,
143
+ value: validated[key] ?? null,
144
+ }, `${key} = ${String(validated[key] ?? '')}`);
145
+ }
146
+ function coerceValue(key, raw) {
147
+ if (key === 'model') {
148
+ return raw === 'null' || raw === '' ? null : raw;
149
+ }
150
+ return raw;
151
+ }
152
+ function runConfigList(ctx) {
153
+ const config = readConfig();
154
+ const entries = CONFIG_KEYS.map((key) => ({
155
+ key,
156
+ value: config[key] ?? null,
157
+ }));
158
+ ctx.writeOutput({ command: 'config.list', config, entries }, entries
159
+ .map((entry) => entry.value === null || entry.value === undefined
160
+ ? `${entry.key} = (unset)`
161
+ : `${entry.key} = ${String(entry.value)}`)
162
+ .join('\n'));
163
+ }
164
+ async function runConfigTrust(args, ctx) {
165
+ const target = args[0];
166
+ if (!target)
167
+ throw new Error('pugi config trust requires a path (use "." for cwd).');
168
+ const root = target === '.' ? ctx.workspaceRoot : resolve(ctx.workspaceRoot, target);
169
+ // Identity for the audit entry: prefer the explicit env override
170
+ // (`PUGI_TRUSTED_BY`), then `USER` from the shell, then literal
171
+ // 'cli'. The trust ledger requires a non-empty string and we want
172
+ // it to mean something for a future audit replay.
173
+ const by = process.env.PUGI_TRUSTED_BY?.trim() ||
174
+ process.env.USER?.trim() ||
175
+ process.env.USERNAME?.trim() ||
176
+ 'cli';
177
+ await trustWorkspace(root, by);
178
+ ctx.writeOutput({ command: 'config.trust', workspaceRoot: root, trustedBy: by }, `Trusted workspace: ${root}`);
179
+ }
180
+ async function runConfigMcp(args, ctx) {
181
+ const sub = args[0];
182
+ if (!sub) {
183
+ throw new Error('pugi config mcp requires a sub-command: trust, deny, or list.');
184
+ }
185
+ switch (sub) {
186
+ case 'list':
187
+ return runConfigMcpList(ctx);
188
+ case 'trust':
189
+ return runConfigMcpFlip(args.slice(1), ctx, 'trusted');
190
+ case 'deny':
191
+ return runConfigMcpFlip(args.slice(1), ctx, 'denied');
192
+ default:
193
+ throw new Error(`Unknown sub-command "pugi config mcp ${sub}". Expected trust, deny, or list.`);
194
+ }
195
+ }
196
+ async function runConfigMcpList(ctx) {
197
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
198
+ const declared = Array.from(registry.servers.values()).map((state) => ({
199
+ name: state.name,
200
+ command: state.config.command,
201
+ args: state.config.args,
202
+ trust: state.trust,
203
+ surfacedTools: state.surfacedTools.length,
204
+ lastError: state.lastError ?? null,
205
+ }));
206
+ const ledger = await listMcpTrust();
207
+ await registry.shutdown();
208
+ if (declared.length === 0) {
209
+ ctx.writeOutput({ command: 'config.mcp.list', servers: [], ledger }, 'No MCP servers declared. Add one to .pugi/mcp.json or ~/.pugi/mcp.json.');
210
+ return;
211
+ }
212
+ ctx.writeOutput({ command: 'config.mcp.list', servers: declared, ledger }, [
213
+ 'MCP servers:',
214
+ ...declared.map((server) => ` ${server.name.padEnd(20)} ${server.trust.padEnd(8)} ${server.command} ${server.args.join(' ')}`),
215
+ ].join('\n'));
216
+ }
217
+ async function runConfigMcpFlip(args, ctx, state) {
218
+ const name = args[0];
219
+ if (!name) {
220
+ throw new Error(`pugi config mcp ${state === 'trusted' ? 'trust' : 'deny'} requires a server name.`);
221
+ }
222
+ const by = process.env.PUGI_TRUSTED_BY?.trim() ||
223
+ process.env.USER?.trim() ||
224
+ process.env.USERNAME?.trim() ||
225
+ 'cli';
226
+ await setMcpTrust(name, state, by);
227
+ ctx.writeOutput({ command: `config.mcp.${state === 'trusted' ? 'trust' : 'deny'}`, name, state, decidedBy: by }, state === 'trusted'
228
+ ? `MCP server "${name}" is now trusted.`
229
+ : `MCP server "${name}" is now denied.`);
230
+ }
231
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ import { readConfig } from './config.js';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, resolve } from 'node:path';
6
+ /**
7
+ * `pugi privacy` — read or update the operator's privacy mode.
8
+ *
9
+ * Subcommands:
10
+ * - `pugi privacy show` — print current mode + source
11
+ * - `pugi privacy set <mode>` — write `local-only | metadata | full`
12
+ *
13
+ * Persistence:
14
+ * - Stored in `~/.pugi/privacy.json` (PUGI_HOME-aware).
15
+ * - The `privacy` key in `~/.pugi/config.json` is also consulted at
16
+ * read time (config takes precedence when both exist) so a user who
17
+ * already set `pugi config set privacy ...` sees the same value
18
+ * here.
19
+ *
20
+ * Modes:
21
+ * - `local-only` — nothing leaves the workstation. Engine commands
22
+ * refuse to ship transcripts; sync is a no-op.
23
+ * - `metadata` — only artifact metadata + session timeline ships
24
+ * (no raw file contents).
25
+ * - `full` — full sync allowed (operator-acknowledged).
26
+ */
27
+ export const privacyModeSchema = z.enum(['local-only', 'metadata', 'full']);
28
+ const privacyFileSchema = z.object({
29
+ schema: z.number().int().positive().default(1),
30
+ mode: privacyModeSchema,
31
+ });
32
+ function privacyPath() {
33
+ const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
34
+ return resolve(home, 'privacy.json');
35
+ }
36
+ export function readPrivacyFile() {
37
+ const path = privacyPath();
38
+ if (!existsSync(path))
39
+ return null;
40
+ const raw = readFileSync(path, 'utf8');
41
+ if (raw.trim() === '')
42
+ return null;
43
+ const parsed = JSON.parse(raw);
44
+ const result = privacyFileSchema.safeParse(parsed);
45
+ if (!result.success)
46
+ return null;
47
+ return result.data.mode;
48
+ }
49
+ function writePrivacyFile(mode) {
50
+ const path = privacyPath();
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ writeFileSync(path, `${JSON.stringify({ schema: 1, mode }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
53
+ }
54
+ /**
55
+ * Effective privacy mode resolution order:
56
+ * 1. `~/.pugi/config.json` privacy key (when set via `pugi config set`)
57
+ * 2. `~/.pugi/privacy.json` (when set via `pugi privacy set`)
58
+ * 3. `metadata` (default — matches the M1 default-ship posture)
59
+ */
60
+ export function resolvePrivacyMode() {
61
+ const config = readConfig();
62
+ if (config.privacy)
63
+ return { mode: config.privacy, source: 'config' };
64
+ const fromFile = readPrivacyFile();
65
+ if (fromFile)
66
+ return { mode: fromFile, source: 'privacy' };
67
+ return { mode: 'metadata', source: 'default' };
68
+ }
69
+ export async function runPrivacyCommand(args, ctx) {
70
+ const sub = args[0];
71
+ if (!sub || sub === '--help' || sub === '-h') {
72
+ ctx.writeOutput({
73
+ command: 'privacy',
74
+ usage: ['pugi privacy show', 'pugi privacy set <mode>'],
75
+ modes: ['local-only', 'metadata', 'full'],
76
+ }, [
77
+ 'Usage:',
78
+ ' pugi privacy show Show current privacy mode.',
79
+ ' pugi privacy set <mode> Set mode to local-only, metadata, or full.',
80
+ ].join('\n'));
81
+ return;
82
+ }
83
+ switch (sub) {
84
+ case 'show':
85
+ return runPrivacyShow(ctx);
86
+ case 'set':
87
+ return runPrivacySet(args.slice(1), ctx);
88
+ default:
89
+ throw new Error(`Unknown sub-command "pugi privacy ${sub}". Expected show or set.`);
90
+ }
91
+ }
92
+ function runPrivacyShow(ctx) {
93
+ const resolved = resolvePrivacyMode();
94
+ ctx.writeOutput({ command: 'privacy.show', ...resolved }, `Privacy mode: ${resolved.mode} (source: ${resolved.source})`);
95
+ }
96
+ function runPrivacySet(args, ctx) {
97
+ const raw = args[0];
98
+ if (!raw)
99
+ throw new Error('pugi privacy set requires a mode: local-only, metadata, or full.');
100
+ const result = privacyModeSchema.safeParse(raw);
101
+ if (!result.success) {
102
+ throw new Error(`Invalid privacy mode "${raw}". Allowed: local-only, metadata, full.`);
103
+ }
104
+ writePrivacyFile(result.data);
105
+ ctx.writeOutput({ command: 'privacy.set', mode: result.data }, `Privacy mode set to ${result.data}.`);
106
+ }
107
+ //# sourceMappingURL=privacy.js.map
@@ -0,0 +1,329 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { hashContent } from '../../core/file-cache.js';
5
+ import { recordFileMutation, recordToolCall, recordToolResult, } from '../../core/session.js';
6
+ /**
7
+ * `pugi undo` — revert the file mutations from the most recent successful
8
+ * `write` / `edit` / `multi_edit` tool result.
9
+ *
10
+ * Walk strategy:
11
+ * 1. Read `.pugi/events.jsonl` line by line into an array.
12
+ * 2. Walk backwards. Find the most recent `tool_result` whose
13
+ * `status === 'success'` and whose linked `tool_call` names a
14
+ * mutating tool (write / edit / multi_edit).
15
+ * 3. From that point, gather every `file_mutation` event that shares
16
+ * the same `toolCallId`. M1 records one `file_mutation` per
17
+ * mutating tool call, but the loop is shaped for the multi_edit
18
+ * future case where a single tool call mutates many files.
19
+ *
20
+ * Restore strategy (M1 — no blob store yet):
21
+ * For each mutation we restore from the workspace's git history when
22
+ * possible, with a strict safety gate to avoid silently overwriting
23
+ * the user's WORK-IN-PROGRESS:
24
+ *
25
+ * - `operation: create` → unlink the created file iff its current
26
+ * sha256 matches the recorded `afterHash`. Skip otherwise (the user
27
+ * has edited it since; refuse to delete their work).
28
+ * - `operation: update` → restore from `HEAD:<path>` iff (a) the file
29
+ * was tracked at HEAD AND (b) the HEAD content's sha256 matches the
30
+ * recorded `beforeHash`. Otherwise abort the turn (no partial
31
+ * reverts — the spec is atomic).
32
+ * - `operation: delete` → restore the deleted file from `HEAD:<path>`
33
+ * if tracked there; skip otherwise.
34
+ *
35
+ * If any single restore is unsafe the whole turn aborts with exit code
36
+ * 1 and no files are touched.
37
+ *
38
+ * Why git as the backing store: the file cache stores hash + metadata
39
+ * but NOT raw content (see `core/file-cache.ts`). M1 ships without a
40
+ * dedicated blob CAS; `git show HEAD:<path>` is the cheapest authoritative
41
+ * source for pre-mutation content as long as the workspace is a git
42
+ * repository. The α6.4 SQLite session store will replace this with a
43
+ * proper before-blob ledger.
44
+ */
45
+ const MUTATING_TOOLS = new Set(['write', 'edit', 'multi_edit']);
46
+ export async function runUndoCommand(_args, ctx) {
47
+ const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
48
+ if (!existsSync(eventsPath)) {
49
+ ctx.writeOutput({ command: 'undo', status: 'noop', reason: 'no_session' }, 'No session events found. Nothing to undo.');
50
+ return;
51
+ }
52
+ const events = parseEvents(eventsPath);
53
+ const target = findLastMutationTurn(events);
54
+ if (!target) {
55
+ ctx.writeOutput({ command: 'undo', status: 'noop', reason: 'no_mutation' }, 'No mutating tool result found in the session log.');
56
+ return;
57
+ }
58
+ if (target.mutations.length === 0) {
59
+ ctx.writeOutput({
60
+ command: 'undo',
61
+ status: 'noop',
62
+ reason: 'no_file_mutations',
63
+ toolCallId: target.toolCallId,
64
+ }, `Tool call ${target.toolCallId} recorded no file mutations.`);
65
+ return;
66
+ }
67
+ // Pre-flight: confirm every mutation is reversible before touching
68
+ // disk. This keeps the operation atomic per spec.
69
+ const plan = planReverts(ctx.workspaceRoot, target.mutations);
70
+ if (plan.aborted) {
71
+ ctx.writeOutput({
72
+ command: 'undo',
73
+ status: 'aborted',
74
+ reason: plan.reason,
75
+ toolCallId: target.toolCallId,
76
+ unsafe: plan.unsafe,
77
+ }, `Refusing to undo ${target.toolCallId}: ${plan.reason}`);
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const restored = [];
82
+ for (const step of plan.steps) {
83
+ try {
84
+ executeRevert(ctx.workspaceRoot, step);
85
+ restored.push({ path: step.path, operation: step.operation });
86
+ }
87
+ catch (error) {
88
+ // A revert failed after pre-flight said it was safe. Surface the
89
+ // error and bail out — the spec says no partial state on failure.
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ ctx.writeOutput({
92
+ command: 'undo',
93
+ status: 'failed',
94
+ reason: message,
95
+ toolCallId: target.toolCallId,
96
+ restored,
97
+ failedAt: step.path,
98
+ }, `Undo failed mid-flight on ${step.path}: ${message}`);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ }
103
+ // Audit the inverse mutations so the event log explains the undo.
104
+ const toolCallId = recordToolCall(ctx.session, 'undo', `revert ${target.toolCallId}`);
105
+ for (const step of plan.steps) {
106
+ recordFileMutation(ctx.session, {
107
+ toolCallId,
108
+ path: step.path,
109
+ operation: step.operation === 'create' ? 'delete' : 'update',
110
+ beforeHash: step.beforeHash,
111
+ afterHash: step.afterHash,
112
+ });
113
+ }
114
+ recordToolResult(ctx.session, toolCallId, 'success', `Undid ${restored.length} mutation(s) from ${target.toolCallId}`);
115
+ ctx.writeOutput({
116
+ command: 'undo',
117
+ status: 'ok',
118
+ toolCallId: target.toolCallId,
119
+ restored,
120
+ }, [
121
+ `Undid ${restored.length} mutation(s) from ${target.toolCallId}:`,
122
+ ...restored.map((entry) => ` ${entry.operation.padEnd(7)} ${entry.path}`),
123
+ ].join('\n'));
124
+ }
125
+ function parseEvents(eventsPath) {
126
+ const raw = readFileSync(eventsPath, 'utf8');
127
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
128
+ const out = [];
129
+ for (const line of lines) {
130
+ try {
131
+ const parsed = JSON.parse(line);
132
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
133
+ out.push(parsed);
134
+ }
135
+ }
136
+ catch {
137
+ // Drop malformed lines silently — partial writes mid-shutdown can
138
+ // produce them and undo should still work for the rest.
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ function findLastMutationTurn(events) {
144
+ const toolCalls = new Map();
145
+ const results = [];
146
+ for (let i = 0; i < events.length; i += 1) {
147
+ const event = events[i];
148
+ if (!event)
149
+ continue;
150
+ if (event.type === 'tool_call' && typeof event.id === 'string' && typeof event.tool === 'string') {
151
+ toolCalls.set(event.id, { id: event.id, tool: event.tool });
152
+ }
153
+ else if (event.type === 'tool_result' &&
154
+ typeof event.id === 'string' &&
155
+ typeof event.toolCallId === 'string' &&
156
+ (event.status === 'success' || event.status === 'error' || event.status === 'cancelled')) {
157
+ results.push({
158
+ id: event.id,
159
+ toolCallId: event.toolCallId,
160
+ status: event.status,
161
+ index: i,
162
+ });
163
+ }
164
+ }
165
+ for (let i = results.length - 1; i >= 0; i -= 1) {
166
+ const result = results[i];
167
+ if (result.status !== 'success')
168
+ continue;
169
+ const call = toolCalls.get(result.toolCallId);
170
+ if (!call)
171
+ continue;
172
+ if (!MUTATING_TOOLS.has(call.tool))
173
+ continue;
174
+ // Collect file_mutation events whose toolCallId matches. We do NOT
175
+ // window by event index: M1 emits the file_mutation alongside the
176
+ // tool_result, but a future iteration that emits them earlier
177
+ // (e.g. mid-stream for multi_edit) still maps correctly.
178
+ const mutations = [];
179
+ for (const event of events) {
180
+ if (event.type !== 'file_mutation')
181
+ continue;
182
+ if (event.toolCallId !== result.toolCallId)
183
+ continue;
184
+ if (typeof event.path !== 'string')
185
+ continue;
186
+ if (event.operation !== 'create' &&
187
+ event.operation !== 'update' &&
188
+ event.operation !== 'delete' &&
189
+ event.operation !== 'move') {
190
+ continue;
191
+ }
192
+ mutations.push({
193
+ toolCallId: result.toolCallId,
194
+ path: event.path,
195
+ operation: event.operation,
196
+ beforeHash: typeof event.beforeHash === 'string' ? event.beforeHash : undefined,
197
+ afterHash: typeof event.afterHash === 'string' ? event.afterHash : undefined,
198
+ });
199
+ }
200
+ return {
201
+ toolCallId: result.toolCallId,
202
+ resultIndex: result.index,
203
+ tool: call.tool,
204
+ mutations,
205
+ };
206
+ }
207
+ return null;
208
+ }
209
+ function planReverts(root, mutations) {
210
+ const steps = [];
211
+ const unsafe = [];
212
+ for (const mutation of mutations) {
213
+ const abs = resolve(root, mutation.path);
214
+ switch (mutation.operation) {
215
+ case 'create': {
216
+ if (!existsSync(abs)) {
217
+ // Already gone — nothing to undo for this entry.
218
+ continue;
219
+ }
220
+ const current = readFileSync(abs, 'utf8');
221
+ const currentHash = hashContent(current);
222
+ if (!mutation.afterHash || currentHash !== mutation.afterHash) {
223
+ unsafe.push(`${mutation.path}: created by Pugi, then modified by the user — refusing to delete uncommitted work`);
224
+ continue;
225
+ }
226
+ steps.push({
227
+ path: mutation.path,
228
+ operation: 'create',
229
+ beforeHash: mutation.afterHash,
230
+ afterHash: undefined,
231
+ });
232
+ break;
233
+ }
234
+ case 'update': {
235
+ if (!existsSync(abs)) {
236
+ unsafe.push(`${mutation.path}: file expected to exist for update revert, not found`);
237
+ continue;
238
+ }
239
+ const current = readFileSync(abs, 'utf8');
240
+ const currentHash = hashContent(current);
241
+ if (!mutation.afterHash || currentHash !== mutation.afterHash) {
242
+ unsafe.push(`${mutation.path}: updated by Pugi, then modified by the user — refusing to overwrite uncommitted work`);
243
+ continue;
244
+ }
245
+ const headContent = readHead(root, mutation.path);
246
+ if (headContent === null) {
247
+ unsafe.push(`${mutation.path}: no git HEAD version available for revert`);
248
+ continue;
249
+ }
250
+ if (mutation.beforeHash && hashContent(headContent) !== mutation.beforeHash) {
251
+ unsafe.push(`${mutation.path}: git HEAD differs from the pre-mutation hash — refusing to restore an unverifiable version`);
252
+ continue;
253
+ }
254
+ steps.push({
255
+ path: mutation.path,
256
+ operation: 'update',
257
+ beforeHash: mutation.afterHash,
258
+ afterHash: mutation.beforeHash,
259
+ restoreContent: headContent,
260
+ });
261
+ break;
262
+ }
263
+ case 'delete': {
264
+ const headContent = readHead(root, mutation.path);
265
+ if (headContent === null) {
266
+ unsafe.push(`${mutation.path}: deleted file has no git HEAD version, cannot restore`);
267
+ continue;
268
+ }
269
+ if (mutation.beforeHash && hashContent(headContent) !== mutation.beforeHash) {
270
+ unsafe.push(`${mutation.path}: git HEAD differs from the pre-deletion hash, cannot restore safely`);
271
+ continue;
272
+ }
273
+ steps.push({
274
+ path: mutation.path,
275
+ operation: 'delete',
276
+ beforeHash: undefined,
277
+ afterHash: mutation.beforeHash,
278
+ restoreContent: headContent,
279
+ });
280
+ break;
281
+ }
282
+ case 'move':
283
+ // Move reverts are not in M1 scope — the tool layer does not yet
284
+ // emit `move` mutations. Flag as unsafe so a future operator who
285
+ // hits this gets a clear failure instead of partial revert.
286
+ unsafe.push(`${mutation.path}: move undo is not supported in M1`);
287
+ break;
288
+ }
289
+ }
290
+ if (unsafe.length > 0) {
291
+ return {
292
+ aborted: true,
293
+ reason: 'one or more files are unsafe to revert',
294
+ unsafe,
295
+ steps: [],
296
+ };
297
+ }
298
+ return { aborted: false, steps };
299
+ }
300
+ function executeRevert(root, step) {
301
+ const abs = resolve(root, step.path);
302
+ if (step.operation === 'create') {
303
+ // We previously created the file. Reverting means deleting it.
304
+ unlinkSync(abs);
305
+ return;
306
+ }
307
+ if (step.restoreContent === undefined) {
308
+ throw new Error(`internal: restoreContent missing for ${step.path}`);
309
+ }
310
+ // Atomic write via tmp+rename — same pattern as file-tools.writeTool.
311
+ const tmp = `${abs}.pugi-undo-${Date.now()}`;
312
+ writeFileSync(tmp, step.restoreContent, { encoding: 'utf8', mode: 0o600 });
313
+ renameSync(tmp, abs);
314
+ }
315
+ function readHead(root, path) {
316
+ try {
317
+ const out = execFileSync('git', ['show', `HEAD:${path}`], {
318
+ cwd: root,
319
+ encoding: 'utf8',
320
+ stdio: ['ignore', 'pipe', 'ignore'],
321
+ maxBuffer: 64 * 1024 * 1024,
322
+ });
323
+ return out;
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ //# sourceMappingURL=undo.js.map