@pugi/cli 0.1.0-beta.24 → 0.1.0-beta.26

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 (41) hide show
  1. package/dist/core/checkpoint/resumer.js +149 -0
  2. package/dist/core/checkpoint/rewinder.js +291 -0
  3. package/dist/core/compact/summarizer.js +12 -0
  4. package/dist/core/dispatch/cache-cleanup.js +197 -0
  5. package/dist/core/dispatch/cache-handoff.js +295 -0
  6. package/dist/core/engine/native-pugi.js +67 -3
  7. package/dist/core/engine/tool-bridge.js +123 -3
  8. package/dist/core/hooks/events.js +44 -0
  9. package/dist/core/hooks/index.js +15 -0
  10. package/dist/core/hooks/registry.js +213 -0
  11. package/dist/core/hooks/runner.js +236 -0
  12. package/dist/core/lsp/cache.js +105 -0
  13. package/dist/core/lsp/language-detect.js +66 -0
  14. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  15. package/dist/core/memory-sync/queue.js +158 -0
  16. package/dist/core/memory-sync/queue.spec.js +105 -0
  17. package/dist/core/repl/session.js +73 -1
  18. package/dist/core/repl/slash-commands.js +20 -0
  19. package/dist/core/repl/store/session-store.js +31 -2
  20. package/dist/core/repo-map/build.js +125 -0
  21. package/dist/core/repo-map/cache.js +185 -0
  22. package/dist/core/repo-map/extractor.js +254 -0
  23. package/dist/core/repo-map/formatter.js +145 -0
  24. package/dist/core/repo-map/scanner.js +211 -0
  25. package/dist/core/session.js +44 -0
  26. package/dist/core/settings.js +9 -0
  27. package/dist/core/telemetry/emitter.js +229 -0
  28. package/dist/core/telemetry/queue.js +251 -0
  29. package/dist/runtime/cli.js +216 -0
  30. package/dist/runtime/commands/dispatch.js +126 -0
  31. package/dist/runtime/commands/hooks.js +184 -0
  32. package/dist/runtime/commands/lsp.js +25 -23
  33. package/dist/runtime/commands/memory.js +508 -0
  34. package/dist/runtime/commands/memory.spec.js +174 -0
  35. package/dist/runtime/commands/repo-map.js +95 -0
  36. package/dist/runtime/commands/resume.js +118 -0
  37. package/dist/runtime/commands/rewind.js +333 -0
  38. package/dist/runtime/commands/sessions.js +163 -0
  39. package/dist/runtime/version.js +1 -1
  40. package/dist/tools/agent-tool.js +23 -0
  41. package/package.json +2 -2
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Post-edit diagnostics — Leak L15.
3
+ *
4
+ * Claude Code's leak intel surfaced this pattern: after a `FileEdit` /
5
+ * `Write` tool call lands, an LSP diagnostic pass runs on the touched
6
+ * file and the result is appended to the tool envelope before the
7
+ * model sees it. The model then self-corrects in the same turn —
8
+ * "TS2304: Cannot find name 'undef'" comes back, the model fixes the
9
+ * typo in the next tool call, no operator round-trip needed.
10
+ *
11
+ * This module is the Pugi side of that pattern:
12
+ *
13
+ * 1. The tool-bridge calls `runPostEditDiagnostics(path, ctx)` after
14
+ * a successful `edit` / `write` / `multi_edit`.
15
+ * 2. We infer the language from the extension (`language-detect`).
16
+ * Unsupported extension → `{ skip: true }` and the bridge appends
17
+ * nothing.
18
+ * 3. We borrow (or lazily start) the per-language cached client
19
+ * from `cache.ts`. A spawn failure → `{ skip: true }` and the
20
+ * envelope stays clean. Silence on failure is intentional: an
21
+ * operator who has not installed `typescript-language-server`
22
+ * should not see an LSP nag on every edit.
23
+ * 4. We pull diagnostics with a hard 5s ceiling. A timeout logs a
24
+ * warning on stderr (gated on `PUGI_LSP_DEBUG=1`) and skips —
25
+ * the envelope is never blocked on LSP.
26
+ * 5. We format the surviving diagnostics into a readable tail
27
+ * mirroring the leak format:
28
+ *
29
+ * LSP DIAGNOSTICS (typescript):
30
+ * foo.ts:42:5 error TS2304: Cannot find name 'undef'.
31
+ * foo.ts:51:1 warn TS6133: 'unused' is declared.
32
+ *
33
+ * The bridge concatenates this tail onto its existing `wrote ...` /
34
+ * `edited ...` body with a single newline separator. When there are
35
+ * zero diagnostics we return `{ skip: true }` so the existing body
36
+ * is unchanged — the "no news is good news" path stays terse.
37
+ *
38
+ * Brand voice: ASCII only, no emoji, no banned words.
39
+ */
40
+ import { isAbsolute, relative, resolve } from 'node:path';
41
+ import { getOrStartLspClient } from './cache.js';
42
+ import { languageForFile } from './language-detect.js';
43
+ const DEFAULT_TIMEOUT_MS = 5_000;
44
+ /**
45
+ * Hard cap on how many diagnostics we surface to the model. A file
46
+ * with 200 errors after a broken bulk edit would otherwise blow the
47
+ * context window; the model can re-run `pugi lsp diagnostics` if
48
+ * it needs the full list.
49
+ */
50
+ const MAX_DIAGNOSTICS = 25;
51
+ export async function runPostEditDiagnostics(filePath, opts) {
52
+ const lang = languageForFile(filePath);
53
+ if (!lang) {
54
+ return { skip: true, reason: 'unsupported_language' };
55
+ }
56
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
57
+ const clientResult = await loadClient(lang, opts);
58
+ if (!clientResult.ok) {
59
+ return { skip: true, reason: mapStartFailure(clientResult.reason) };
60
+ }
61
+ // Run diagnostics with a hard timeout. The underlying LspClient has
62
+ // its own per-request timeout (5s default) but a slow handshake
63
+ // can blow past it; we belt-and-suspenders here so the agent loop
64
+ // never blocks on LSP.
65
+ const relPath = toWorkspaceRelative(filePath, opts.cwd);
66
+ const diagnosticsPromise = clientResult.client.diagnostics(relPath);
67
+ let timer;
68
+ const timeoutPromise = new Promise((resolveFn) => {
69
+ timer = setTimeout(() => resolveFn({ timedOut: true }), timeoutMs);
70
+ timer.unref();
71
+ });
72
+ const race = await Promise.race([
73
+ diagnosticsPromise.then((value) => ({ timedOut: false, value })),
74
+ timeoutPromise,
75
+ ]);
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ if (race.timedOut) {
79
+ const writeFn = opts.debugWrite ?? ((line) => {
80
+ if (process.env.PUGI_LSP_DEBUG === '1')
81
+ process.stderr.write(`${line}\n`);
82
+ });
83
+ writeFn(`[pugi-lsp] post-edit diagnostics for ${relPath} timed out after ${timeoutMs}ms (lang=${lang})`);
84
+ return { skip: true, reason: 'timeout' };
85
+ }
86
+ const diag = race.value;
87
+ if (!diag.ok) {
88
+ return { skip: true, reason: 'lsp_error' };
89
+ }
90
+ if (diag.value.length === 0) {
91
+ return { skip: true, reason: 'no_diagnostics' };
92
+ }
93
+ const tail = formatDiagnosticsTail(relPath, lang, diag.value);
94
+ return { skip: false, tail, count: diag.value.length, language: lang };
95
+ }
96
+ async function loadClient(lang, opts) {
97
+ if (opts.clientLoader) {
98
+ return opts.clientLoader(lang);
99
+ }
100
+ const { cwd, timeoutMs, clientLoader: _ignoredA, debugWrite: _ignoredB, ...rest } = opts;
101
+ const result = await getOrStartLspClient(lang, { cwd, ...rest });
102
+ if (!result.ok) {
103
+ return { ok: false, reason: result.reason, detail: result.detail };
104
+ }
105
+ return { ok: true, client: result.client };
106
+ }
107
+ function mapStartFailure(reason) {
108
+ if (reason === 'lsp_unavailable' || reason === 'language_unsupported')
109
+ return 'lsp_unavailable';
110
+ if (reason === 'lsp_disabled')
111
+ return 'lsp_disabled';
112
+ return 'lsp_error';
113
+ }
114
+ /**
115
+ * Convert an absolute or workspace-relative path into the form the
116
+ * LSP client expects — same shape as `runtime/commands/lsp.ts` uses.
117
+ */
118
+ function toWorkspaceRelative(filePath, cwd) {
119
+ if (!isAbsolute(filePath))
120
+ return filePath;
121
+ const rel = relative(cwd, resolve(cwd, filePath));
122
+ return rel || filePath;
123
+ }
124
+ /**
125
+ * Format diagnostics into the leak-shaped envelope tail. Pure function
126
+ * exported for unit tests to assert the line format independent of
127
+ * any LSP plumbing.
128
+ */
129
+ export function formatDiagnosticsTail(relPath, lang, diagnostics) {
130
+ const visible = diagnostics.slice(0, MAX_DIAGNOSTICS);
131
+ const truncated = diagnostics.length > visible.length;
132
+ const lines = [`LSP DIAGNOSTICS (${LANGUAGE_LABELS[lang]}):`];
133
+ for (const diag of visible) {
134
+ const line = diag.range.start.line + 1; // LSP is zero-based; humans expect 1-based.
135
+ const col = diag.range.start.character + 1;
136
+ const severity = SEVERITY_LABELS[diag.severityLabel];
137
+ const code = diag.code !== undefined && diag.code !== '' ? ` ${diag.code}` : '';
138
+ const source = diag.source ? `${diag.source}` : '';
139
+ const head = source ? `${severity}${code} (${source}):` : `${severity}${code}:`;
140
+ lines.push(` ${relPath}:${line}:${col} ${head} ${diag.message}`);
141
+ }
142
+ if (truncated) {
143
+ lines.push(` ... ${diagnostics.length - visible.length} more diagnostic(s) — re-run pugi lsp diagnostics ${relPath} for the full list`);
144
+ }
145
+ return lines.join('\n');
146
+ }
147
+ const LANGUAGE_LABELS = {
148
+ ts: 'typescript',
149
+ js: 'javascript',
150
+ py: 'python',
151
+ go: 'go',
152
+ rust: 'rust',
153
+ };
154
+ /**
155
+ * Map LSP severity label → the short token the leak envelope uses.
156
+ * "warn" is shorter than "warning" and matches Claude Code's leak
157
+ * verbatim; the rest mirror LSP terminology.
158
+ */
159
+ const SEVERITY_LABELS = {
160
+ error: 'error',
161
+ warning: 'warn ',
162
+ info: 'info ',
163
+ hint: 'hint ',
164
+ };
165
+ /** Test-only surface so specs can poke the pure helpers without LSP. */
166
+ export const __test__ = {
167
+ formatDiagnosticsTail,
168
+ MAX_DIAGNOSTICS,
169
+ DEFAULT_TIMEOUT_MS,
170
+ };
171
+ //# sourceMappingURL=post-edit-diagnostics.js.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Pugi memory sync queue (ADR-0063 Day 4).
3
+ *
4
+ * Local pending-write queue for `pugi memory` commands when the
5
+ * operator is offline or the admin-api is unreachable. Each pending
6
+ * mutation lands on disk as one JSONL line; `pugi memory sync` reads
7
+ * the queue, fires them to the admin-api in order, and rewrites the
8
+ * file with only the entries that still failed.
9
+ *
10
+ * Storage:
11
+ *
12
+ * ~/.pugi/memory-queue.jsonl (mode 0600)
13
+ *
14
+ * Each line is a fully-typed `PendingMemoryOperation` envelope. The
15
+ * envelope is forward-compatible: an older CLI reading a JSONL file
16
+ * written by a newer CLI silently skips lines whose `op` field is
17
+ * not in its known set (so a partial-rollback scenario doesn't crash
18
+ * the queue).
19
+ *
20
+ * Design intent:
21
+ * - Append-only on disk for the hot path (`pugi memory write` /
22
+ * `pugi memory forget` queue when offline). Rewrites only on
23
+ * successful sync.
24
+ * - One file per operator (PUGI_HOME-aware). Queue is local to the
25
+ * machine — no cross-host coordination. Multi-device sync is
26
+ * deferred to Phase 6 (server-side outbox).
27
+ * - No fsync / atomic rename ceremony in v1 — best effort. The
28
+ * queue is a convenience surface, not a durability primitive;
29
+ * the source of truth is the admin-api row.
30
+ */
31
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, resolve } from 'node:path';
34
+ import { z } from 'zod';
35
+ /** Six canonical kinds — must mirror `apps/admin-api/src/persona-memory/persona-memory.types.ts`. */
36
+ export const PERSONA_MEMORY_KINDS = [
37
+ 'pattern',
38
+ 'preference',
39
+ 'architecture',
40
+ 'bug',
41
+ 'workflow',
42
+ 'fact',
43
+ ];
44
+ const writeOpSchema = z.object({
45
+ op: z.literal('write'),
46
+ enqueuedAt: z.string().datetime(),
47
+ personaSlug: z.string().min(1).max(64),
48
+ kind: z.enum(PERSONA_MEMORY_KINDS),
49
+ content: z.string().min(1).max(4000),
50
+ forgetAfter: z.string().datetime().nullable().optional(),
51
+ });
52
+ const forgetOpSchema = z.object({
53
+ op: z.literal('forget'),
54
+ enqueuedAt: z.string().datetime(),
55
+ id: z.string().min(1),
56
+ });
57
+ const pendingMemoryOpSchema = z.discriminatedUnion('op', [
58
+ writeOpSchema,
59
+ forgetOpSchema,
60
+ ]);
61
+ /** Default storage path. Override via `PUGI_HOME` for tests / multi-account. */
62
+ export function defaultQueuePath() {
63
+ const root = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
64
+ return resolve(root, 'memory-queue.jsonl');
65
+ }
66
+ /**
67
+ * Append one pending operation to the queue file. Creates the parent
68
+ * directory + file with mode 0600 if missing. Pure-disk, no network.
69
+ *
70
+ * Returns the count of pending ops after the append (1-based) so the
71
+ * CLI command can render "queued (3 pending) — run `pugi memory sync`".
72
+ */
73
+ export function enqueueMemoryOp(op, pathOverride) {
74
+ const fullOp = {
75
+ ...op,
76
+ enqueuedAt: new Date().toISOString(),
77
+ };
78
+ pendingMemoryOpSchema.parse(fullOp);
79
+ const queuePath = pathOverride ?? defaultQueuePath();
80
+ ensureQueueFile(queuePath);
81
+ const existing = readFileSync(queuePath, 'utf-8');
82
+ const line = `${JSON.stringify(fullOp)}\n`;
83
+ writeFileSync(queuePath, `${existing}${line}`, { encoding: 'utf-8', mode: 0o600 });
84
+ // Best-effort chmod (in case the file existed already at the wrong mode).
85
+ try {
86
+ chmodSync(queuePath, 0o600);
87
+ }
88
+ catch {
89
+ // ignore — the file was just written above, mode might be platform-dependent.
90
+ }
91
+ return countPending(queuePath);
92
+ }
93
+ /** Read the queue file and return parsed entries. Skips unknown / malformed lines. */
94
+ export function readMemoryQueue(pathOverride) {
95
+ const queuePath = pathOverride ?? defaultQueuePath();
96
+ if (!existsSync(queuePath))
97
+ return [];
98
+ const raw = readFileSync(queuePath, 'utf-8');
99
+ const out = [];
100
+ for (const line of raw.split(/\r?\n/)) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed)
103
+ continue;
104
+ try {
105
+ const parsed = pendingMemoryOpSchema.parse(JSON.parse(trimmed));
106
+ out.push(parsed);
107
+ }
108
+ catch {
109
+ // forward-compat: a future op kind we don't recognise should not
110
+ // crash the queue; just drop the line during this read.
111
+ continue;
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ /** Rewrite the queue file with `remaining` entries only. Empty array clears the file. */
117
+ export function rewriteMemoryQueue(remaining, pathOverride) {
118
+ const queuePath = pathOverride ?? defaultQueuePath();
119
+ ensureQueueFile(queuePath);
120
+ if (remaining.length === 0) {
121
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
122
+ return;
123
+ }
124
+ const body = remaining.map((op) => JSON.stringify(op)).join('\n') + '\n';
125
+ writeFileSync(queuePath, body, { encoding: 'utf-8', mode: 0o600 });
126
+ }
127
+ /** Count pending ops without re-parsing every line individually for the typed shape. */
128
+ export function countPending(pathOverride) {
129
+ const queuePath = pathOverride ?? defaultQueuePath();
130
+ if (!existsSync(queuePath))
131
+ return 0;
132
+ const raw = readFileSync(queuePath, 'utf-8');
133
+ let n = 0;
134
+ for (const line of raw.split(/\r?\n/)) {
135
+ if (line.trim().length > 0)
136
+ n++;
137
+ }
138
+ return n;
139
+ }
140
+ /** Quick predicate — was anything ever queued? */
141
+ export function hasPendingOps(pathOverride) {
142
+ return countPending(pathOverride) > 0;
143
+ }
144
+ function ensureQueueFile(queuePath) {
145
+ const dir = dirname(queuePath);
146
+ if (!existsSync(dir))
147
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ if (!existsSync(queuePath)) {
149
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
150
+ try {
151
+ chmodSync(queuePath, 0o600);
152
+ }
153
+ catch {
154
+ // ignore
155
+ }
156
+ }
157
+ }
158
+ //# sourceMappingURL=queue.js.map
@@ -0,0 +1,105 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { afterEach, beforeEach, describe, it } from 'node:test';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { countPending, enqueueMemoryOp, hasPendingOps, readMemoryQueue, rewriteMemoryQueue, } from './queue.js';
7
+ let tmpRoot = '';
8
+ let queuePath = '';
9
+ beforeEach(() => {
10
+ tmpRoot = mkdtempSync(resolve(tmpdir(), 'pugi-memory-queue-'));
11
+ queuePath = resolve(tmpRoot, 'memory-queue.jsonl');
12
+ });
13
+ afterEach(() => {
14
+ try {
15
+ rmSync(tmpRoot, { recursive: true, force: true });
16
+ }
17
+ catch {
18
+ // ignore
19
+ }
20
+ });
21
+ describe('memory-sync queue', () => {
22
+ it('countPending returns 0 for missing file', () => {
23
+ assert.equal(countPending(queuePath), 0);
24
+ assert.equal(hasPendingOps(queuePath), false);
25
+ });
26
+ it('enqueueMemoryOp appends a write op and returns 1', () => {
27
+ const n = enqueueMemoryOp({
28
+ op: 'write',
29
+ personaSlug: 'mira',
30
+ kind: 'preference',
31
+ content: 'operator prefers pnpm',
32
+ }, queuePath);
33
+ assert.equal(n, 1);
34
+ assert.equal(hasPendingOps(queuePath), true);
35
+ });
36
+ it('enqueueMemoryOp appends multiple ops sequentially', () => {
37
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
38
+ enqueueMemoryOp({ op: 'forget', id: 'mem-abc' }, queuePath);
39
+ assert.equal(countPending(queuePath), 2);
40
+ });
41
+ it('readMemoryQueue returns parsed entries in order', () => {
42
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'workflow', content: 'first' }, queuePath);
43
+ enqueueMemoryOp({ op: 'forget', id: 'mem-1' }, queuePath);
44
+ const ops = readMemoryQueue(queuePath);
45
+ assert.equal(ops.length, 2);
46
+ assert.equal(ops[0]?.op, 'write');
47
+ if (ops[0]?.op === 'write')
48
+ assert.equal(ops[0].content, 'first');
49
+ assert.equal(ops[1]?.op, 'forget');
50
+ });
51
+ it('readMemoryQueue skips malformed lines without crashing', () => {
52
+ writeFileSync(queuePath, [
53
+ JSON.stringify({
54
+ op: 'write',
55
+ personaSlug: 'mira',
56
+ kind: 'fact',
57
+ content: 'a',
58
+ enqueuedAt: new Date().toISOString(),
59
+ }),
60
+ '{not valid json',
61
+ JSON.stringify({ op: 'future_op', whatever: true }),
62
+ ].join('\n'));
63
+ const ops = readMemoryQueue(queuePath);
64
+ assert.equal(ops.length, 1);
65
+ });
66
+ it('rewriteMemoryQueue with empty array clears the file', () => {
67
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
68
+ rewriteMemoryQueue([], queuePath);
69
+ assert.equal(countPending(queuePath), 0);
70
+ const raw = readFileSync(queuePath, 'utf-8');
71
+ assert.equal(raw, '');
72
+ });
73
+ it('rewriteMemoryQueue with remaining entries persists them', () => {
74
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
75
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'b' }, queuePath);
76
+ const all = readMemoryQueue(queuePath);
77
+ rewriteMemoryQueue([all[1]], queuePath);
78
+ const after = readMemoryQueue(queuePath);
79
+ assert.equal(after.length, 1);
80
+ if (after[0]?.op === 'write')
81
+ assert.equal(after[0].content, 'b');
82
+ });
83
+ it('enqueueMemoryOp rejects an invalid kind via Zod', () => {
84
+ assert.throws(() => enqueueMemoryOp({
85
+ op: 'write',
86
+ personaSlug: 'mira',
87
+ // @ts-expect-error — intentional bad value
88
+ kind: 'whatever',
89
+ content: 'a',
90
+ }, queuePath));
91
+ });
92
+ it('enqueueMemoryOp rejects oversized content (>4000 chars)', () => {
93
+ assert.throws(() => enqueueMemoryOp({
94
+ op: 'write',
95
+ personaSlug: 'mira',
96
+ kind: 'fact',
97
+ content: 'x'.repeat(4001),
98
+ }, queuePath));
99
+ });
100
+ it('countPending counts non-empty lines only', () => {
101
+ writeFileSync(queuePath, 'line1\n\n\nline2\n');
102
+ assert.equal(countPending(queuePath), 2);
103
+ });
104
+ });
105
+ //# sourceMappingURL=queue.spec.js.map
@@ -36,6 +36,7 @@ import { webFetchTool } from '../../tools/web-fetch.js';
36
36
  import { loadSettings } from '../settings.js';
