@pugi/cli 0.1.0-beta.24 → 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/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/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/session.js +35 -0
- package/dist/core/repl/slash-commands.js +10 -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 +90 -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/version.js +1 -1
- package/dist/tui/repl-splash-mascot.js +19 -7
- package/package.json +3 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks MVP — runner (Leak L12, first pass).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the shell command declared in `hooks-mvp.json`, applies the
|
|
5
|
+
* timeout watchdog, captures stdout / stderr, and surfaces a
|
|
6
|
+
* structured result. Two events are wired in the MVP (SessionStart,
|
|
7
|
+
* PreToolUse); the runner itself is event-agnostic so the fast-follow
|
|
8
|
+
* PR can attach the remaining 6 events without changing this file.
|
|
9
|
+
*
|
|
10
|
+
* Safety properties:
|
|
11
|
+
* - 30 s default timeout (per task spec); SIGTERM then SIGKILL with
|
|
12
|
+
* a 2 s grace window.
|
|
13
|
+
* - 1 MiB output cap per stream — a misbehaving hook (`yes`) cannot
|
|
14
|
+
* OOM the parent CLI by buffering unbounded data.
|
|
15
|
+
* - Spawn failures are caught + logged; the session never crashes
|
|
16
|
+
* because of a missing binary or a syntax error in the command.
|
|
17
|
+
* - Hook errors are atomic-appended to `<workspaceRoot>/.pugi/logs/
|
|
18
|
+
* hooks.log`. Multiple sessions can write concurrently without
|
|
19
|
+
* interleaving because `appendFileSync` opens with O_APPEND.
|
|
20
|
+
*
|
|
21
|
+
* Brand voice: ASCII only.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { DEFAULT_HOOK_TIMEOUT_MS, isToolEvent, } from './registry.js';
|
|
27
|
+
const HOOK_STREAM_CAP_BYTES = 1024 * 1024;
|
|
28
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
29
|
+
/**
|
|
30
|
+
* Fire every matching hook for `event` sequentially. Sequential (not
|
|
31
|
+
* parallel) is the intentional default — operators frequently chain
|
|
32
|
+
* `git add` -> `eslint --fix` style hooks that would race otherwise.
|
|
33
|
+
* Returns a `HookFireOutcome` with the per-invocation results.
|
|
34
|
+
*/
|
|
35
|
+
export async function fireHooks(opts) {
|
|
36
|
+
const { config, event, payload, toolName, workspaceRoot, env } = opts;
|
|
37
|
+
const matching = config.listMatching(event, toolName);
|
|
38
|
+
if (matching.length === 0) {
|
|
39
|
+
return { event, results: [], anyBlocked: false };
|
|
40
|
+
}
|
|
41
|
+
const logger = workspaceRoot ? new HookLogger(workspaceRoot) : undefined;
|
|
42
|
+
const results = [];
|
|
43
|
+
let anyBlocked = false;
|
|
44
|
+
for (const entry of matching) {
|
|
45
|
+
const timeoutMs = entry.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
46
|
+
const result = await executeOne(entry.command, payload, timeoutMs, env);
|
|
47
|
+
// Blocking semantics only honored for PreToolUse in the MVP.
|
|
48
|
+
// Other events can declare `blocking: true` but the runner just
|
|
49
|
+
// logs that intent — it does NOT short-circuit. The fast-follow
|
|
50
|
+
// PR threads PostToolUse + UserPromptSubmit blocking through.
|
|
51
|
+
const blockable = entry.blocking === true && event === 'PreToolUse';
|
|
52
|
+
const blocked = blockable && !result.ok;
|
|
53
|
+
if (blocked) {
|
|
54
|
+
anyBlocked = true;
|
|
55
|
+
result.blocked = true;
|
|
56
|
+
result.blockSentinel = `HOOK_BLOCKED: ${truncate(entry.command, 80)} exited ${result.exitCode}`;
|
|
57
|
+
}
|
|
58
|
+
if (logger && !result.ok) {
|
|
59
|
+
logger.recordFailure(event, entry.command, result);
|
|
60
|
+
}
|
|
61
|
+
results.push(result);
|
|
62
|
+
}
|
|
63
|
+
return { event, results, anyBlocked };
|
|
64
|
+
}
|
|
65
|
+
async function executeOne(command, payload, timeoutMs, env) {
|
|
66
|
+
const startedAt = Date.now();
|
|
67
|
+
return new Promise((resolvePromise) => {
|
|
68
|
+
const payloadJson = JSON.stringify(payload);
|
|
69
|
+
const childEnv = {
|
|
70
|
+
...(env ?? process.env),
|
|
71
|
+
PUGI_HOOK_PAYLOAD: payloadJson,
|
|
72
|
+
PUGI_HOOK_EVENT: payload.event,
|
|
73
|
+
PUGI_HOOK_SESSION_ID: payload.sessionId,
|
|
74
|
+
};
|
|
75
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
76
|
+
env: childEnv,
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
const state = {
|
|
80
|
+
stdout: '',
|
|
81
|
+
stderr: '',
|
|
82
|
+
killedForTimeout: false,
|
|
83
|
+
killedForStreamCap: false,
|
|
84
|
+
};
|
|
85
|
+
const escalateKill = () => {
|
|
86
|
+
if (state.sigKillTimer)
|
|
87
|
+
return;
|
|
88
|
+
state.sigKillTimer = setTimeout(() => {
|
|
89
|
+
if (!child.killed)
|
|
90
|
+
child.kill('SIGKILL');
|
|
91
|
+
}, SIGKILL_GRACE_MS);
|
|
92
|
+
if (state.sigKillTimer.unref)
|
|
93
|
+
state.sigKillTimer.unref();
|
|
94
|
+
};
|
|
95
|
+
const enforceStreamCap = () => {
|
|
96
|
+
if (state.killedForStreamCap)
|
|
97
|
+
return;
|
|
98
|
+
if (state.stdout.length + state.stderr.length <= HOOK_STREAM_CAP_BYTES)
|
|
99
|
+
return;
|
|
100
|
+
state.killedForStreamCap = true;
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
escalateKill();
|
|
103
|
+
};
|
|
104
|
+
child.stdout?.on('data', (chunk) => {
|
|
105
|
+
if (state.killedForStreamCap)
|
|
106
|
+
return;
|
|
107
|
+
state.stdout += chunk.toString('utf8');
|
|
108
|
+
enforceStreamCap();
|
|
109
|
+
});
|
|
110
|
+
child.stderr?.on('data', (chunk) => {
|
|
111
|
+
if (state.killedForStreamCap)
|
|
112
|
+
return;
|
|
113
|
+
state.stderr += chunk.toString('utf8');
|
|
114
|
+
enforceStreamCap();
|
|
115
|
+
});
|
|
116
|
+
// Best-effort stdin payload — hook scripts that want to read it can
|
|
117
|
+
// (e.g. `jq .`); scripts that ignore stdin will EPIPE on our write
|
|
118
|
+
// which we swallow because the env var carries the same data.
|
|
119
|
+
if (child.stdin) {
|
|
120
|
+
child.stdin.on('error', () => {
|
|
121
|
+
// EPIPE is benign — see above.
|
|
122
|
+
});
|
|
123
|
+
child.stdin.end(payloadJson);
|
|
124
|
+
}
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
state.killedForTimeout = true;
|
|
127
|
+
child.kill('SIGTERM');
|
|
128
|
+
escalateKill();
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
if (timer.unref)
|
|
131
|
+
timer.unref();
|
|
132
|
+
child.on('error', (error) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
if (state.sigKillTimer)
|
|
135
|
+
clearTimeout(state.sigKillTimer);
|
|
136
|
+
resolvePromise({
|
|
137
|
+
command: truncate(command, 200),
|
|
138
|
+
exitCode: -1,
|
|
139
|
+
stdoutBytes: state.stdout.length,
|
|
140
|
+
stderrBytes: state.stderr.length,
|
|
141
|
+
elapsedMs: Date.now() - startedAt,
|
|
142
|
+
ok: false,
|
|
143
|
+
blocked: false,
|
|
144
|
+
timedOut: false,
|
|
145
|
+
// No blockSentinel here — spawn errors are not the same as
|
|
146
|
+
// blocking-failure semantics. The caller logs them generically.
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
child.on('close', (code, signal) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
if (state.sigKillTimer)
|
|
152
|
+
clearTimeout(state.sigKillTimer);
|
|
153
|
+
let exitCode;
|
|
154
|
+
if (code !== null) {
|
|
155
|
+
exitCode = code;
|
|
156
|
+
}
|
|
157
|
+
else if (signal === 'SIGTERM') {
|
|
158
|
+
exitCode = -15;
|
|
159
|
+
}
|
|
160
|
+
else if (signal === 'SIGKILL') {
|
|
161
|
+
exitCode = -9;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
exitCode = -1;
|
|
165
|
+
}
|
|
166
|
+
const ok = exitCode === 0 &&
|
|
167
|
+
!state.killedForTimeout &&
|
|
168
|
+
!state.killedForStreamCap;
|
|
169
|
+
resolvePromise({
|
|
170
|
+
command: truncate(command, 200),
|
|
171
|
+
exitCode,
|
|
172
|
+
stdoutBytes: state.stdout.length,
|
|
173
|
+
stderrBytes: state.stderr.length,
|
|
174
|
+
elapsedMs: Date.now() - startedAt,
|
|
175
|
+
ok,
|
|
176
|
+
blocked: false,
|
|
177
|
+
timedOut: state.killedForTimeout,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Append-only failure log at `<workspaceRoot>/.pugi/logs/hooks.log`.
|
|
184
|
+
* Each line is a JSON record so log scrapers can `jq` over it.
|
|
185
|
+
*/
|
|
186
|
+
class HookLogger {
|
|
187
|
+
path;
|
|
188
|
+
prepared = false;
|
|
189
|
+
constructor(workspaceRoot) {
|
|
190
|
+
this.path = resolve(workspaceRoot, '.pugi', 'logs', 'hooks.log');
|
|
191
|
+
}
|
|
192
|
+
recordFailure(event, command, result) {
|
|
193
|
+
this.prepareDir();
|
|
194
|
+
const line = JSON.stringify({
|
|
195
|
+
ts: new Date().toISOString(),
|
|
196
|
+
event,
|
|
197
|
+
command: truncate(command, 200),
|
|
198
|
+
exitCode: result.exitCode,
|
|
199
|
+
timedOut: result.timedOut,
|
|
200
|
+
elapsedMs: result.elapsedMs,
|
|
201
|
+
stdoutBytes: result.stdoutBytes,
|
|
202
|
+
stderrBytes: result.stderrBytes,
|
|
203
|
+
toolEvent: isToolEvent(event),
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
appendFileSync(this.path, `${line}\n`, 'utf8');
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Logging is best-effort — the session must not crash when the
|
|
210
|
+
// disk is full or the directory is read-only. The runner has
|
|
211
|
+
// already returned the result; dropping the log line is the
|
|
212
|
+
// safe fallback.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
prepareDir() {
|
|
216
|
+
if (this.prepared)
|
|
217
|
+
return;
|
|
218
|
+
const dir = resolve(this.path, '..');
|
|
219
|
+
if (!existsSync(dir)) {
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(dir, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// ignored — appendFileSync will surface a fresh error on the
|
|
225
|
+
// write path, which we also swallow.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.prepared = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function truncate(value, max) {
|
|
232
|
+
if (value.length <= max)
|
|
233
|
+
return value;
|
|
234
|
+
return `${value.slice(0, max - 3)}...`;
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=runner.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-process LSP client cache — Leak L15.
|
|
3
|
+
*
|
|
4
|
+
* The α7.7 `runtime/commands/lsp.ts` CLI surface spawns one LSP server
|
|
5
|
+
* per invocation and stops it at the end. That is correct for the
|
|
6
|
+
* one-shot `pugi lsp hover ...` shape but wrong for L15's
|
|
7
|
+
* post-edit auto-diagnostics: every successful `edit`/`write` would
|
|
8
|
+
* otherwise pay the ~2-3s cold-start of `typescript-language-server`,
|
|
9
|
+
* which is unusable inside an agent loop.
|
|
10
|
+
*
|
|
11
|
+
* This module owns a singleton map keyed by `LspLanguage` with lazy
|
|
12
|
+
* initialization (`getOrStart`). The first edit of a TS file in a
|
|
13
|
+
* session pays cold-start; every subsequent edit of any TS/TSX file
|
|
14
|
+
* in the same workspace reuses the warm client.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle:
|
|
17
|
+
* - `getOrStart(lang, cwd)` — spawn if missing, return cached otherwise.
|
|
18
|
+
* - `stopAll()` — graceful shutdown of every cached client. Called from
|
|
19
|
+
* `runCli` exit so a Ctrl-C never leaves zombie LSP processes behind.
|
|
20
|
+
* - `reset()` — test-only escape hatch, drops the cache without
|
|
21
|
+
* touching child processes (specs inject stubs that own their own
|
|
22
|
+
* lifecycle).
|
|
23
|
+
*
|
|
24
|
+
* Failure handling: a startup failure is NOT cached. The next call
|
|
25
|
+
* tries again. This keeps the cache from poisoning a session when the
|
|
26
|
+
* operator installs the missing LSP binary mid-session and re-edits.
|
|
27
|
+
*
|
|
28
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
29
|
+
*/
|
|
30
|
+
import { isLspLanguageDisabled, startLspClient, } from './client.js';
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Return a warm client for `lang`, starting one if needed. The
|
|
34
|
+
* workspace `cwd` is captured at cache-insert time; if a subsequent
|
|
35
|
+
* call asks for the same language with a different `cwd` we tear
|
|
36
|
+
* down the old client and start a fresh one. This handles the
|
|
37
|
+
* agent-worktree case where the same process hops between workspace
|
|
38
|
+
* roots inside one Node lifetime.
|
|
39
|
+
*/
|
|
40
|
+
export async function getOrStartLspClient(lang, opts) {
|
|
41
|
+
// β7 L9: respect the per-language disable toggle BEFORE we attempt to
|
|
42
|
+
// spawn. The check is cheap and keeps the disabled path from paying
|
|
43
|
+
// the `npx --yes` warmup cost on first use.
|
|
44
|
+
if (isLspLanguageDisabled(lang, opts.lspSettings)) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
reason: 'lsp_disabled',
|
|
48
|
+
detail: `${lang} is disabled via .pugi/settings.json::lsp`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const existing = cache.get(lang);
|
|
52
|
+
if (existing && existing.cwd === opts.cwd) {
|
|
53
|
+
return { ok: true, client: existing.client };
|
|
54
|
+
}
|
|
55
|
+
if (existing && existing.cwd !== opts.cwd) {
|
|
56
|
+
// Workspace switched — stop the old client and fall through to spawn.
|
|
57
|
+
try {
|
|
58
|
+
await existing.client.stop();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// best effort; stop() is idempotent + swallow-safe
|
|
62
|
+
}
|
|
63
|
+
cache.delete(lang);
|
|
64
|
+
}
|
|
65
|
+
const result = await startLspClient(lang, opts);
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
return { ok: false, reason: result.reason, detail: result.detail };
|
|
68
|
+
}
|
|
69
|
+
cache.set(lang, { client: result.value, cwd: opts.cwd });
|
|
70
|
+
return { ok: true, client: result.value };
|
|
71
|
+
}
|
|
72
|
+
/** Look up the cached client without starting one. Returns undefined when missing. */
|
|
73
|
+
export function peekLspClient(lang) {
|
|
74
|
+
return cache.get(lang)?.client;
|
|
75
|
+
}
|
|
76
|
+
/** Snapshot of currently-cached languages — used by `pugi lsp status` debug output. */
|
|
77
|
+
export function listCachedLanguages() {
|
|
78
|
+
return Array.from(cache.keys());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stop every cached client and clear the cache. Called from `runCli`
|
|
82
|
+
* exit and from specs that own the lifecycle of their stub servers.
|
|
83
|
+
*/
|
|
84
|
+
export async function stopAllLspClients() {
|
|
85
|
+
const snapshot = Array.from(cache.values());
|
|
86
|
+
cache.clear();
|
|
87
|
+
await Promise.all(snapshot.map(async (entry) => {
|
|
88
|
+
try {
|
|
89
|
+
await entry.client.stop();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// best effort — shutting down anyway
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Test-only: drop the cache map WITHOUT calling stop on the children.
|
|
98
|
+
* Specs that inject stub servers manage the stub lifecycle themselves;
|
|
99
|
+
* this lets a spec swap a stub mid-test without the cache holding a
|
|
100
|
+
* stale reference to a torn-down process.
|
|
101
|
+
*/
|
|
102
|
+
export function __resetLspCacheForTests() {
|
|
103
|
+
cache.clear();
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-from-extension detection — Leak L15.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "given a file path, which `LspLanguage`
|
|
5
|
+
* slug do we route to". The α7.7 `runtime/commands/lsp.ts` shipped its
|
|
6
|
+
* own inline `inferLanguage` switch; L15 (post-edit auto-diagnostics)
|
|
7
|
+
* needs the same lookup from `core/engine/tool-bridge.ts`, so we lift
|
|
8
|
+
* the table into a dedicated module to avoid a second copy drifting
|
|
9
|
+
* out of sync.
|
|
10
|
+
*
|
|
11
|
+
* Returning `undefined` is the calling code's signal to silently skip
|
|
12
|
+
* LSP — an unsupported extension is NOT an error, it just means "no
|
|
13
|
+
* diagnostics for this file". The tool-bridge hook treats this as a
|
|
14
|
+
* no-op envelope tail.
|
|
15
|
+
*
|
|
16
|
+
* Adding a new language requires THREE coordinated changes:
|
|
17
|
+
* 1. Add the `LspLanguage` slug + server descriptor in `client.ts`.
|
|
18
|
+
* 2. Map its extensions here.
|
|
19
|
+
* 3. Add a `lsp-language-matrix` spec row exercising the new ext.
|
|
20
|
+
*
|
|
21
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
22
|
+
*/
|
|
23
|
+
import { extname } from 'node:path';
|
|
24
|
+
/**
|
|
25
|
+
* Lower-case extension (including the dot) → LSP language slug.
|
|
26
|
+
* Mirror of the switch in `runtime/commands/lsp.ts::inferLanguage`.
|
|
27
|
+
* The table form lets tests assert coverage and lets new languages
|
|
28
|
+
* land with one edit instead of two.
|
|
29
|
+
*/
|
|
30
|
+
export const EXTENSION_TO_LANGUAGE = {
|
|
31
|
+
'.ts': 'ts',
|
|
32
|
+
'.tsx': 'ts',
|
|
33
|
+
'.mts': 'ts',
|
|
34
|
+
'.cts': 'ts',
|
|
35
|
+
'.js': 'js',
|
|
36
|
+
'.jsx': 'js',
|
|
37
|
+
'.mjs': 'js',
|
|
38
|
+
'.cjs': 'js',
|
|
39
|
+
'.py': 'py',
|
|
40
|
+
'.pyi': 'py',
|
|
41
|
+
'.go': 'go',
|
|
42
|
+
'.rs': 'rust',
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Infer the `LspLanguage` for a workspace-relative or absolute path.
|
|
46
|
+
* Returns `undefined` for unmapped extensions — the caller decides
|
|
47
|
+
* whether that is silently skipped (post-edit hook) or surfaced as
|
|
48
|
+
* `language_unsupported` (`pugi lsp` CLI).
|
|
49
|
+
*/
|
|
50
|
+
export function languageForFile(file) {
|
|
51
|
+
const ext = extname(file).toLowerCase();
|
|
52
|
+
if (!ext)
|
|
53
|
+
return undefined;
|
|
54
|
+
return EXTENSION_TO_LANGUAGE[ext];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Return every extension currently mapped to the given language.
|
|
58
|
+
* Used by the matrix spec to assert coverage without re-typing the
|
|
59
|
+
* extension list.
|
|
60
|
+
*/
|
|
61
|
+
export function extensionsForLanguage(lang) {
|
|
62
|
+
return Object.entries(EXTENSION_TO_LANGUAGE)
|
|
63
|
+
.filter(([, value]) => value === lang)
|
|
64
|
+
.map(([ext]) => ext);
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=language-detect.js.map
|
|
@@ -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
|
|
@@ -1233,6 +1233,41 @@ export class ReplSession {
|
|
|
1233
1233
|
}
|
|
1234
1234
|
return verdict;
|
|
1235
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
|
+
}
|
|
1236
1271
|
case 'stub': {
|
|
1237
1272
|
this.appendSystemLine(verdict.message);
|
|
1238
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' },
|
|
@@ -462,6 +463,15 @@ export function parseSlashCommand(input) {
|
|
|
462
463
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
463
464
|
return { kind: 'share', args: tokens };
|
|
464
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
|
+
}
|
|
465
475
|
case 'release-notes':
|
|
466
476
|
case 'releasenotes':
|
|
467
477
|
case 'changelog': {
|