@pugi/cli 0.1.0-beta.23 → 0.1.0-beta.25
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/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/native-pugi.js +67 -3
- package/dist/core/engine/tool-bridge.js +123 -3
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +84 -0
- package/dist/core/repl/slash-commands.js +25 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +9 -0
- package/dist/runtime/cli.js +170 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/repl-splash-mascot.js +19 -7
- package/package.json +3 -3
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project onboarding state for the Pugi REPL.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Claude Code's projectOnboardingState pattern (the leaked
|
|
5
|
+
* upstream file ships a 4-flag memoized check called on every prompt
|
|
6
|
+
* submit). Independent implementation: the storage location, the steps,
|
|
7
|
+
* the reset path, and the cap counter are all Pugi-shaped.
|
|
8
|
+
*
|
|
9
|
+
* # Why this exists
|
|
10
|
+
*
|
|
11
|
+
* The REPL's status bar can flash a `setup needed: run /init` hint when
|
|
12
|
+
* a workspace is still ungoverned (no PUGI.md, no skills installed). The
|
|
13
|
+
* hint must NEVER show on a workspace where the operator already ran the
|
|
14
|
+
* interview - otherwise the prompt becomes nag-noise. Three layered
|
|
15
|
+
* short-circuits guarantee that:
|
|
16
|
+
*
|
|
17
|
+
* 1. `hasCompletedOnboarding === true` in `~/.pugi/state.json` -> stop.
|
|
18
|
+
* The interview's final phase writes this flag once; subsequent
|
|
19
|
+
* REPL boots short-circuit at the global ledger and never even
|
|
20
|
+
* stat the workspace.
|
|
21
|
+
* 2. `onboardingSeenCount >= 4` -> stop. If the operator dismissed
|
|
22
|
+
* the hint four times without finishing the interview, assume they
|
|
23
|
+
* do not want it. The bar stays clean from boot 5 onward.
|
|
24
|
+
* 3. `PUGI_IS_DEMO=1` -> stop. The demo / snapshot recorder sets this
|
|
25
|
+
* so the screen capture stays free of onboarding chrome.
|
|
26
|
+
*
|
|
27
|
+
* If none short-circuit, the function checks the per-workspace steps
|
|
28
|
+
* (`PUGI.md` present, at least one skill installed, auth ok, workspace
|
|
29
|
+
* dir bound) and returns `true` only when at least one step is still
|
|
30
|
+
* incomplete.
|
|
31
|
+
*
|
|
32
|
+
* # Storage shape
|
|
33
|
+
*
|
|
34
|
+
* The global state file is JSON-on-disk at
|
|
35
|
+
* `~/.pugi/state.json`. It carries two scalars:
|
|
36
|
+
*
|
|
37
|
+
* {
|
|
38
|
+
* "hasCompletedOnboarding": boolean,
|
|
39
|
+
* "onboardingSeenCount": integer >= 0
|
|
40
|
+
* }
|
|
41
|
+
*
|
|
42
|
+
* The file is read at most once per process (memoized) so the hot path
|
|
43
|
+
* is in-memory. `incrementSeenCount()` re-reads the file before
|
|
44
|
+
* mutating to avoid clobbering a sibling Pugi process's increment
|
|
45
|
+
* (concurrent REPLs in two terminals). Atomic-ish: we write to
|
|
46
|
+
* `state.json.tmp` then rename. A race that overwrites another
|
|
47
|
+
* increment is benign - the worst case is a single missed +1 across
|
|
48
|
+
* two siblings.
|
|
49
|
+
*
|
|
50
|
+
* # Reset path
|
|
51
|
+
*
|
|
52
|
+
* `rm ~/.pugi/state.json` resets every flag. Tests rely on this:
|
|
53
|
+
* `resetOnboardingStateForTests(stateDir)` is the public helper that
|
|
54
|
+
* removes the file and clears the memoization cache.
|
|
55
|
+
*/
|
|
56
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
57
|
+
import { homedir } from 'node:os';
|
|
58
|
+
import { join } from 'node:path';
|
|
59
|
+
const DEFAULT_STATE = Object.freeze({
|
|
60
|
+
hasCompletedOnboarding: false,
|
|
61
|
+
onboardingSeenCount: 0,
|
|
62
|
+
});
|
|
63
|
+
/** Hard cap; once the operator has seen the hint this many times we stop. */
|
|
64
|
+
export const ONBOARDING_SEEN_CAP = 4;
|
|
65
|
+
/**
|
|
66
|
+
* Singleton memoization cache - cleared by `resetMemoizationForTests`.
|
|
67
|
+
* Keyed by stateDir so concurrent specs in the same process do not
|
|
68
|
+
* interfere with each other.
|
|
69
|
+
*/
|
|
70
|
+
const SHOULD_SHOW_CACHE = new Map();
|
|
71
|
+
const PERSISTED_STATE_CACHE = new Map();
|
|
72
|
+
/**
|
|
73
|
+
* Default fs implementation - the real `node:fs` module wrapped into
|
|
74
|
+
* the minimal surface this file uses.
|
|
75
|
+
*/
|
|
76
|
+
const REAL_FS = {
|
|
77
|
+
existsSync,
|
|
78
|
+
readFileSync: (path, encoding) => readFileSync(path, encoding),
|
|
79
|
+
writeFileSync: (path, data, options) => writeFileSync(path, data, options),
|
|
80
|
+
mkdirSync: (path, opts) => {
|
|
81
|
+
mkdirSync(path, opts);
|
|
82
|
+
},
|
|
83
|
+
renameSync,
|
|
84
|
+
rmSync: (path, opts) => rmSync(path, opts),
|
|
85
|
+
readdirSync: (path) => readdirSync(path),
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Compute the four Pugi-specific onboarding steps for the given
|
|
89
|
+
* workspace. Pure: the only side effect is the filesystem probe (which
|
|
90
|
+
* tests stub via `probes.fs`).
|
|
91
|
+
*/
|
|
92
|
+
export function getSteps(probes) {
|
|
93
|
+
const fs = probes.fs ?? REAL_FS;
|
|
94
|
+
const pugiDirPath = join(probes.cwd, '.pugi');
|
|
95
|
+
const hasWorkspaceDir = fs.existsSync(pugiDirPath);
|
|
96
|
+
const hasPugiMd = fs.existsSync(join(probes.cwd, 'PUGI.md')) ||
|
|
97
|
+
fs.existsSync(join(pugiDirPath, 'PUGI.md'));
|
|
98
|
+
const skillsDir = join(pugiDirPath, 'skills');
|
|
99
|
+
const hasSkill = fs.existsSync(skillsDir) && !isEmptyDir(skillsDir, fs);
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
key: 'workspace',
|
|
103
|
+
text: 'Bind a workspace by running pugi init',
|
|
104
|
+
isComplete: hasWorkspaceDir,
|
|
105
|
+
isEnabled: true,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'pugimd',
|
|
109
|
+
text: 'Run /init to write PUGI.md with project context',
|
|
110
|
+
isComplete: hasPugiMd,
|
|
111
|
+
isEnabled: hasWorkspaceDir,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: 'skills',
|
|
115
|
+
text: 'Install at least one skill under .pugi/skills/',
|
|
116
|
+
isComplete: hasSkill,
|
|
117
|
+
isEnabled: hasWorkspaceDir,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: 'auth',
|
|
121
|
+
text: 'Run pugi login to authenticate',
|
|
122
|
+
isComplete: probes.authOk,
|
|
123
|
+
isEnabled: true,
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Roll-up over the steps. True when every ENABLED step is complete -
|
|
129
|
+
* disabled steps (e.g. `pugimd` before `workspace` is set up) are
|
|
130
|
+
* excluded so the operator does not get stuck on a step they cannot
|
|
131
|
+
* action yet.
|
|
132
|
+
*/
|
|
133
|
+
export function isOnboardingComplete(probes) {
|
|
134
|
+
const steps = getSteps(probes);
|
|
135
|
+
return steps
|
|
136
|
+
.filter((s) => s.isEnabled)
|
|
137
|
+
.every((s) => s.isComplete);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The exported gate the REPL status bar consults. Memoized per
|
|
141
|
+
* stateDir; the hot path is one map lookup once the first call has run.
|
|
142
|
+
*/
|
|
143
|
+
export function shouldShowOnboarding(probes) {
|
|
144
|
+
const stateDir = probes.stateDir ?? defaultStateDir();
|
|
145
|
+
const cached = SHOULD_SHOW_CACHE.get(stateDir);
|
|
146
|
+
if (cached !== undefined)
|
|
147
|
+
return cached;
|
|
148
|
+
const persisted = loadPersistedState(stateDir, probes.fs ?? REAL_FS);
|
|
149
|
+
const isDemo = probes.isDemoOverride ?? (process.env.PUGI_IS_DEMO === '1');
|
|
150
|
+
if (persisted.hasCompletedOnboarding) {
|
|
151
|
+
SHOULD_SHOW_CACHE.set(stateDir, false);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (persisted.onboardingSeenCount >= ONBOARDING_SEEN_CAP) {
|
|
155
|
+
SHOULD_SHOW_CACHE.set(stateDir, false);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (isDemo) {
|
|
159
|
+
SHOULD_SHOW_CACHE.set(stateDir, false);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const verdict = !isOnboardingComplete(probes);
|
|
163
|
+
SHOULD_SHOW_CACHE.set(stateDir, verdict);
|
|
164
|
+
return verdict;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Idempotent setter: if the workspace is now fully onboarded, flip the
|
|
168
|
+
* persisted flag. Cheap to call on every prompt submit because the
|
|
169
|
+
* fast path short-circuits on the in-memory cache.
|
|
170
|
+
*
|
|
171
|
+
* The "maybe" prefix mirrors the upstream pattern: a no-op is the
|
|
172
|
+
* common case; the write only fires once when the operator's most
|
|
173
|
+
* recent action just completed the ladder.
|
|
174
|
+
*/
|
|
175
|
+
export function maybeMarkOnboardingComplete(probes) {
|
|
176
|
+
const stateDir = probes.stateDir ?? defaultStateDir();
|
|
177
|
+
const fs = probes.fs ?? REAL_FS;
|
|
178
|
+
const persisted = loadPersistedState(stateDir, fs);
|
|
179
|
+
if (persisted.hasCompletedOnboarding)
|
|
180
|
+
return;
|
|
181
|
+
if (!isOnboardingComplete(probes))
|
|
182
|
+
return;
|
|
183
|
+
persistState(stateDir, fs, { ...persisted, hasCompletedOnboarding: true });
|
|
184
|
+
// Invalidate the cache so the next `shouldShowOnboarding` call sees
|
|
185
|
+
// the new value without restarting the process.
|
|
186
|
+
SHOULD_SHOW_CACHE.delete(stateDir);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Increment the seen counter. The REPL status bar calls this once per
|
|
190
|
+
* boot where it actually rendered the hint. After
|
|
191
|
+
* `ONBOARDING_SEEN_CAP` increments the cap guard in
|
|
192
|
+
* `shouldShowOnboarding` flips the gate off permanently for this user.
|
|
193
|
+
*/
|
|
194
|
+
export function incrementOnboardingSeenCount(probes) {
|
|
195
|
+
const stateDir = probes.stateDir ?? defaultStateDir();
|
|
196
|
+
const fs = probes.fs ?? REAL_FS;
|
|
197
|
+
const persisted = loadPersistedState(stateDir, fs);
|
|
198
|
+
const next = {
|
|
199
|
+
...persisted,
|
|
200
|
+
onboardingSeenCount: persisted.onboardingSeenCount + 1,
|
|
201
|
+
};
|
|
202
|
+
persistState(stateDir, fs, next);
|
|
203
|
+
SHOULD_SHOW_CACHE.delete(stateDir);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reset every persisted flag. Tests use this via the public helper; an
|
|
207
|
+
* operator can achieve the same effect by `rm ~/.pugi/state.json`.
|
|
208
|
+
*/
|
|
209
|
+
export function resetOnboardingStateForTests(probes) {
|
|
210
|
+
const stateDir = probes.stateDir ?? defaultStateDir();
|
|
211
|
+
const fs = probes.fs ?? REAL_FS;
|
|
212
|
+
const file = stateFilePath(stateDir);
|
|
213
|
+
if (fs.existsSync(file)) {
|
|
214
|
+
fs.rmSync(file, { force: true });
|
|
215
|
+
}
|
|
216
|
+
SHOULD_SHOW_CACHE.delete(stateDir);
|
|
217
|
+
PERSISTED_STATE_CACHE.delete(stateDir);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Clear the in-memory memoization without touching the disk. The
|
|
221
|
+
* spec uses this between assertions when it wants to force a re-read.
|
|
222
|
+
*/
|
|
223
|
+
export function resetMemoizationForTests(stateDir) {
|
|
224
|
+
if (stateDir) {
|
|
225
|
+
SHOULD_SHOW_CACHE.delete(stateDir);
|
|
226
|
+
PERSISTED_STATE_CACHE.delete(stateDir);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
SHOULD_SHOW_CACHE.clear();
|
|
230
|
+
PERSISTED_STATE_CACHE.clear();
|
|
231
|
+
}
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
/* Internal helpers */
|
|
234
|
+
/* ------------------------------------------------------------------ */
|
|
235
|
+
function defaultStateDir() {
|
|
236
|
+
return join(homedir(), '.pugi');
|
|
237
|
+
}
|
|
238
|
+
function stateFilePath(stateDir) {
|
|
239
|
+
return join(stateDir, 'state.json');
|
|
240
|
+
}
|
|
241
|
+
function loadPersistedState(stateDir, fs) {
|
|
242
|
+
const cached = PERSISTED_STATE_CACHE.get(stateDir);
|
|
243
|
+
if (cached !== undefined)
|
|
244
|
+
return cached;
|
|
245
|
+
const file = stateFilePath(stateDir);
|
|
246
|
+
if (!fs.existsSync(file)) {
|
|
247
|
+
PERSISTED_STATE_CACHE.set(stateDir, DEFAULT_STATE);
|
|
248
|
+
return DEFAULT_STATE;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
252
|
+
const parsed = JSON.parse(raw);
|
|
253
|
+
const normalized = {
|
|
254
|
+
hasCompletedOnboarding: parsed.hasCompletedOnboarding === true,
|
|
255
|
+
onboardingSeenCount: typeof parsed.onboardingSeenCount === 'number' &&
|
|
256
|
+
Number.isFinite(parsed.onboardingSeenCount) &&
|
|
257
|
+
parsed.onboardingSeenCount >= 0
|
|
258
|
+
? Math.floor(parsed.onboardingSeenCount)
|
|
259
|
+
: 0,
|
|
260
|
+
};
|
|
261
|
+
PERSISTED_STATE_CACHE.set(stateDir, normalized);
|
|
262
|
+
return normalized;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Corrupt file - treat as if it were absent. The next persist
|
|
266
|
+
// overwrites with a clean shape; we never crash the REPL boot on
|
|
267
|
+
// an unreadable state file.
|
|
268
|
+
PERSISTED_STATE_CACHE.set(stateDir, DEFAULT_STATE);
|
|
269
|
+
return DEFAULT_STATE;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function persistState(stateDir, fs, next) {
|
|
273
|
+
if (!fs.existsSync(stateDir)) {
|
|
274
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
const file = stateFilePath(stateDir);
|
|
277
|
+
const tmp = `${file}.tmp`;
|
|
278
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
279
|
+
fs.renameSync(tmp, file);
|
|
280
|
+
PERSISTED_STATE_CACHE.set(stateDir, next);
|
|
281
|
+
}
|
|
282
|
+
function isEmptyDir(path, fs) {
|
|
283
|
+
// An empty `skills/` directory counts as "no skill installed yet" so
|
|
284
|
+
// the onboarding step stays incomplete. The injected fs shim may
|
|
285
|
+
// omit `readdirSync` - in that case we conservatively report
|
|
286
|
+
// non-empty (the dir existed, so the operator likely did something
|
|
287
|
+
// intentional) to avoid false-positive nag.
|
|
288
|
+
if (!fs.readdirSync)
|
|
289
|
+
return false;
|
|
290
|
+
try {
|
|
291
|
+
return fs.readdirSync(path).length === 0;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=onboarding-state.js.map
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* verbatim - the brand gate on those happens at the controller.
|
|
28
28
|
*/
|
|
29
29
|
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
30
31
|
import { getPersona } from '@pugi/personas';
|
|
31
32
|
import { listRoles, getPersonaForRole } from '../agents/registry.js';
|
|
32
33
|
import { evaluateCap, describeVerdict } from './cap-warning.js';
|
|
@@ -1167,6 +1168,54 @@ export class ReplSession {
|
|
|
1167
1168
|
}
|
|
1168
1169
|
return verdict;
|
|
1169
1170
|
}
|
|
1171
|
+
case 'update': {
|
|
1172
|
+
// Leak L27 (2026-05-27): /update probes the npm registry for a
|
|
1173
|
+
// newer @pugi/cli version on the configured channel and prints
|
|
1174
|
+
// the install command. The slash form NEVER spawns `npm install
|
|
1175
|
+
// -g` — that would corrupt the binary we are currently running.
|
|
1176
|
+
// Operators see the install command + run it manually (or run
|
|
1177
|
+
// `pugi update --apply` from a fresh shell after the REPL
|
|
1178
|
+
// exits). The slash + top-level paths share the dispatcher so
|
|
1179
|
+
// channel resolution + last-check persistence stay single-
|
|
1180
|
+
// sourced.
|
|
1181
|
+
try {
|
|
1182
|
+
const { parseUpdateArgs, runUpdateCommand } = await import('../../runtime/commands/update.js');
|
|
1183
|
+
const parsed = parseUpdateArgs(verdict.args);
|
|
1184
|
+
if ('error' in parsed) {
|
|
1185
|
+
this.appendSystemLine(parsed.error);
|
|
1186
|
+
return verdict;
|
|
1187
|
+
}
|
|
1188
|
+
// Force `apply=false` on the slash path — see comment above.
|
|
1189
|
+
const slashFlags = { ...parsed, apply: false };
|
|
1190
|
+
const lines = [];
|
|
1191
|
+
await runUpdateCommand({
|
|
1192
|
+
cwd: process.cwd(),
|
|
1193
|
+
home: homedir(),
|
|
1194
|
+
env: process.env,
|
|
1195
|
+
flags: slashFlags,
|
|
1196
|
+
promptConfirm: async () => false,
|
|
1197
|
+
writeOutput: (_payload, text) => {
|
|
1198
|
+
for (const line of text.split('\n')) {
|
|
1199
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1200
|
+
if (trimmed.length > 0)
|
|
1201
|
+
lines.push(trimmed);
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
if (lines.length === 0) {
|
|
1206
|
+
this.appendSystemLine('/update: no output.');
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
for (const line of lines)
|
|
1210
|
+
this.appendSystemLine(line);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1215
|
+
this.appendSystemLine(`/update failed: ${message}`);
|
|
1216
|
+
}
|
|
1217
|
+
return verdict;
|
|
1218
|
+
}
|
|
1170
1219
|
case 'feedback': {
|
|
1171
1220
|
// Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
|
|
1172
1221
|
// mounts a fresh Ink tree (renderFeedbackPrompt) outside the
|
|
@@ -1184,6 +1233,41 @@ export class ReplSession {
|
|
|
1184
1233
|
}
|
|
1185
1234
|
return verdict;
|
|
1186
1235
|
}
|
|
1236
|
+
case 'repo-map': {
|
|
1237
|
+
// Leak L28 (2026-05-27): AST-light workspace summary. Delegate
|
|
1238
|
+
// к the shared `runRepoMapCommand` so the slash + top-level
|
|
1239
|
+
// paths stay single-sourced. The rendered text lands on the
|
|
1240
|
+
// system pane via `appendSystemLine` (no fresh Ink mount) so
|
|
1241
|
+
// the listing flows into the conversation transcript like
|
|
1242
|
+
// any other command output.
|
|
1243
|
+
try {
|
|
1244
|
+
const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
|
|
1245
|
+
const lines = [];
|
|
1246
|
+
await runRepoMapCommand({
|
|
1247
|
+
cwd: process.cwd(),
|
|
1248
|
+
refresh: verdict.refresh,
|
|
1249
|
+
json: false,
|
|
1250
|
+
writeOutput: (_payload, text) => {
|
|
1251
|
+
for (const line of text.split('\n')) {
|
|
1252
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1253
|
+
lines.push(trimmed);
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
if (lines.length === 0) {
|
|
1258
|
+
this.appendSystemLine('/repo-map: no output.');
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
for (const line of lines)
|
|
1262
|
+
this.appendSystemLine(line);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
catch (error) {
|
|
1266
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1267
|
+
this.appendSystemLine(`/repo-map failed: ${message}`);
|
|
1268
|
+
}
|
|
1269
|
+
return verdict;
|
|
1270
|
+
}
|
|
1187
1271
|
case 'stub': {
|
|
1188
1272
|
this.appendSystemLine(verdict.message);
|
|
1189
1273
|
return verdict;
|
|
@@ -75,6 +75,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
75
75
|
{ name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
|
|
76
76
|
{ name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
|
|
77
77
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
78
|
+
{ name: 'repo-map', args: '[refresh]', gloss: 'AST-light symbol summary of the workspace (leak L28)', group: 'Pugi tools' },
|
|
78
79
|
// Settings
|
|
79
80
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
80
81
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
@@ -95,6 +96,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
95
96
|
{ name: 'feedback', args: '', gloss: 'file a bug / feature / general comment without leaving the REPL', group: 'Meta' },
|
|
96
97
|
{ name: 'share', args: '[--gist|--pugi] [--redact] [--preview]', gloss: 'Export session transcript to gist / pugi.io (leak L20)', group: 'Meta' },
|
|
97
98
|
{ name: 'release-notes', args: '[--reset]', gloss: 'Show changelog diff since last upgrade (leak L24)', group: 'Meta' },
|
|
99
|
+
{ name: 'update', args: '[--check|--apply [--yes]] [--channel <name>]', gloss: 'Check for / apply CLI update on stable / beta / canary (leak L27)', group: 'Meta' },
|
|
98
100
|
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
99
101
|
]);
|
|
100
102
|
/**
|
|
@@ -461,6 +463,15 @@ export function parseSlashCommand(input) {
|
|
|
461
463
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
462
464
|
return { kind: 'share', args: tokens };
|
|
463
465
|
}
|
|
466
|
+
case 'repo-map':
|
|
467
|
+
case 'repomap': {
|
|
468
|
+
// Leak L28 (2026-05-27): build + show the AST-light symbol
|
|
469
|
+
// summary. Accepts `refresh` as a positional или `--refresh`
|
|
470
|
+
// flag so muscle memory from both shells lands the same way.
|
|
471
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
472
|
+
const refresh = tokens.includes('--refresh') || tokens.includes('refresh') || tokens.includes('-r');
|
|
473
|
+
return { kind: 'repo-map', refresh };
|
|
474
|
+
}
|
|
464
475
|
case 'release-notes':
|
|
465
476
|
case 'releasenotes':
|
|
466
477
|
case 'changelog': {
|
|
@@ -474,6 +485,20 @@ export function parseSlashCommand(input) {
|
|
|
474
485
|
const reset = tokens.includes('--reset') || tokens.includes('-r');
|
|
475
486
|
return { kind: 'release-notes', reset };
|
|
476
487
|
}
|
|
488
|
+
case 'update': {
|
|
489
|
+
// Leak L27 (2026-05-27): forward the tokenized argv to the
|
|
490
|
+
// session module which delegates to `runUpdateCommand`. The
|
|
491
|
+
// dispatcher owns argv validation (unknown channel / flag) so
|
|
492
|
+
// the slash parser stays as thin as the rest of the surface.
|
|
493
|
+
// The slash form does NOT support `--apply` because spawning
|
|
494
|
+
// `npm install -g` from inside a running REPL session would
|
|
495
|
+
// corrupt the operator's running binary — the dispatcher treats
|
|
496
|
+
// `--apply` from a slash as a non-interactive offer (probe +
|
|
497
|
+
// install command, no shell-out). Top-level `pugi update --apply`
|
|
498
|
+
// remains the recommended path for the actual install.
|
|
499
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
500
|
+
return { kind: 'update', args: tokens };
|
|
501
|
+
}
|
|
477
502
|
case 'memory':
|
|
478
503
|
case 'config':
|
|
479
504
|
case 'budget':
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-map build orchestrator — Leak L28 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Single entry point that the CLI command + the engine boot path both
|
|
5
|
+
* call. Wires the scanner → extractor → cache → formatter pipeline
|
|
6
|
+
* together and surfaces a structured result the caller can render or
|
|
7
|
+
* inject without knowing the inner module shapes.
|
|
8
|
+
*
|
|
9
|
+
* The orchestrator is split out от cache.ts and the command handler
|
|
10
|
+
* so:
|
|
11
|
+
*
|
|
12
|
+
* 1. The CLI command + the engine system-prompt injector share one
|
|
13
|
+
* code path. Drift between the two would silently change what
|
|
14
|
+
* the model sees vs. what the operator sees.
|
|
15
|
+
*
|
|
16
|
+
* 2. The spec can exercise the full pipeline against a temp dir
|
|
17
|
+
* without mounting Ink or the engine.
|
|
18
|
+
*
|
|
19
|
+
* Pure-ish: reads from disk (the source files + the cache), but never
|
|
20
|
+
* mutates anything outside `.pugi/repo-map.json` and never logs. The
|
|
21
|
+
* caller decides whether к persist the cache (`writeCache: true`) or
|
|
22
|
+
* к compute the map в-memory (`writeCache: false` — useful for
|
|
23
|
+
* non-interactive `--json` invocations on read-only fs).
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { loadPugiIgnore } from '../context/pugiignore.js';
|
|
27
|
+
import { defaultCachePath, diffCacheAgainstScan, mergeCache, readRepoMapCache, writeRepoMapCache, } from './cache.js';
|
|
28
|
+
import { extractFromFile } from './extractor.js';
|
|
29
|
+
import { scanRepoForMap } from './scanner.js';
|
|
30
|
+
import { formatRepoMap } from './formatter.js';
|
|
31
|
+
/**
|
|
32
|
+
* Run the full pipeline. Returns a structured verdict; never throws.
|
|
33
|
+
* The 'too-large' branch fires when the workspace exceeds the file
|
|
34
|
+
* cap — callers surface a hint к the operator ("repo too large for
|
|
35
|
+
* inline map — try .pugiignore") and skip injection.
|
|
36
|
+
*/
|
|
37
|
+
export function buildRepoMap(options) {
|
|
38
|
+
const root = options.root;
|
|
39
|
+
const refresh = options.refresh === true;
|
|
40
|
+
const writeCache = options.writeCache !== false;
|
|
41
|
+
const cachePath = options.cachePath ?? defaultCachePath(root);
|
|
42
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
43
|
+
const ignore = loadPugiIgnore(root);
|
|
44
|
+
const scan = scanRepoForMap({ root, ignore });
|
|
45
|
+
if (!scan.ok) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
root,
|
|
49
|
+
reason: scan.skipped.reason,
|
|
50
|
+
walked: scan.skipped.walked,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const prior = refresh ? null : readCacheOrNull(cachePath);
|
|
54
|
+
const diff = diffCacheAgainstScan(prior, scan.files);
|
|
55
|
+
const freshExtracts = new Map();
|
|
56
|
+
for (const file of diff.toRebuild) {
|
|
57
|
+
let source;
|
|
58
|
+
try {
|
|
59
|
+
source = readFile(file.absPath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// File vanished или became unreadable mid-build — skip. The
|
|
63
|
+
// cache layer will just not have an entry for it; next refresh
|
|
64
|
+
// picks it up if it reappears.
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
freshExtracts.set(file.relPath, extractFromFile(file, source));
|
|
68
|
+
}
|
|
69
|
+
const cache = mergeCache({
|
|
70
|
+
root,
|
|
71
|
+
prior,
|
|
72
|
+
scanned: scan.files,
|
|
73
|
+
freshExtracts,
|
|
74
|
+
});
|
|
75
|
+
let cacheWritten = false;
|
|
76
|
+
if (writeCache) {
|
|
77
|
+
const writeResult = writeRepoMapCache(cachePath, cache);
|
|
78
|
+
cacheWritten = writeResult.ok;
|
|
79
|
+
}
|
|
80
|
+
// Surface the extracts в the same order the scanner produced (sorted
|
|
81
|
+
// by POSIX path) so callers iterating the result render deterministic
|
|
82
|
+
// output. The formatter does its own priority sort, so a different
|
|
83
|
+
// order here would only affect callers that iterate manually.
|
|
84
|
+
const extracts = [];
|
|
85
|
+
for (const file of scan.files) {
|
|
86
|
+
const entry = cache.entries[file.relPath];
|
|
87
|
+
if (entry)
|
|
88
|
+
extracts.push(entry.extract);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
root,
|
|
93
|
+
cache,
|
|
94
|
+
extracts,
|
|
95
|
+
scanStats: scan.stats,
|
|
96
|
+
diffStats: {
|
|
97
|
+
rebuilt: diff.toRebuild.length,
|
|
98
|
+
reused: diff.reuse.length,
|
|
99
|
+
dropped: diff.toDrop.length,
|
|
100
|
+
},
|
|
101
|
+
cachePath,
|
|
102
|
+
cacheWritten,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Convenience wrapper: build + format в one call. The engine boot
|
|
107
|
+
* path uses this so it does not have к know the formatter shape.
|
|
108
|
+
*/
|
|
109
|
+
export function buildAndFormatRepoMap(options) {
|
|
110
|
+
const build = buildRepoMap(options);
|
|
111
|
+
if (!build.ok)
|
|
112
|
+
return { build };
|
|
113
|
+
const format = formatRepoMap(build.extracts, {
|
|
114
|
+
maxBytes: options.formatBytesCap,
|
|
115
|
+
omitHeader: options.omitHeader,
|
|
116
|
+
});
|
|
117
|
+
return { build, format };
|
|
118
|
+
}
|
|
119
|
+
function readCacheOrNull(path) {
|
|
120
|
+
const verdict = readRepoMapCache(path);
|
|
121
|
+
if (verdict.ok)
|
|
122
|
+
return verdict.cache;
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=build.js.map
|