37
37
  import { getJobRegistry } from '../jobs/registry.js';
38
38
  import { applyCompactMask } from '../compact/buffer-rewriter.js';
39
+ import { applyRewindMask } from '../checkpoint/rewinder.js';
39
40
  import { evaluateAutoCompact } from '../compact/auto-trigger.js';
40
41
  import { estimateTokensInMany } from '../compact/token-counter.js';
41
42
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
@@ -1015,6 +1016,37 @@ export class ReplSession {
1015
1016
  await this.dispatchCompact('manual');
1016
1017
  return verdict;
1017
1018
  }
1019
+ case 'rewind': {
1020
+ // Leak L9 (2026-05-27): /rewind appends an append-only
1021
+ // tombstone marker that rolls the conversation back to a
1022
+ // checkpoint. The actual replay-mask is advisory — the on-disk
1023
+ // events stay durable so `pugi sessions undo-rewind` can
1024
+ // reverse the operation. We forward to the same runner the
1025
+ // top-level `pugi rewind` command uses to keep the surface
1026
+ // single-sourced. Dynamic import avoids pulling the checkpoint
1027
+ // graph into the dispatcher at module load.
1028
+ if (!this.store || !this.localSessionId) {
1029
+ this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
1030
+ return verdict;
1031
+ }
1032
+ try {
1033
+ const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
1034
+ await runRewindCommand(verdict.args, {
1035
+ workspaceRoot: process.cwd(),
1036
+ sessionId: this.localSessionId,
1037
+ store: this.store,
1038
+ writeOutput: (_payload, text) => {
1039
+ if (text.length > 0)
1040
+ this.appendSystemLine(text);
1041
+ },
1042
+ });
1043
+ }
1044
+ catch (error) {
1045
+ const message = error instanceof Error ? error.message : String(error);
1046
+ this.appendSystemLine(`/rewind failed: ${message}`);
1047
+ }
1048
+ return verdict;
1049
+ }
1018
1050
  case 'share': {
1019
1051
  // Leak L20 (2026-05-27): /share forwards to the same runner the
1020
1052
  // top-level `pugi share` command uses. The session module
@@ -1233,6 +1265,41 @@ export class ReplSession {
1233
1265
  }
1234
1266
  return verdict;
1235
1267
  }
