@pugi/cli 0.1.0-beta.25 → 0.1.0-beta.27
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/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/compact/summarizer.js +12 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/repl/session.js +82 -1
- package/dist/core/repl/slash-commands.js +19 -0
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/runtime/cli.js +190 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/prd-check.js +235 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +23 -0
- package/dist/tui/repl-splash-mascot.js +7 -19
- package/package.json +3 -3
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi prd-check` — Pugi α7 Wave 6 verified-deliverable gate (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Runs a PRD ↔ delivered-artifact verification sweep BEFORE the
|
|
5
|
+
* operator (or an autonomous agent) claims a feature is done.
|
|
6
|
+
* Reads `docs/prd/<feature>.md` (or any explicit path), parses the
|
|
7
|
+
* acceptance-criteria section, and runs file / test / doc / command
|
|
8
|
+
* / route verifiers per criterion. Output mirrors `pugi doctor`:
|
|
9
|
+
* either a plain-text table or a JSON envelope (`--json`).
|
|
10
|
+
*
|
|
11
|
+
* Module contract (mirrors L17 doctor split):
|
|
12
|
+
*
|
|
13
|
+
* - parser + verifiers + reporter are pure with respect to deps;
|
|
14
|
+
* this file is the THIN wiring that resolves cwd, glob-expands
|
|
15
|
+
* `--all`, loads each PRD, and forwards the structured result
|
|
16
|
+
* к the supplied writeOutput sink.
|
|
17
|
+
*
|
|
18
|
+
* - `runPrdCheckCommand` is the single entry point. Both the
|
|
19
|
+
* top-level `pugi prd-check` shell command AND the in-REPL
|
|
20
|
+
* `/prd-check` slash command call it. The function returns the
|
|
21
|
+
* `PrdCheckEnvelope[]` so the REPL can render via Ink without
|
|
22
|
+
* re-running the verification.
|
|
23
|
+
*
|
|
24
|
+
* - Exit codes follow `exitCodeFor` from the reporter:
|
|
25
|
+
* 0 — healthy (every criterion PASS or SKIPPED)
|
|
26
|
+
* 1 — failing (≥1 FAIL across the scanned PRDs)
|
|
27
|
+
* 2 — unparsed (≥1 PRD missing the acceptance section)
|
|
28
|
+
* When `--all` scans multiple PRDs the worst verdict wins (1 > 2 > 0).
|
|
29
|
+
*
|
|
30
|
+
* - The `knownCommands` set is sourced from the CLI registry — we
|
|
31
|
+
* accept it as an injected parameter so the spec can drive
|
|
32
|
+
* command-verification without importing the entire cli.ts
|
|
33
|
+
* module (which would pull the engine graph into the test).
|
|
34
|
+
*/
|
|
35
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
36
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
37
|
+
import { parsePrd } from '../../core/prd-check/parser.js';
|
|
38
|
+
import { buildEnvelope, exitCodeFor, renderTable, } from '../../core/prd-check/reporter.js';
|
|
39
|
+
import { createDefaultDeps, verifyAll, } from '../../core/prd-check/verifiers.js';
|
|
40
|
+
const DEFAULT_PRD_DIR = 'docs/prd';
|
|
41
|
+
/**
|
|
42
|
+
* Run the gate. Resolves which PRDs to inspect, runs the parser +
|
|
43
|
+
* verifiers + reporter chain per PRD, emits the combined output,
|
|
44
|
+
* and sets `process.exitCode` to the worst verdict across the set.
|
|
45
|
+
*/
|
|
46
|
+
export async function runPrdCheckCommand(ctx) {
|
|
47
|
+
const paths = resolveTargets(ctx);
|
|
48
|
+
if (paths.length === 0) {
|
|
49
|
+
const result = {
|
|
50
|
+
command: 'prd-check',
|
|
51
|
+
overall: 'unparsed',
|
|
52
|
+
envelopes: [],
|
|
53
|
+
};
|
|
54
|
+
ctx.writeOutput(result, 'No PRD files found.');
|
|
55
|
+
process.exitCode = exitCodeFor('unparsed');
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
const deps = ctx.deps ??
|
|
59
|
+
createDefaultDeps({
|
|
60
|
+
workspaceRoot: ctx.cwd,
|
|
61
|
+
knownCommands: ctx.knownCommands,
|
|
62
|
+
});
|
|
63
|
+
const envelopes = [];
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
envelopes.push(checkSinglePrd(path, ctx.cwd, deps));
|
|
66
|
+
}
|
|
67
|
+
const overall = combineOverall(envelopes.map((e) => e.overall));
|
|
68
|
+
const result = {
|
|
69
|
+
command: 'prd-check',
|
|
70
|
+
overall,
|
|
71
|
+
envelopes,
|
|
72
|
+
};
|
|
73
|
+
const text = renderRun(result, ctx.cwd);
|
|
74
|
+
ctx.writeOutput(result, text);
|
|
75
|
+
process.exitCode = exitCodeFor(overall);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render the combined plain-text view. Multi-PRD runs print one
|
|
80
|
+
* table per envelope separated by a divider; single-PRD runs print
|
|
81
|
+
* just the table to keep the output narrow.
|
|
82
|
+
*/
|
|
83
|
+
function renderRun(result, cwd) {
|
|
84
|
+
if (result.envelopes.length === 0) {
|
|
85
|
+
return 'No PRD files found.';
|
|
86
|
+
}
|
|
87
|
+
if (result.envelopes.length === 1) {
|
|
88
|
+
return renderTable(result.envelopes[0]);
|
|
89
|
+
}
|
|
90
|
+
const parts = [];
|
|
91
|
+
for (const envelope of result.envelopes) {
|
|
92
|
+
const relPath = relative(cwd, envelope.prdPath) || envelope.prdPath;
|
|
93
|
+
parts.push(renderTable({ ...envelope, prdPath: relPath }));
|
|
94
|
+
parts.push('');
|
|
95
|
+
}
|
|
96
|
+
parts.push('-'.repeat(50));
|
|
97
|
+
const summary = `${result.envelopes.length} PRD(s) scanned. Overall: ${result.overall.toUpperCase()}`;
|
|
98
|
+
parts.push(summary);
|
|
99
|
+
return parts.join('\n');
|
|
100
|
+
}
|
|
101
|
+
function checkSinglePrd(prdPath, workspaceRoot, deps) {
|
|
102
|
+
let source;
|
|
103
|
+
try {
|
|
104
|
+
source = readFileSync(prdPath, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return {
|
|
109
|
+
command: 'prd-check',
|
|
110
|
+
prdPath: relative(workspaceRoot, prdPath) || prdPath,
|
|
111
|
+
title: null,
|
|
112
|
+
overall: 'unparsed',
|
|
113
|
+
counts: { pass: 0, fail: 0, skipped: 0 },
|
|
114
|
+
criteria: [
|
|
115
|
+
{
|
|
116
|
+
index: 0,
|
|
117
|
+
text: `PRD file unreadable: ${message}`,
|
|
118
|
+
status: 'fail',
|
|
119
|
+
results: [],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const parsed = parsePrd(source);
|
|
125
|
+
const verified = verifyAll(parsed.criteria, deps);
|
|
126
|
+
return buildEnvelope({
|
|
127
|
+
prdPath: relative(workspaceRoot, prdPath) || prdPath,
|
|
128
|
+
title: parsed.title,
|
|
129
|
+
hasAcceptanceSection: parsed.hasAcceptanceSection,
|
|
130
|
+
verified,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function resolveTargets(ctx) {
|
|
134
|
+
if (ctx.flags.all) {
|
|
135
|
+
const prdDir = resolve(ctx.cwd, DEFAULT_PRD_DIR);
|
|
136
|
+
return listMarkdownFiles(prdDir);
|
|
137
|
+
}
|
|
138
|
+
if (ctx.prdPath) {
|
|
139
|
+
const absolute = isAbsolute(ctx.prdPath)
|
|
140
|
+
? ctx.prdPath
|
|
141
|
+
: resolve(ctx.cwd, ctx.prdPath);
|
|
142
|
+
return [absolute];
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function listMarkdownFiles(dir) {
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(dir);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const full = join(dir, entry);
|
|
157
|
+
let isDirectory = false;
|
|
158
|
+
try {
|
|
159
|
+
isDirectory = statSync(full).isDirectory();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (isDirectory) {
|
|
165
|
+
out.push(...listMarkdownFiles(full));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (entry.endsWith('.md')) {
|
|
169
|
+
out.push(full);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out.sort();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Combine per-PRD verdicts into the run-wide one. failing > unparsed > healthy.
|
|
176
|
+
* Exported for the spec.
|
|
177
|
+
*/
|
|
178
|
+
export function combineOverall(verdicts) {
|
|
179
|
+
if (verdicts.length === 0)
|
|
180
|
+
return 'unparsed';
|
|
181
|
+
if (verdicts.some((v) => v === 'failing'))
|
|
182
|
+
return 'failing';
|
|
183
|
+
if (verdicts.some((v) => v === 'unparsed'))
|
|
184
|
+
return 'unparsed';
|
|
185
|
+
return 'healthy';
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse the CLI argv tail. Accepts:
|
|
189
|
+
*
|
|
190
|
+
* pugi prd-check -> error (no target)
|
|
191
|
+
* pugi prd-check <path> -> single PRD
|
|
192
|
+
* pugi prd-check --all -> scan docs/prd/**.md
|
|
193
|
+
* pugi prd-check <path> --json -> single PRD, JSON envelope
|
|
194
|
+
*
|
|
195
|
+
* `--json` is also forwarded from the global flag set in cli.ts;
|
|
196
|
+
* the local parse re-honours it so the slash command can use the
|
|
197
|
+
* same parser without the global flag plumbing.
|
|
198
|
+
*/
|
|
199
|
+
export function parsePrdCheckArgs(args, options) {
|
|
200
|
+
let json = options.jsonDefault;
|
|
201
|
+
let all = false;
|
|
202
|
+
let prdPath;
|
|
203
|
+
for (const arg of args) {
|
|
204
|
+
if (arg === '--json') {
|
|
205
|
+
json = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (arg === '--all') {
|
|
209
|
+
all = true;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (arg.startsWith('--')) {
|
|
213
|
+
return { ok: false, error: `unknown flag: ${arg}` };
|
|
214
|
+
}
|
|
215
|
+
if (prdPath !== undefined) {
|
|
216
|
+
return { ok: false, error: `unexpected extra argument: ${arg}` };
|
|
217
|
+
}
|
|
218
|
+
prdPath = arg;
|
|
219
|
+
}
|
|
220
|
+
if (!all && prdPath === undefined) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: 'pugi prd-check <prd-path> | --all (pass a PRD path or --all to scan docs/prd/**.md)',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (all && prdPath !== undefined) {
|
|
227
|
+
return { ok: false, error: 'cannot combine <path> with --all' };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
flags: { json, all },
|
|
232
|
+
...(prdPath !== undefined ? { prdPath } : {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=prd-check.js.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/resume` runtime — leak L9 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Light runner that augments the existing `pugi resume` surface in
|
|
5
|
+
* `runtime/cli.ts` with rewind-aware session listings. The full
|
|
6
|
+
* REPL-mount path stays in cli.ts (it owns the credential resolver +
|
|
7
|
+
* Ink renderer); this module exposes the data-access helpers the slash
|
|
8
|
+
* dispatcher + the cli.ts handler share.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate runner rather than inlining inside cli.ts: the in-REPL
|
|
11
|
+
* `/resume` slash already holds the writer lock, so it cannot use the
|
|
12
|
+
* read-only view path the top-level command relies on. The split lets
|
|
13
|
+
* the slash code call `listResumableSessionsForRepl` (which routes
|
|
14
|
+
* through the live store) while the shell code calls
|
|
15
|
+
* `listResumableSessionsReadOnly` (which uses the no-lock view).
|
|
16
|
+
*/
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { slugForCwd } from '../../core/repl/history.js';
|
|
19
|
+
import { applyAllMasks, listResumableSessions, } from '../../core/checkpoint/resumer.js';
|
|
20
|
+
import { findLatestActiveRewind } from '../../core/checkpoint/rewinder.js';
|
|
21
|
+
/**
|
|
22
|
+
* Read-only `pugi resume --list` path. Uses the no-lock view so a live
|
|
23
|
+
* REPL writing in the same project does not block the listing.
|
|
24
|
+
*/
|
|
25
|
+
export async function runResumeList(ctx) {
|
|
26
|
+
const slug = slugForCwd(ctx.workspaceRoot);
|
|
27
|
+
const baseInput = {
|
|
28
|
+
projectSlug: slug,
|
|
29
|
+
limit: ctx.limit ?? 10,
|
|
30
|
+
home: ctx.home ?? homedir(),
|
|
31
|
+
};
|
|
32
|
+
const sessions = await listResumableSessions(baseInput);
|
|
33
|
+
const rows = sessions.map(toResumeListRow);
|
|
34
|
+
const text = renderResumeList(rows, slug);
|
|
35
|
+
ctx.writeOutput({
|
|
36
|
+
command: 'resume',
|
|
37
|
+
status: rows.length === 0 ? 'empty' : 'listed',
|
|
38
|
+
projectSlug: slug,
|
|
39
|
+
sessions: rows,
|
|
40
|
+
}, text);
|
|
41
|
+
return {
|
|
42
|
+
command: 'resume',
|
|
43
|
+
status: rows.length === 0 ? 'empty' : 'listed',
|
|
44
|
+
projectSlug: slug,
|
|
45
|
+
sessions: rows,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* In-REPL `/resume` slash variant. The live REPL holds the writer
|
|
50
|
+
* lockfile so we cannot reuse the read-only view path; this routes
|
|
51
|
+
* through the store the session module already opened. Returns the
|
|
52
|
+
* sessions list directly so the slash handler can render system lines.
|
|
53
|
+
*/
|
|
54
|
+
export async function runResumeListForRepl(input) {
|
|
55
|
+
const slug = slugForCwd(input.workspaceRoot);
|
|
56
|
+
const limit = input.limit ?? 10;
|
|
57
|
+
const sessionRows = await input.store.listSessions({
|
|
58
|
+
project: slug,
|
|
59
|
+
limit,
|
|
60
|
+
status: 'active+archived',
|
|
61
|
+
});
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const row of sessionRows) {
|
|
64
|
+
const events = await input.store.loadEvents(row.id);
|
|
65
|
+
const visible = applyAllMasks(events);
|
|
66
|
+
const latest = findLatestActiveRewind(events);
|
|
67
|
+
out.push({
|
|
68
|
+
id: row.id,
|
|
69
|
+
title: row.title,
|
|
70
|
+
branch: row.branch,
|
|
71
|
+
turnCount: row.turnCount,
|
|
72
|
+
eventCount: row.eventCount,
|
|
73
|
+
visibleEventCount: visible.length,
|
|
74
|
+
hasActiveRewind: latest !== null,
|
|
75
|
+
updatedAt: row.updatedAt,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function toResumeListRow(input) {
|
|
81
|
+
return {
|
|
82
|
+
id: input.row.id,
|
|
83
|
+
title: input.row.title,
|
|
84
|
+
branch: input.row.branch,
|
|
85
|
+
turnCount: input.row.turnCount,
|
|
86
|
+
eventCount: input.row.eventCount,
|
|
87
|
+
visibleEventCount: input.visibleEventCount,
|
|
88
|
+
hasActiveRewind: input.hasActiveRewind,
|
|
89
|
+
updatedAt: input.row.updatedAt,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Pretty-print a resume list. Adds a small "rewound" tag when the
|
|
94
|
+
* session carries an unfinished rewind so the operator knows the
|
|
95
|
+
* transcript will load with a masked range.
|
|
96
|
+
*/
|
|
97
|
+
export function renderResumeList(rows, projectSlug) {
|
|
98
|
+
if (rows.length === 0) {
|
|
99
|
+
return `No stored sessions for project '${projectSlug}' yet.`;
|
|
100
|
+
}
|
|
101
|
+
const lines = [
|
|
102
|
+
`Recent local sessions for '${projectSlug}' (${rows.length}):`,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
106
|
+
const row = rows[i];
|
|
107
|
+
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
108
|
+
const idShort = row.id.slice(0, 13);
|
|
109
|
+
const branch = (row.branch ?? 'no-branch').padEnd(16);
|
|
110
|
+
const turns = `${row.turnCount}t`.padStart(4);
|
|
111
|
+
const events = `${row.visibleEventCount}e`.padStart(5);
|
|
112
|
+
const tag = row.hasActiveRewind ? ' [rewound]' : '';
|
|
113
|
+
lines.push(` ${idShort} ${branch} ${turns} ${events}${tag} ${title}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('', 'Resume with: pugi resume <id>');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=resume.js.map
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/rewind` runtime — leak L9 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Three invocation modes, sharing one runner:
|
|
5
|
+
*
|
|
6
|
+
* - `/rewind N` drop the last N operator turns + every
|
|
7
|
+
* tool call that landed between.
|
|
8
|
+
* - `/rewind --to <id>` rewind to a specific event index from the
|
|
9
|
+
* visible (post-mask) transcript.
|
|
10
|
+
* - `/rewind` interactive picker — surfaces the last 10
|
|
11
|
+
* user-turn boundaries (newest-first) and
|
|
12
|
+
* returns a payload the caller can use to
|
|
13
|
+
* mount a select prompt.
|
|
14
|
+
*
|
|
15
|
+
* The rewind is APPEND-ONLY: we never delete events. `applyRewindMask`
|
|
16
|
+
* elides the masked range on read; `pugi sessions undo-rewind` appends
|
|
17
|
+
* an inverse marker that nullifies the latest rewind so operators have
|
|
18
|
+
* a reliable escape hatch.
|
|
19
|
+
*
|
|
20
|
+
* Surface contract (same shape as `runCompactCommand`):
|
|
21
|
+
*
|
|
22
|
+
* - Returns a structured result for the JSON path.
|
|
23
|
+
* - Calls `ctx.writeOutput(payload, text)` once per invocation.
|
|
24
|
+
* - Throws ONLY on programmer-error. Store failures, missing
|
|
25
|
+
* sessions, etc. are surfaced as `failed_*` statuses.
|
|
26
|
+
*
|
|
27
|
+
* Exit codes (mapped by the dispatcher in cli.ts):
|
|
28
|
+
*
|
|
29
|
+
* 0 — marker appended OR picker surfaced
|
|
30
|
+
* 1 — store unavailable / session not found
|
|
31
|
+
* 2 — noop (asked to drop 0 turns, nothing to rewind, etc.)
|
|
32
|
+
*/
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { slugForCwd } from '../../core/repl/history.js';
|
|
35
|
+
import { appendRewindMarker, buildRewindPickerRows, pickRewindTargetForTurns, resolveEventIdToIndex, } from '../../core/checkpoint/rewinder.js';
|
|
36
|
+
import { loadFromStore } from '../../core/checkpoint/resumer.js';
|
|
37
|
+
import { SqliteSessionStore, resolveProjectStoreDir, } from '../../core/repl/store/session-store.js';
|
|
38
|
+
/**
|
|
39
|
+
* Entry point reused by the slash command + the top-level dispatcher.
|
|
40
|
+
*
|
|
41
|
+
* `args` accepts:
|
|
42
|
+
* - `[]` picker mode
|
|
43
|
+
* - `["N"]` drop last N turns
|
|
44
|
+
* - `["--to", "<id>"]` rewind to event id
|
|
45
|
+
*
|
|
46
|
+
* Both `-N` and `--turns N` are accepted for parity with Claude Code's
|
|
47
|
+
* `--turns` flag.
|
|
48
|
+
*/
|
|
49
|
+
export async function runRewindCommand(args, ctx) {
|
|
50
|
+
const parsed = parseRewindArgs(args);
|
|
51
|
+
if (parsed.kind === 'error') {
|
|
52
|
+
return emit(ctx, {
|
|
53
|
+
command: 'rewind',
|
|
54
|
+
status: 'failed_parse',
|
|
55
|
+
reason: parsed.message,
|
|
56
|
+
}, parsed.message);
|
|
57
|
+
}
|
|
58
|
+
// Resolve session + store.
|
|
59
|
+
const slug = slugForCwd(ctx.workspaceRoot);
|
|
60
|
+
let store = ctx.store ?? null;
|
|
61
|
+
let sessionId = ctx.sessionId ?? null;
|
|
62
|
+
let storeOpenedHere = false;
|
|
63
|
+
if (store === null) {
|
|
64
|
+
sessionId = sessionId ?? (await pickMostRecentSessionIdReadOnly(slug));
|
|
65
|
+
if (!sessionId) {
|
|
66
|
+
return emit(ctx, {
|
|
67
|
+
command: 'rewind',
|
|
68
|
+
status: 'failed_no_session',
|
|
69
|
+
reason: 'No active session to rewind. Start a REPL with `pugi`.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const opened = await openLiveStore(slug, sessionId);
|
|
73
|
+
if (!opened) {
|
|
74
|
+
return emit(ctx, {
|
|
75
|
+
command: 'rewind',
|
|
76
|
+
status: 'failed_store',
|
|
77
|
+
sessionId,
|
|
78
|
+
reason: 'Could not open local session store (lock held by another REPL?).',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
store = opened;
|
|
82
|
+
storeOpenedHere = true;
|
|
83
|
+
}
|
|
84
|
+
else if (sessionId === null) {
|
|
85
|
+
const rows = await store.listSessions({ project: slug, limit: 1, status: 'active' });
|
|
86
|
+
if (rows.length === 0) {
|
|
87
|
+
return emit(ctx, {
|
|
88
|
+
command: 'rewind',
|
|
89
|
+
status: 'failed_no_session',
|
|
90
|
+
reason: 'No active session to rewind.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
sessionId = rows[0].id;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const loaded = await loadFromStore(store, sessionId);
|
|
97
|
+
if (!loaded) {
|
|
98
|
+
return emit(ctx, {
|
|
99
|
+
command: 'rewind',
|
|
100
|
+
status: 'failed_no_session',
|
|
101
|
+
sessionId,
|
|
102
|
+
reason: `Session '${sessionId}' not found.`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Picker mode: surface the last 10 user-turn boundaries.
|
|
106
|
+
if (parsed.kind === 'picker') {
|
|
107
|
+
const rows = buildRewindPickerRows(loaded.rawEvents, 10);
|
|
108
|
+
if (rows.length === 0) {
|
|
109
|
+
return emit(ctx, {
|
|
110
|
+
command: 'rewind',
|
|
111
|
+
status: 'noop_empty',
|
|
112
|
+
sessionId,
|
|
113
|
+
reason: 'No operator turns to rewind to.',
|
|
114
|
+
}, 'Nothing to rewind — no operator turns yet.');
|
|
115
|
+
}
|
|
116
|
+
const pickerRows = rows.map((r) => ({
|
|
117
|
+
visibleIndex: r.visibleIndex,
|
|
118
|
+
turnsAgo: r.turnsAgo,
|
|
119
|
+
preview: r.preview,
|
|
120
|
+
timestampEpochMs: r.timestampEpochMs,
|
|
121
|
+
}));
|
|
122
|
+
const text = renderPicker(pickerRows);
|
|
123
|
+
return emit(ctx, {
|
|
124
|
+
command: 'rewind',
|
|
125
|
+
status: 'picker',
|
|
126
|
+
sessionId,
|
|
127
|
+
pickerRows,
|
|
128
|
+
}, text);
|
|
129
|
+
}
|
|
130
|
+
// Resolve target index.
|
|
131
|
+
let toEventIndex;
|
|
132
|
+
let turnsRewound;
|
|
133
|
+
if (parsed.kind === 'turns') {
|
|
134
|
+
if (parsed.n <= 0) {
|
|
135
|
+
return emit(ctx, {
|
|
136
|
+
command: 'rewind',
|
|
137
|
+
status: 'noop_zero',
|
|
138
|
+
sessionId,
|
|
139
|
+
reason: 'Asked to drop 0 turns — nothing to do.',
|
|
140
|
+
}, 'Asked to drop 0 turns — nothing to do.');
|
|
141
|
+
}
|
|
142
|
+
const target = pickRewindTargetForTurns(loaded.rawEvents, parsed.n);
|
|
143
|
+
if (target.turnsRewound === 0) {
|
|
144
|
+
return emit(ctx, {
|
|
145
|
+
command: 'rewind',
|
|
146
|
+
status: 'noop_empty',
|
|
147
|
+
sessionId,
|
|
148
|
+
reason: 'No operator turns to rewind.',
|
|
149
|
+
}, 'No operator turns to rewind.');
|
|
150
|
+
}
|
|
151
|
+
toEventIndex = target.toEventIndex;
|
|
152
|
+
turnsRewound = target.turnsRewound;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// mode === 'to-event'
|
|
156
|
+
const resolvedIdx = resolveEventIdToIndex(loaded.rawEvents, parsed.eventId);
|
|
157
|
+
if (resolvedIdx === null) {
|
|
158
|
+
return emit(ctx, {
|
|
159
|
+
command: 'rewind',
|
|
160
|
+
status: 'failed_parse',
|
|
161
|
+
sessionId,
|
|
162
|
+
reason: `Could not resolve event id '${parsed.eventId}'. Try \`/rewind\` for the picker.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
toEventIndex = resolvedIdx;
|
|
166
|
+
// Count user turns in the masked range to report turnsRewound.
|
|
167
|
+
turnsRewound = countUserTurnsAfter(loaded.rawEvents, toEventIndex);
|
|
168
|
+
}
|
|
169
|
+
const fromEventIndex = loaded.rawEvents.length;
|
|
170
|
+
await appendRewindMarker({
|
|
171
|
+
store,
|
|
172
|
+
toEventIndex,
|
|
173
|
+
fromEventIndex,
|
|
174
|
+
turnsRewound,
|
|
175
|
+
reason: parsed.kind === 'turns' ? 'manual' : 'to-event',
|
|
176
|
+
...(ctx.now !== undefined ? { now: ctx.now } : {}),
|
|
177
|
+
});
|
|
178
|
+
// Reload + recompute visible count for the operator banner.
|
|
179
|
+
const after = await loadFromStore(store, sessionId);
|
|
180
|
+
const visibleAfter = after?.visibleEvents.length ?? 0;
|
|
181
|
+
const banner = `Rewound ${turnsRewound} turn${turnsRewound === 1 ? '' : 's'} ` +
|
|
182
|
+
`(to event ${toEventIndex < 0 ? 'start' : `#${toEventIndex + 1}`}). ` +
|
|
183
|
+
`${visibleAfter} event${visibleAfter === 1 ? '' : 's'} now visible. ` +
|
|
184
|
+
`Undo with \`pugi sessions undo-rewind\`.`;
|
|
185
|
+
return emit(ctx, {
|
|
186
|
+
command: 'rewind',
|
|
187
|
+
status: 'rewound',
|
|
188
|
+
sessionId,
|
|
189
|
+
turnsRewound,
|
|
190
|
+
toEventIndex,
|
|
191
|
+
fromEventIndex,
|
|
192
|
+
visibleAfter,
|
|
193
|
+
}, banner);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (storeOpenedHere && store) {
|
|
197
|
+
try {
|
|
198
|
+
await store.close();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* idempotent */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Accepts:
|
|
208
|
+
* pugi rewind -> picker
|
|
209
|
+
* pugi rewind 3 -> drop 3 turns
|
|
210
|
+
* pugi rewind --turns 3 -> same
|
|
211
|
+
* pugi rewind --to 12 -> rewind to event index 12 (1-based visible)
|
|
212
|
+
* pugi rewind --to #12 -> rewind to event index 12 (0-based hidden)
|
|
213
|
+
*/
|
|
214
|
+
function parseRewindArgs(args) {
|
|
215
|
+
if (args.length === 0)
|
|
216
|
+
return { kind: 'picker' };
|
|
217
|
+
const head = args[0];
|
|
218
|
+
// --to <id>
|
|
219
|
+
if (head === '--to' || head === '-t') {
|
|
220
|
+
const eventId = args[1];
|
|
221
|
+
if (!eventId) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'error',
|
|
224
|
+
message: 'Usage: pugi rewind --to <event-id>',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { kind: 'to-event', eventId };
|
|
228
|
+
}
|
|
229
|
+
if (head.startsWith('--to=')) {
|
|
230
|
+
const eventId = head.slice('--to='.length);
|
|
231
|
+
if (eventId.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
kind: 'error',
|
|
234
|
+
message: 'Usage: pugi rewind --to <event-id>',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { kind: 'to-event', eventId };
|
|
238
|
+
}
|
|
239
|
+
// --turns N OR -N OR positional N
|
|
240
|
+
if (head === '--turns' || head === '-n') {
|
|
241
|
+
const n = Number.parseInt(args[1] ?? '', 10);
|
|
242
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
243
|
+
return {
|
|
244
|
+
kind: 'error',
|
|
245
|
+
message: 'Usage: pugi rewind --turns <N>',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { kind: 'turns', n };
|
|
249
|
+
}
|
|
250
|
+
if (head.startsWith('--turns=')) {
|
|
251
|
+
const n = Number.parseInt(head.slice('--turns='.length), 10);
|
|
252
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
253
|
+
return {
|
|
254
|
+
kind: 'error',
|
|
255
|
+
message: 'Usage: pugi rewind --turns <N>',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { kind: 'turns', n };
|
|
259
|
+
}
|
|
260
|
+
// Bare integer: positional turns count.
|
|
261
|
+
const positional = Number.parseInt(head, 10);
|
|
262
|
+
if (Number.isFinite(positional) && positional >= 0) {
|
|
263
|
+
return { kind: 'turns', n: positional };
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
kind: 'error',
|
|
267
|
+
message: `Unknown argument '${head}'. Try \`pugi rewind\`, \`pugi rewind <N>\`, or \`pugi rewind --to <id>\`.`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function emit(ctx, payload, text) {
|
|
271
|
+
const human = text ?? payload.reason ?? `rewind: ${payload.status}`;
|
|
272
|
+
ctx.writeOutput(payload, human);
|
|
273
|
+
return payload;
|
|
274
|
+
}
|
|
275
|
+
function renderPicker(rows) {
|
|
276
|
+
const lines = ['Rewind picker — pick a turn boundary:', ''];
|
|
277
|
+
for (const r of rows) {
|
|
278
|
+
const tag = `[#${r.visibleIndex.toString().padStart(3)}]`;
|
|
279
|
+
const ago = `${r.turnsAgo}t ago`.padStart(8);
|
|
280
|
+
lines.push(` ${tag} ${ago} ${r.preview}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push('', 'Rewind with: pugi rewind --to <#N> (or `pugi rewind <turnsToDrop>`).');
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
}
|
|
285
|
+
function countUserTurnsAfter(events, toEventIndex) {
|
|
286
|
+
let count = 0;
|
|
287
|
+
for (let i = toEventIndex + 1; i < events.length; i += 1) {
|
|
288
|
+
if (events[i].kind === 'user')
|
|
289
|
+
count += 1;
|
|
290
|
+
}
|
|
291
|
+
return count;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Open the SqliteSessionStore for the workspace's project slug, bound
|
|
295
|
+
* to `sessionId`. Returns null when the lock is held by another REPL.
|
|
296
|
+
*/
|
|
297
|
+
async function openLiveStore(projectSlug, sessionId) {
|
|
298
|
+
try {
|
|
299
|
+
const store = new SqliteSessionStore({ projectSlug, home: homedir() });
|
|
300
|
+
await store.open({
|
|
301
|
+
id: sessionId,
|
|
302
|
+
workspaceRoot: process.cwd(),
|
|
303
|
+
projectSlug,
|
|
304
|
+
});
|
|
305
|
+
return store;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Discover the most recent active session id for a project slug,
|
|
313
|
+
* without taking the writer lockfile. Used by the standalone CLI path
|
|
314
|
+
* (no `--session` flag) so a live REPL holding the lock does not block
|
|
315
|
+
* the lookup.
|
|
316
|
+
*/
|
|
317
|
+
async function pickMostRecentSessionIdReadOnly(projectSlug) {
|
|
318
|
+
try {
|
|
319
|
+
const dir = resolveProjectStoreDir(projectSlug, homedir());
|
|
320
|
+
const view = await SqliteSessionStore.openReadOnly(dir);
|
|
321
|
+
try {
|
|
322
|
+
const rows = await view.list({ project: projectSlug, limit: 1, status: 'active' });
|
|
323
|
+
return rows.length > 0 ? rows[0].id : null;
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
await view.close();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
//# sourceMappingURL=rewind.js.map
|