@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.
- 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/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/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/repl/session.js +73 -1
- package/dist/core/repl/slash-commands.js +20 -0
- package/dist/core/repl/store/session-store.js +31 -2
- 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/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/runtime/cli.js +216 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/repo-map.js +95 -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/package.json +2 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default byte cap for the engine system-prompt injection. The L28
|
|
3
|
+
* spec calls for 2000 tokens; conservatively that is ~8 KB of UTF-8
|
|
4
|
+
* (rough Claude tokeniser ratio: ~4 chars per token). We cap at 8 KB
|
|
5
|
+
* so the formatted block stays under the token budget across every
|
|
6
|
+
* supported model family without per-model accounting.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_FORMAT_BYTES_CAP = 8 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum symbols per row. The engine row format is:
|
|
11
|
+
*
|
|
12
|
+
* `- path/to/file.ts — summary line — exports: Foo(class), bar(fn)`
|
|
13
|
+
*
|
|
14
|
+
* Beyond 6 symbols the row grows past the readable column budget and
|
|
15
|
+
* the additional names rarely move the needle for the model — the
|
|
16
|
+
* exports tail is signal-bearing only for the first few entries
|
|
17
|
+
* anyway (`index.ts` re-exports tend к pile up).
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_SYMBOLS_PER_ROW = 6;
|
|
20
|
+
/**
|
|
21
|
+
* Render the repo-map text. The implementation is intentionally split
|
|
22
|
+
* into:
|
|
23
|
+
*
|
|
24
|
+
* - `prioritise(...)` — pure sort + filter, fully testable in
|
|
25
|
+
* isolation, no I/O of any kind.
|
|
26
|
+
* - `renderRow(...)` — one file's row, byte-counted.
|
|
27
|
+
* - main loop — assembles header + rows + footer, respecting cap.
|
|
28
|
+
*
|
|
29
|
+
* The split lets the spec assert each stage in isolation (priority
|
|
30
|
+
* order, single-row shape, truncation arithmetic).
|
|
31
|
+
*/
|
|
32
|
+
export function formatRepoMap(extracts, options = {}) {
|
|
33
|
+
const maxBytes = options.maxBytes ?? DEFAULT_FORMAT_BYTES_CAP;
|
|
34
|
+
const omitHeader = options.omitHeader === true;
|
|
35
|
+
const prioritised = prioritise(extracts);
|
|
36
|
+
const header = omitHeader
|
|
37
|
+
? ''
|
|
38
|
+
: `## Repo map\n\n${prioritised.length} source files indexed.\n\n`;
|
|
39
|
+
const headerBytes = byteLength(header);
|
|
40
|
+
if (headerBytes >= maxBytes) {
|
|
41
|
+
// Cap is smaller than even the header — emit nothing rather than
|
|
42
|
+
// a truncated header that the engine cannot parse.
|
|
43
|
+
return {
|
|
44
|
+
text: '',
|
|
45
|
+
filesIncluded: 0,
|
|
46
|
+
filesTotal: extracts.length,
|
|
47
|
+
bytes: 0,
|
|
48
|
+
truncated: extracts.length > 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const rows = [];
|
|
52
|
+
let bytesUsed = headerBytes;
|
|
53
|
+
let filesIncluded = 0;
|
|
54
|
+
let truncated = false;
|
|
55
|
+
for (let i = 0; i < prioritised.length; i += 1) {
|
|
56
|
+
const row = renderRow(prioritised[i]);
|
|
57
|
+
const rowBytes = byteLength(row);
|
|
58
|
+
// Reserve space for the footer (`\n... N more files\n`). We
|
|
59
|
+
// overestimate at 64 bytes — the exact number depends on the
|
|
60
|
+
// file count digits but 64 covers any realistic case.
|
|
61
|
+
const footerReserve = i + 1 < prioritised.length ? 64 : 0;
|
|
62
|
+
if (bytesUsed + rowBytes + footerReserve > maxBytes) {
|
|
63
|
+
truncated = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
rows.push(row);
|
|
67
|
+
bytesUsed += rowBytes;
|
|
68
|
+
filesIncluded += 1;
|
|
69
|
+
}
|
|
70
|
+
let text = header + rows.join('');
|
|
71
|
+
if (truncated) {
|
|
72
|
+
const omitted = prioritised.length - filesIncluded;
|
|
73
|
+
text += `\n... ${omitted} more file${omitted === 1 ? '' : 's'}\n`;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
filesIncluded,
|
|
78
|
+
filesTotal: extracts.length,
|
|
79
|
+
bytes: byteLength(text),
|
|
80
|
+
truncated,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/* ----------------------------- helpers ----------------------------- */
|
|
84
|
+
/**
|
|
85
|
+
* Sort the extracts by (exported-symbol count desc, path asc). The
|
|
86
|
+
* engine cares about the public surface; a file with 12 exported
|
|
87
|
+
* symbols carries more signal than 50 private helpers.
|
|
88
|
+
*/
|
|
89
|
+
export function prioritise(extracts) {
|
|
90
|
+
return [...extracts].sort((a, b) => {
|
|
91
|
+
const expA = countExports(a);
|
|
92
|
+
const expB = countExports(b);
|
|
93
|
+
if (expA !== expB)
|
|
94
|
+
return expB - expA;
|
|
95
|
+
return a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function countExports(extract) {
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (const sym of extract.symbols)
|
|
101
|
+
if (sym.exported)
|
|
102
|
+
n += 1;
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Render a single file row. Format:
|
|
107
|
+
*
|
|
108
|
+
* `- path/to/file.ts — summary — exports: Foo(class), bar(fn), Baz(type)`
|
|
109
|
+
*
|
|
110
|
+
* When there are no exported symbols, the `exports:` tail is dropped.
|
|
111
|
+
* When there is no summary, the dash separator is dropped.
|
|
112
|
+
*/
|
|
113
|
+
export function renderRow(extract) {
|
|
114
|
+
const exported = extract.symbols.filter((s) => s.exported);
|
|
115
|
+
const symbolsTail = exported.length > 0
|
|
116
|
+
? ` — exports: ${formatSymbolList(exported.slice(0, MAX_SYMBOLS_PER_ROW))}`
|
|
117
|
+
: '';
|
|
118
|
+
const summaryTail = extract.summary ? ` — ${extract.summary}` : '';
|
|
119
|
+
return `- ${extract.relPath}${summaryTail}${symbolsTail}\n`;
|
|
120
|
+
}
|
|
121
|
+
function formatSymbolList(symbols) {
|
|
122
|
+
return symbols.map((s) => `${s.name}(${shortKind(s.kind)})`).join(', ');
|
|
123
|
+
}
|
|
124
|
+
function shortKind(kind) {
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case 'function':
|
|
127
|
+
return 'fn';
|
|
128
|
+
case 'class':
|
|
129
|
+
return 'class';
|
|
130
|
+
case 'interface':
|
|
131
|
+
return 'iface';
|
|
132
|
+
case 'type':
|
|
133
|
+
return 'type';
|
|
134
|
+
case 'enum':
|
|
135
|
+
return 'enum';
|
|
136
|
+
case 'const':
|
|
137
|
+
return 'const';
|
|
138
|
+
case 'heading':
|
|
139
|
+
return 'h';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function byteLength(s) {
|
|
143
|
+
return Buffer.byteLength(s, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=formatter.js.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-map scanner — Leak L28 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Walks the workspace via `fs.readdirSync` (sync, depth-first), filters
|
|
5
|
+
* to a recognised set of source-language extensions, and applies the
|
|
6
|
+
* shared `PugiIgnore` matcher so the same exclusion rules used by the
|
|
7
|
+
* three-tier context skeleton also gate the repo-map.
|
|
8
|
+
*
|
|
9
|
+
* Why a stand-alone scanner (vs. reusing the α6.5 skeleton walker):
|
|
10
|
+
*
|
|
11
|
+
* 1. The skeleton walker emits a flat `IndexArtifact[]` of every
|
|
12
|
+
* ignore-respecting file (markdown, configs, schemas, etc.) for
|
|
13
|
+
* the working-set heuristic. The repo-map ONLY needs source
|
|
14
|
+
* files — markdown headings and JSON keys are not "definitions"
|
|
15
|
+
* in the L28 sense. Filtering downstream is cheap, but the
|
|
16
|
+
* scanner gets to short-circuit on extension before stat'ing
|
|
17
|
+
* the file, which matters for monorepos with thousands of
|
|
18
|
+
* non-source artefacts (lockfiles, schemas, fixtures).
|
|
19
|
+
*
|
|
20
|
+
* 2. We need mtime + size per file so `cache.ts` can invalidate
|
|
21
|
+
* stale entries without re-parsing. The skeleton walker
|
|
22
|
+
* surfaces only paths.
|
|
23
|
+
*
|
|
24
|
+
* 3. The L28 contract caps the walk at `MAX_SRC_FILES` (5000) and
|
|
25
|
+
* individual files at `MAX_FILE_BYTES` (200 KiB). When the cap
|
|
26
|
+
* trips the scanner returns a `{ skipped: 'too-large' }`
|
|
27
|
+
* verdict rather than partial data — the consumer must decide
|
|
28
|
+
* whether to fall back to a no-op map or surface a hint к the
|
|
29
|
+
* operator. Surfacing partial data would silently bias the
|
|
30
|
+
* injected summary toward whichever subtree the walker happened
|
|
31
|
+
* to traverse first.
|
|
32
|
+
*
|
|
33
|
+
* The output is sorted (POSIX path string compare) so two runs over
|
|
34
|
+
* the same workspace produce byte-identical `repo-map.json` caches —
|
|
35
|
+
* `cache.ts` relies on stable ordering for its hash-free freshness
|
|
36
|
+
* check. POSIX-style separators are used in `relPath` regardless of
|
|
37
|
+
* platform so the cache file stays portable.
|
|
38
|
+
*
|
|
39
|
+
* Pure module surface: no logging, no network. Errors during readdir
|
|
40
|
+
* on a single subtree (permission denied, symlink loop) are swallowed
|
|
41
|
+
* and the walker continues — repo-map is a best-effort context
|
|
42
|
+
* enrichment, never a gate.
|
|
43
|
+
*/
|
|
44
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
45
|
+
import { join, posix, relative, resolve, sep } from 'node:path';
|
|
46
|
+
/**
|
|
47
|
+
* Hard ceiling on total source files surfaced by a single scan. The
|
|
48
|
+
* engine context budget is the binding constraint — a 5K-file repo
|
|
49
|
+
* already overflows the 2K-token injection cap so going higher buys
|
|
50
|
+
* nothing but walker latency. Repos above the cap fall back к the
|
|
51
|
+
* `{ skipped: 'too-large' }` verdict.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_SRC_FILES = 5000;
|
|
54
|
+
/**
|
|
55
|
+
* Per-file size cap. Files larger than this are skipped — they are
|
|
56
|
+
* almost always generated (compiled JS, vendored libs, encoded blobs)
|
|
57
|
+
* and add noise without signal. The 200 KiB threshold mirrors the
|
|
58
|
+
* α6.5 skeleton walker's own `MAX_FILE_BYTES` so the two scans agree
|
|
59
|
+
* on "what counts as a source file".
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_FILE_BYTES = 200 * 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Source-language extensions the extractor knows how to parse. Adding
|
|
64
|
+
* a language here without a matching extractor branch is a silent
|
|
65
|
+
* no-op (the file shows up в the scan but extracts zero symbols);
|
|
66
|
+
* the spec asserts the symmetry so a future PR cannot drift the two
|
|
67
|
+
* lists out of sync.
|
|
68
|
+
*/
|
|
69
|
+
export const SUPPORTED_EXTENSIONS = Object.freeze([
|
|
70
|
+
'.ts',
|
|
71
|
+
'.tsx',
|
|
72
|
+
'.js',
|
|
73
|
+
'.jsx',
|
|
74
|
+
'.mjs',
|
|
75
|
+
'.cjs',
|
|
76
|
+
'.md',
|
|
77
|
+
'.mdx',
|
|
78
|
+
]);
|
|
79
|
+
const defaultReaddir = (path) => readdirSync(path, { withFileTypes: true });
|
|
80
|
+
const defaultStat = (path) => {
|
|
81
|
+
const s = statSync(path);
|
|
82
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Walk the workspace once and return every source file the extractor
|
|
86
|
+
* is willing to parse. The function is deliberately synchronous —
|
|
87
|
+
* the underlying walks are CPU-bound, не I/O-bound, and the sync
|
|
88
|
+
* call avoids the promise overhead that dominates for thousands of
|
|
89
|
+
* small files. The L28 engine boot path runs this on a Node `setImmediate`
|
|
90
|
+
* so the main thread is not blocked.
|
|
91
|
+
*/
|
|
92
|
+
export function scanRepoForMap(options) {
|
|
93
|
+
const root = resolve(options.root);
|
|
94
|
+
const readdir = options.readdir ?? defaultReaddir;
|
|
95
|
+
const stat = options.stat ?? defaultStat;
|
|
96
|
+
const maxFiles = options.maxFiles ?? MAX_SRC_FILES;
|
|
97
|
+
const maxFileBytes = options.maxFileBytes ?? MAX_FILE_BYTES;
|
|
98
|
+
const ignore = options.ignore;
|
|
99
|
+
const files = [];
|
|
100
|
+
let walked = 0;
|
|
101
|
+
let skippedLarge = 0;
|
|
102
|
+
let skippedIgnored = 0;
|
|
103
|
+
let tooLarge = false;
|
|
104
|
+
/**
|
|
105
|
+
* Depth-first recursion. We push dirs into a manual stack instead of
|
|
106
|
+
* recursing in JS because deep monorepos (Nx with 100+ packages)
|
|
107
|
+
* have approached the v8 default stack limit on Windows runners
|
|
108
|
+
* before; an explicit stack is one less thing to debug.
|
|
109
|
+
*/
|
|
110
|
+
const stack = [root];
|
|
111
|
+
while (stack.length > 0) {
|
|
112
|
+
const dir = stack.pop();
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = readdir(dir);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Permission denied / symlink loop / mid-flight delete — keep
|
|
119
|
+
// walking. Repo-map is best-effort context, never a gate.
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const abs = join(dir, entry.name);
|
|
124
|
+
const isDir = entry.isDirectory();
|
|
125
|
+
if (ignore.isIgnored(abs, isDir)) {
|
|
126
|
+
skippedIgnored += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (isDir) {
|
|
130
|
+
stack.push(abs);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!entry.isFile()) {
|
|
134
|
+
// Symlinks, sockets, FIFOs etc. Skip silently — they are not
|
|
135
|
+
// source code and stat'ing them can throw on broken links.
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
walked += 1;
|
|
139
|
+
const ext = extOf(entry.name);
|
|
140
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
let statResult;
|
|
144
|
+
try {
|
|
145
|
+
statResult = stat(abs);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// File vanished between readdir and stat — skip.
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (statResult.size > maxFileBytes) {
|
|
152
|
+
skippedLarge += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Workspace-relative POSIX path. `relative` returns the host
|
|
156
|
+
// separator on Windows; normalise to forward slashes so the
|
|
157
|
+
// cache file is portable.
|
|
158
|
+
const rel = relative(root, abs).split(sep).join(posix.sep);
|
|
159
|
+
files.push({
|
|
160
|
+
relPath: rel,
|
|
161
|
+
absPath: abs,
|
|
162
|
+
ext,
|
|
163
|
+
sizeBytes: statResult.size,
|
|
164
|
+
mtimeMs: statResult.mtimeMs,
|
|
165
|
+
});
|
|
166
|
+
if (files.length > maxFiles) {
|
|
167
|
+
tooLarge = true;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (tooLarge)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
if (tooLarge) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
root,
|
|
178
|
+
skipped: {
|
|
179
|
+
reason: 'too-large',
|
|
180
|
+
walked,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// Sort by POSIX path for stable cache output. Two runs over the
|
|
185
|
+
// same workspace yield byte-identical JSON so the cache hash check
|
|
186
|
+
// is a simple `mtime + size` per entry without a content digest.
|
|
187
|
+
files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
root,
|
|
191
|
+
files,
|
|
192
|
+
stats: {
|
|
193
|
+
walked,
|
|
194
|
+
kept: files.length,
|
|
195
|
+
skippedLarge,
|
|
196
|
+
skippedIgnored,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Lowercase extension including the leading dot, or '' when the
|
|
202
|
+
* filename has no extension. Mirrors `node:path.extname` semantics —
|
|
203
|
+
* inlined so the scanner has zero per-iteration call overhead.
|
|
204
|
+
*/
|
|
205
|
+
function extOf(name) {
|
|
206
|
+
const dot = name.lastIndexOf('.');
|
|
207
|
+
if (dot < 0 || dot === 0)
|
|
208
|
+
return '';
|
|
209
|
+
return name.slice(dot).toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=scanner.js.map
|
package/dist/core/session.js
CHANGED
|
@@ -18,6 +18,50 @@ export function openSession(root) {
|
|
|
18
18
|
enabled,
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Leak L12 MVP — fire the `SessionStart` lifecycle event for all hooks
|
|
23
|
+
* declared in `~/.pugi/hooks-mvp.json`. Single-call surface; the REPL
|
|
24
|
+
* boot path invokes this once after `openSession`. Best-effort: any
|
|
25
|
+
* failure (missing config, hook spawn error) is swallowed so a
|
|
26
|
+
* misconfigured hook can never crash the REPL.
|
|
27
|
+
*
|
|
28
|
+
* Returns the number of hooks that fired (0 when no config / no
|
|
29
|
+
* matching hooks). Tests assert on the return value as the
|
|
30
|
+
* single-call invariant.
|
|
31
|
+
*/
|
|
32
|
+
export async function fireSessionStartMvp(session) {
|
|
33
|
+
try {
|
|
34
|
+
const { loadHooksConfig, fireHooks } = await import('./hooks/index.js');
|
|
35
|
+
// Defense-in-depth: `loadHooksConfig` is contractually non-null
|
|
36
|
+
// (returns `HooksConfig.empty(path)` when the file is absent), but
|
|
37
|
+
// the dynamic import boundary above can in principle return an
|
|
38
|
+
// unexpected shape if the module is mis-resolved at runtime. Guard
|
|
39
|
+
// the optional-chained `isEmpty()` call so a malformed loader can
|
|
40
|
+
// never raise `TypeError: Cannot read properties of undefined` and
|
|
41
|
+
// crash the REPL boot path. Belt-and-suspenders with the
|
|
42
|
+
// surrounding try/catch — the catch still swallows everything else.
|
|
43
|
+
const config = loadHooksConfig();
|
|
44
|
+
if (!config || config.isEmpty())
|
|
45
|
+
return 0;
|
|
46
|
+
const outcome = await fireHooks({
|
|
47
|
+
config,
|
|
48
|
+
event: 'SessionStart',
|
|
49
|
+
payload: {
|
|
50
|
+
event: 'SessionStart',
|
|
51
|
+
sessionId: session.id,
|
|
52
|
+
workspaceRoot: session.root,
|
|
53
|
+
startedAt: new Date().toISOString(),
|
|
54
|
+
},
|
|
55
|
+
workspaceRoot: session.root,
|
|
56
|
+
});
|
|
57
|
+
return outcome.results.length;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// SessionStart is never blocking — log nothing, return 0. A
|
|
61
|
+
// broken `hooks-mvp.json` is surfaced via `pugi hooks doctor`.
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
21
65
|
export function recordCommandStarted(session, command) {
|
|
22
66
|
if (!session.enabled)
|
|
23
67
|
return;
|
package/dist/core/settings.js
CHANGED
|
@@ -88,6 +88,15 @@ const pugiSettingsSchema = z.object({
|
|
|
88
88
|
python: z.boolean().optional(),
|
|
89
89
|
go: z.boolean().optional(),
|
|
90
90
|
rust: z.boolean().optional(),
|
|
91
|
+
// Leak L15 (2026-05-27): post-edit auto-diagnostics. When `true`,
|
|
92
|
+
// a successful `edit`/`write`/`multi_edit` triggers a diagnostic
|
|
93
|
+
// pull on the touched file(s) and the result is appended to the
|
|
94
|
+
// tool envelope so the model can self-correct in the same turn.
|
|
95
|
+
// Off by default — the cold-start of `typescript-language-server`
|
|
96
|
+
// is heavy enough that we opt in explicitly until dogfood proves
|
|
97
|
+
// the throughput trade is worth it. Also enabled via env var
|
|
98
|
+
// `PUGI_LSP_POST_EDIT=1` for CI / one-off operator probes.
|
|
99
|
+
postEditDiagnostics: z.boolean().optional(),
|
|
91
100
|
})
|
|
92
101
|
.optional(),
|
|
93
102
|
// β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry emitter — Wave 6 BIG TRACK 11 (PR-PUGI-OBSERVABILITY-STACK).
|
|
3
|
+
*
|
|
4
|
+
* Single entry point used by the REPL + slash dispatcher + tool runner
|
|
5
|
+
* to record CLI lifecycle events. Honours the L25 telemetry-state
|
|
6
|
+
* consent verdict — events are dropped silently when the operator chose
|
|
7
|
+
* `off` (default) and accepted into the queue when they chose
|
|
8
|
+
* `anonymous` or `community`.
|
|
9
|
+
*
|
|
10
|
+
* Opt-out hatches (any one of them stops the emitter cold):
|
|
11
|
+
*
|
|
12
|
+
* 1. `~/.pugi/.telemetry-disabled` marker file (per-user kill switch
|
|
13
|
+
* the operator can drop with `touch` even when their config got
|
|
14
|
+
* corrupted). Hot-path: we stat() this once at boot AND on every
|
|
15
|
+
* emit so an emergency operator gesture takes effect immediately
|
|
16
|
+
* without a REPL restart.
|
|
17
|
+
* 2. `PUGI_TELEMETRY=0` (env). Honoured at every emit — CI scripts
|
|
18
|
+
* can wrap a one-shot invocation without touching the user's
|
|
19
|
+
* config.
|
|
20
|
+
* 3. L25 `~/.pugi/config.json::telemetry === 'off'`. Default for
|
|
21
|
+
* fresh installs — the onboarding wizard flips it to `anonymous`
|
|
22
|
+
* or `community` when the operator says yes.
|
|
23
|
+
*
|
|
24
|
+
* The emitter is fire-and-forget. Calls never throw, never block the
|
|
25
|
+
* caller, and never await the network. The queue persists events to a
|
|
26
|
+
* JSONL spill so a synchronous CLI process can exit before a network
|
|
27
|
+
* round-trip completes without losing data.
|
|
28
|
+
*
|
|
29
|
+
* Allowlisted meta keys — call sites MUST pass only the canonical keys
|
|
30
|
+
* (see `META_ALLOWLIST` below). Unknown keys are dropped at this layer
|
|
31
|
+
* AND again at the admin-api ingest layer (defence in depth).
|
|
32
|
+
*/
|
|
33
|
+
import { statSync } from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
import { readTelemetryChoice, } from '../onboarding/telemetry-state.js';
|
|
37
|
+
import { PUGI_CLI_VERSION } from '../../runtime/version.js';
|
|
38
|
+
import { newSessionId, spillEvent, } from './queue.js';
|
|
39
|
+
/**
|
|
40
|
+
* Allowed meta keys. Matches the admin-api allowlist verbatim — the two
|
|
41
|
+
* lists MUST stay in sync. The server is the structural wall; this is
|
|
42
|
+
* the defence-in-depth at the source. Anything off-list is dropped
|
|
43
|
+
* before the event lands on disk so a CI grep of the spill file never
|
|
44
|
+
* surfaces a key that was never supposed to leave the process.
|
|
45
|
+
*/
|
|
46
|
+
export const META_ALLOWLIST = new Set([
|
|
47
|
+
'platform',
|
|
48
|
+
'arch',
|
|
49
|
+
'nodeVersion',
|
|
50
|
+
'tier',
|
|
51
|
+
'consent',
|
|
52
|
+
'pty',
|
|
53
|
+
'durationCategory',
|
|
54
|
+
'exitCode',
|
|
55
|
+
'parentCommand',
|
|
56
|
+
'subagent',
|
|
57
|
+
'modelTier',
|
|
58
|
+
'cacheHit',
|
|
59
|
+
'retryCount',
|
|
60
|
+
'quotaTier',
|
|
61
|
+
'tunedModel',
|
|
62
|
+
'flagsHash',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* Single source-of-truth marker the operator can `touch` to kill all
|
|
66
|
+
* telemetry from a single user account in one gesture. Bypasses the
|
|
67
|
+
* config file — survives a corrupt `config.json` and is documented in
|
|
68
|
+
* `pugi help telemetry`.
|
|
69
|
+
*/
|
|
70
|
+
export const KILL_SWITCH_MARKER = '.telemetry-disabled';
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the kill-switch marker path. Pure — exposed for tests.
|
|
73
|
+
*/
|
|
74
|
+
export function killSwitchPath(env = process.env) {
|
|
75
|
+
const home = env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
76
|
+
return resolve(home, KILL_SWITCH_MARKER);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Is the kill switch armed? Stat the marker on every emit so an
|
|
80
|
+
* operator gesture (e.g. `touch ~/.pugi/.telemetry-disabled`) takes
|
|
81
|
+
* effect immediately without a REPL restart.
|
|
82
|
+
*/
|
|
83
|
+
export function isKillSwitchArmed(env = process.env) {
|
|
84
|
+
const path = killSwitchPath(env);
|
|
85
|
+
try {
|
|
86
|
+
statSync(path);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Is the env-var opt-out set? Honours common falsy literals so a CI
|
|
95
|
+
* matrix that exports `PUGI_TELEMETRY=false` or `=no` does the right
|
|
96
|
+
* thing.
|
|
97
|
+
*/
|
|
98
|
+
export function isEnvDisabled(env = process.env) {
|
|
99
|
+
const raw = (env.PUGI_TELEMETRY ?? '').trim().toLowerCase();
|
|
100
|
+
if (raw === '')
|
|
101
|
+
return false;
|
|
102
|
+
return raw === '0' || raw === 'false' || raw === 'off' || raw === 'no';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the current consent verdict. Cached per-call (no module-level
|
|
106
|
+
* caching) so the onboarding wizard's flip from `off` → `anonymous`
|
|
107
|
+
* takes effect immediately without a REPL restart.
|
|
108
|
+
*/
|
|
109
|
+
export function currentConsent(ctx = {}) {
|
|
110
|
+
return readTelemetryChoice({ env: ctx.env });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Strip unknown keys + truncate long string values. Mirrors the server
|
|
114
|
+
* sanitiser. Cap is intentionally smaller here (256) than on the wire
|
|
115
|
+
* (512) — keeps the spill file lean on a long-offline laptop.
|
|
116
|
+
*/
|
|
117
|
+
const META_VALUE_CAP = 256;
|
|
118
|
+
export function sanitiseMeta(value) {
|
|
119
|
+
const out = {};
|
|
120
|
+
if (!value || typeof value !== 'object')
|
|
121
|
+
return out;
|
|
122
|
+
let kept = 0;
|
|
123
|
+
for (const [k, v] of Object.entries(value)) {
|
|
124
|
+
if (kept >= 16)
|
|
125
|
+
break;
|
|
126
|
+
if (!META_ALLOWLIST.has(k))
|
|
127
|
+
continue;
|
|
128
|
+
if (v === null || v === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
if (typeof v === 'string') {
|
|
131
|
+
out[k] = v.length > META_VALUE_CAP ? v.slice(0, META_VALUE_CAP) : v;
|
|
132
|
+
}
|
|
133
|
+
else if (typeof v === 'number' && Number.isFinite(v)) {
|
|
134
|
+
out[k] = v;
|
|
135
|
+
}
|
|
136
|
+
else if (typeof v === 'boolean') {
|
|
137
|
+
out[k] = v;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
kept += 1;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Per-process session id. Allocated lazily on first `emit(...)` so a
|
|
148
|
+
* CLI invocation that never fires telemetry never burns a UUID.
|
|
149
|
+
*/
|
|
150
|
+
let cachedSessionId = null;
|
|
151
|
+
/**
|
|
152
|
+
* Reset the cached session id. Called by tests AND the REPL `/reset`
|
|
153
|
+
* path so a long-running REPL can rotate sessions on demand.
|
|
154
|
+
*/
|
|
155
|
+
export function resetSessionId() {
|
|
156
|
+
cachedSessionId = null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build the outbound event from `EmitInput` + the resolved consent
|
|
160
|
+
* tier. Pure — exposed for spec parity.
|
|
161
|
+
*/
|
|
162
|
+
export function buildEvent(input, consent, sessionId) {
|
|
163
|
+
const kind = input.kind ?? 'command-exec';
|
|
164
|
+
const success = typeof input.success === 'boolean' ? input.success : true;
|
|
165
|
+
// Anonymous tier strips everything but the bare counts. Community
|
|
166
|
+
// tier carries the (sanitised) meta payload. Off tier never reaches
|
|
167
|
+
// this function — the emit() gate rejects before we build.
|
|
168
|
+
const meta = consent === 'community' ? sanitiseMeta(input.meta) : {};
|
|
169
|
+
const out = {
|
|
170
|
+
sessionId,
|
|
171
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
172
|
+
command: input.command,
|
|
173
|
+
kind,
|
|
174
|
+
ts: new Date().toISOString(),
|
|
175
|
+
success,
|
|
176
|
+
};
|
|
177
|
+
if (typeof input.durationMs === 'number' && Number.isFinite(input.durationMs)) {
|
|
178
|
+
out.durationMs = Math.max(0, Math.round(input.durationMs));
|
|
179
|
+
}
|
|
180
|
+
if (input.errorCode)
|
|
181
|
+
out.errorCode = input.errorCode;
|
|
182
|
+
if (input.tool)
|
|
183
|
+
out.tool = input.tool;
|
|
184
|
+
if (input.model)
|
|
185
|
+
out.model = input.model;
|
|
186
|
+
if (typeof input.tokensIn === 'number' && Number.isFinite(input.tokensIn)) {
|
|
187
|
+
out.tokensIn = Math.max(0, Math.round(input.tokensIn));
|
|
188
|
+
}
|
|
189
|
+
if (typeof input.tokensOut === 'number' && Number.isFinite(input.tokensOut)) {
|
|
190
|
+
out.tokensOut = Math.max(0, Math.round(input.tokensOut));
|
|
191
|
+
}
|
|
192
|
+
if (Object.keys(meta).length > 0)
|
|
193
|
+
out.meta = meta;
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Fire one telemetry event. Never throws. Returns a discriminated
|
|
198
|
+
* verdict so callers (the spec, the diagnostic surface) can assert on
|
|
199
|
+
* the path the emit took.
|
|
200
|
+
*
|
|
201
|
+
* Hot-path: opt-out checks → consent check → buildEvent → spillEvent.
|
|
202
|
+
* The spillEvent step is sync filesystem IO; for CLI surfaces that
|
|
203
|
+
* cannot tolerate a 1ms stat() (e.g. inner REPL tick) the caller
|
|
204
|
+
* should queue via `setImmediate(() => emit(...))`.
|
|
205
|
+
*/
|
|
206
|
+
export function emit(input, ctx = {}) {
|
|
207
|
+
const env = ctx.env ?? process.env;
|
|
208
|
+
// The three opt-out hatches, cheapest first.
|
|
209
|
+
if (isEnvDisabled(env))
|
|
210
|
+
return { kind: 'disabled', reason: 'env' };
|
|
211
|
+
if (isKillSwitchArmed(env))
|
|
212
|
+
return { kind: 'disabled', reason: 'marker' };
|
|
213
|
+
const consent = currentConsent({ env });
|
|
214
|
+
if (consent === 'off')
|
|
215
|
+
return { kind: 'disabled', reason: 'consent' };
|
|
216
|
+
if (!cachedSessionId)
|
|
217
|
+
cachedSessionId = ctx.sessionId ?? newSessionId();
|
|
218
|
+
const sessionId = ctx.sessionId ?? cachedSessionId;
|
|
219
|
+
const event = buildEvent(input, consent, sessionId);
|
|
220
|
+
try {
|
|
221
|
+
spillEvent(event, { repoRoot: ctx.repoRoot });
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Spill failure is silent — the emitter is best-effort by contract.
|
|
225
|
+
// A broken spill is a degraded-but-functional CLI, not a failed one.
|
|
226
|
+
}
|
|
227
|
+
return { kind: 'enqueued' };
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=emitter.js.map
|