1268
+ case 'repo-map': {
1269
+ // Leak L28 (2026-05-27): AST-light workspace summary. Delegate
1270
+ // к the shared `runRepoMapCommand` so the slash + top-level
1271
+ // paths stay single-sourced. The rendered text lands on the
1272
+ // system pane via `appendSystemLine` (no fresh Ink mount) so
1273
+ // the listing flows into the conversation transcript like
1274
+ // any other command output.
1275
+ try {
1276
+ const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
1277
+ const lines = [];
1278
+ await runRepoMapCommand({
1279
+ cwd: process.cwd(),
1280
+ refresh: verdict.refresh,
1281
+ json: false,
1282
+ writeOutput: (_payload, text) => {
1283
+ for (const line of text.split('\n')) {
1284
+ const trimmed = line.replace(/\s+$/u, '');
1285
+ lines.push(trimmed);
1286
+ }
1287
+ },
1288
+ });
1289
+ if (lines.length === 0) {
1290
+ this.appendSystemLine('/repo-map: no output.');
1291
+ }
1292
+ else {
1293
+ for (const line of lines)
1294
+ this.appendSystemLine(line);
1295
+ }
1296
+ }
1297
+ catch (error) {
1298
+ const message = error instanceof Error ? error.message : String(error);
1299
+ this.appendSystemLine(`/repo-map failed: ${message}`);
1300
+ }
1301
+ return verdict;
1302
+ }
1236
1303
  case 'stub': {
1237
1304
  this.appendSystemLine(verdict.message);
1238
1305
  return verdict;
@@ -2997,7 +3064,12 @@ export class ReplSession {
2997
3064
  // condensed into the boundary's `keptTailTurns + marker` slice so
2998
3065
  // the post-resume transcript starts at the most-recent context
2999
3066
  // floor rather than re-playing the full pre-compaction history.
3000
- const masked = applyCompactMask(events);
3067
+ //
3068
+ // Leak L9 (2026-05-27): then apply rewind-marker masking. Any
3069
+ // event inside an active rewind range is stripped from the
3070
+ // visible transcript; the on-disk events stay durable so a
3071
+ // follow-up `pugi sessions undo-rewind` can restore them.
3072
+ const masked = applyRewindMask(applyCompactMask(events));
3001
3073
  const rows = [];
3002
3074
  for (const event of masked) {
3003
3075
  const row = eventToTranscriptRow(event);
@@ -66,6 +66,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
66
66
  { name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
67
67
  { name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
68
68
  { name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
69
+ { name: 'rewind', args: '[N | --to <id>]', gloss: 'Roll the conversation back to a checkpoint (leak L9)', group: 'Session' },
69
70
  { name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
70
71
  { name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
71
72
  // Pugi tools
@@ -75,6 +76,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
75
76
  { name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
76
77
  { name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
77
78
  { name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
79
+ { name: 'repo-map', args: '[refresh]', gloss: 'AST-light symbol summary of the workspace (leak L28)', group: 'Pugi tools' },
78
80
  // Settings
79
81
  { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
80
82
  { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
@@ -438,6 +440,15 @@ export function parseSlashCommand(input) {
438
440
  // fresh shell.
439
441
  return { kind: 'compact' };
440
442
  }
443
+ case 'rewind': {
444
+ // Leak L9 (2026-05-27): `/rewind [N | --to <id>]`. Tokenize the
445
+ // tail unchanged so `runRewindCommand` (in `runtime/commands/
446
+ // rewind.ts`) handles every mode (picker / turns / to-event)
447
+ // through one parser. The slash + top-level CLI surfaces stay
448
+ // single-sourced — same separation as `/compact`.
449
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
450
+ return { kind: 'rewind', args: tokens };
451
+ }
441
452
  case 'stickers': {
442
453
  // Leak L33 (2026-05-27): brand-personality gimmick. Tail args
443
454
  // are ignored — the surface is intentionally parameterless. The
@@ -462,6 +473,15 @@ export function parseSlashCommand(input) {
462
473
  const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
463
474
  return { kind: 'share', args: tokens };
464
475
  }
476
+ case 'repo-map':
477
+ case 'repomap': {
478
+ // Leak L28 (2026-05-27): build + show the AST-light symbol
479
+ // summary. Accepts `refresh` as a positional или `--refresh`
480
+ // flag so muscle memory from both shells lands the same way.
481
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
482
+ const refresh = tokens.includes('--refresh') || tokens.includes('refresh') || tokens.includes('-r');
483
+ return { kind: 'repo-map', refresh };
484
+ }
465
485
  case 'release-notes':
466
486
  case 'releasenotes':
467
487
  case 'changelog': {
@@ -361,7 +361,7 @@ export class SqliteSessionStore {
361
361
  // which maps to SQLITE_OPEN_READONLY. The option form is the
362
362
  // documented API; the file-URI form (file:...?mode=ro) also works.
363
363
  const db = new DatabaseSync(dbPath, { readOnly: true });
364
- return new SqliteSessionStoreReadOnlyView(db);
364
+ return new SqliteSessionStoreReadOnlyView(db, projectStoreDir);
365
365
  }
366
366
  /* ------------------------------------------------------------ */
367
367
  /* Internals */
@@ -584,8 +584,37 @@ export class SqliteSessionStore {
584
584
  */
585
585
  export class SqliteSessionStoreReadOnlyView {
586
586
  db;
587
- constructor(db) {
587
+ projectStoreDir;
588
+ constructor(db,
589
+ /**
590
+ * Project store directory — required for the JSONL event read path.
591
+ * L9 (2026-05-27): `/rewind` + `/resume` need to walk events from
592
+ * inside the read-only view so the rewind picker + resume preview
593
+ * never take the writer lockfile.
594
+ */
595
+ projectStoreDir) {
588
596
  this.db = db;
597
+ this.projectStoreDir = projectStoreDir;
598
+ }
599
+ /**
600
+ * Read every event for a session via the durable JSONL log. The
601
+ * SQLite cache is NOT used here — JSONL is the source of truth and
602
+ * the cache only holds counters. The walk stitches across rotation
603
+ * files (`events.<n>.jsonl`) in the same order `JsonlEventLog.read`
604
+ * uses inside the writer path so consumers see one consistent stream
605
+ * whether they came in via the writer store OR the read-only view.
606
+ */
607
+ async events(sessionId, opts) {
608
+ const sessionDir = resolve(this.projectStoreDir, 'sessions', sessionId);
609
+ if (!existsSync(sessionDir))
610
+ return [];
611
+ const log = new JsonlEventLog({ sessionDir });
612
+ try {
613
+ return log.read(opts);
614
+ }
615
+ finally {
616
+ log.close();
617
+ }
589
618
  }
590
619
  async list(opts) {
591
620
  const limit = clampLimit(opts?.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);