ccsniff 1.1.24 → 1.1.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/package.json +1 -1
- package/src/cli.js +36 -508
- package/src/index.js +10 -5
- package/src/discipline-helpers.js +0 -29
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { JsonlReplayer, rollup, vault } from './index.js';
|
|
3
3
|
import { toUnslothMessages, toShareGPT } from './unsloth.js';
|
|
4
4
|
import { parseTime, compileRegexes, buildFilter } from './filters.js';
|
|
5
|
-
import { stripQuoted, targetsOutsideCwd, targetsSingleFile } from './discipline-helpers.js';
|
|
6
5
|
import fs from 'fs';
|
|
7
6
|
import path from 'path';
|
|
8
7
|
import os from 'os';
|
|
@@ -31,7 +30,7 @@ const FLAGS = {
|
|
|
31
30
|
string: ['since', 'until', 'before', 'after', 'grep', 'igrep', 'cwd', 'project', 'role', 'type', 'tool', 'session', 'sid', 'sess', 'parent', 'rollup', 'format', 'sort', 'unsloth', 'unsloth-format', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
|
|
32
31
|
multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
|
|
33
32
|
number: ['limit', 'head', 'tail-n', 'ctx', 'truncate', 'days'],
|
|
34
|
-
bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', '
|
|
33
|
+
bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'text', 'full-history', 'stats', 'count', 'help', 'h'],
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
function parseArgs(argv) {
|
|
@@ -64,21 +63,7 @@ USAGE
|
|
|
64
63
|
ccsniff --list-sessions [filters]
|
|
65
64
|
ccsniff --list-projects
|
|
66
65
|
ccsniff --list-tools
|
|
67
|
-
ccsniff --
|
|
68
|
-
ccsniff --learning-xref [--sess <id>] [--days N] join transcript turns to rs-learn recall/memorize
|
|
69
|
-
ccsniff --git-discipline [--stats] git push from a dirty/unwitnessed tree
|
|
70
|
-
ccsniff --search-discipline [--stats] native search (Grep/Glob/Explore/find) instead of codesearch/recall
|
|
71
|
-
ccsniff --glyph-discipline [--stats] decorative glyphs (arrows/box/star/dot/check/emoji) written into files
|
|
72
|
-
(excludes subagents by default — --include-subagents to opt in;
|
|
73
|
-
excludes 'echo > .gm/exec-spool/in/...' as canonical spool-write)
|
|
74
|
-
ccsniff --continuation-discipline [--stats] assistant turn that ends in prose with no tool call:
|
|
75
|
-
a summary, or deferred intent ("Let me X" / "I'll X" / "Now to")
|
|
76
|
-
as the final sentence — the toolless-turn stop (paper §38)
|
|
77
|
-
ccsniff --verb-bypass-discipline [--stats] a platform-native tool used where a plugkit verb exists:
|
|
78
|
-
WebFetch/WebSearch->fetch, Task-search->codesearch,
|
|
79
|
-
raw puppeteer/chrome->browser, platform-memory Write->memorize-fire
|
|
80
|
-
ccsniff --spool-discipline [--stats] a spool request written to in/<verb>/ but never read back from
|
|
81
|
-
out/ (the Write-alone-is-not-a-dispatch non-dispatch)
|
|
66
|
+
ccsniff --text [filters] human-readable demarcated view of thinking/tool calls/actions
|
|
82
67
|
ccsniff --stats [filters]
|
|
83
68
|
|
|
84
69
|
TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
|
|
@@ -117,12 +102,17 @@ OUTPUT
|
|
|
117
102
|
--sort <key> ts|sid|cwd|role|type (default ts)
|
|
118
103
|
--count print only the match count
|
|
119
104
|
--stats breakdown by role/type/tool/project/session
|
|
105
|
+
--text human-readable demarcated blocks (thinking/tool_use/tool_result/text)
|
|
120
106
|
-f, --tail live tail after replay
|
|
121
107
|
--rollup <out> dump filtered events to file
|
|
122
108
|
--format ndjson|sqlite rollup format (default ndjson; sqlite needs better-sqlite3)
|
|
123
109
|
--unsloth <out> write Unsloth training JSONL (one conversation per session per line)
|
|
124
110
|
--unsloth-format <fmt> messages (OpenAI/ChatML, default) | sharegpt
|
|
125
111
|
|
|
112
|
+
LIMITS
|
|
113
|
+
by default, output caps at the 500 most recent matching events (after --limit/--tail-n/--ctx apply)
|
|
114
|
+
--full-history disable the default 500-event cap and dump everything matched
|
|
115
|
+
|
|
126
116
|
EXAMPLES
|
|
127
117
|
ccsniff --since 24h --grep "rs-exec" --limit 50
|
|
128
118
|
ccsniff --since 7d --until 1d --role user --json
|
|
@@ -172,6 +162,23 @@ function formatRow(ev, opts) {
|
|
|
172
162
|
return `[${t}] [${repo}${tag}] ${ev.role}/${block.type || '?'}${tool}: ${out}\n`;
|
|
173
163
|
}
|
|
174
164
|
|
|
165
|
+
// Human-readable demarcated block per event — for watching an agent's thinking, tool calls,
|
|
166
|
+
// and tool results go by as a live transcript rather than a dense single-line dump.
|
|
167
|
+
function formatTextBlock(ev) {
|
|
168
|
+
const conv = ev.conversation || {};
|
|
169
|
+
const block = ev.block || {};
|
|
170
|
+
const t = new Date(ev.timestamp).toISOString().slice(0, 19).replace('T', ' ');
|
|
171
|
+
const repo = path.basename(conv.cwd || '');
|
|
172
|
+
const tag = conv.isSubagent ? ' (subagent)' : '';
|
|
173
|
+
const kind = block.type === 'tool_use' ? `tool_use: ${block.name || '?'}`
|
|
174
|
+
: block.type === 'tool_result' ? 'tool_result'
|
|
175
|
+
: block.type === 'thinking' ? 'thinking'
|
|
176
|
+
: block.type || ev.role;
|
|
177
|
+
const header = `==== [${t}] ${repo}${tag} :: ${ev.role} :: ${kind} ====`;
|
|
178
|
+
const body = blockText(block).trim();
|
|
179
|
+
return `${header}\n${body}\n\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
175
182
|
function collect(opts, since) {
|
|
176
183
|
const r = new JsonlReplayer();
|
|
177
184
|
const all = [];
|
|
@@ -227,9 +234,9 @@ if (opts.rollup) {
|
|
|
227
234
|
// ---------- live tail (filter applied to live events)
|
|
228
235
|
if (opts.tail) {
|
|
229
236
|
const r = new JsonlReplayer();
|
|
230
|
-
r.on('streaming_progress', ev => { if (filter(ev)) process.stdout.write(formatRow(ev, opts)); });
|
|
237
|
+
r.on('streaming_progress', ev => { if (filter(ev)) process.stdout.write(opts.text ? formatTextBlock(ev) : formatRow(ev, opts)); });
|
|
231
238
|
r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
|
|
232
|
-
r.start();
|
|
239
|
+
r.start(since);
|
|
233
240
|
process.stdout.write('# tailing... (Ctrl-C to exit)\n');
|
|
234
241
|
process.stdin.resume();
|
|
235
242
|
} else {
|
|
@@ -257,493 +264,6 @@ if (opts['list-projects']) {
|
|
|
257
264
|
process.exit(0);
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
// Each --*-discipline below is its own one-shot report ending in process.exit(0), so only the
|
|
261
|
-
// first requested discipline in source order would ever run. Combining flags (e.g.
|
|
262
|
-
// `--git-discipline --search-discipline`) would silently drop every discipline but the first and
|
|
263
|
-
// yield a false-clean audit. Fail loud instead of running one and dropping the rest.
|
|
264
|
-
const DISCIPLINE_FLAGS = ['bash-discipline', 'git-discipline', 'verb-bypass-discipline', 'spool-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline'];
|
|
265
|
-
const requestedDisciplines = DISCIPLINE_FLAGS.filter(d => opts[d]);
|
|
266
|
-
if (requestedDisciplines.length > 1) {
|
|
267
|
-
process.stderr.write(`ccsniff: ${requestedDisciplines.length} discipline flags given (${requestedDisciplines.map(d => '--' + d).join(' ')}); each is a separate one-shot report and only the first would run. Invoke one discipline per call.\n`);
|
|
268
|
-
process.exit(2);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ---------- bash-discipline (flag Bash calls that should have been Read/Glob/Grep/dispatch)
|
|
272
|
-
if (opts['bash-discipline']) {
|
|
273
|
-
// discipline is about MY tool routing, not subagents — they have separate prompts/contexts.
|
|
274
|
-
// Default: exclude subagents. --include-subagents opts them back in.
|
|
275
|
-
const includeSubagents = opts['include-subagents'];
|
|
276
|
-
const BAD_LEADING = /^\s*(cat|head|tail|ls|grep|find|sed|awk)\b/;
|
|
277
|
-
const SLEEP_POLL = /\bsleep\s+\d+\s*;.*(cat|ls|grep|find|head|tail)/;
|
|
278
|
-
const SPOOL_WRITE = /\.gm\/exec-spool\/in\//;
|
|
279
|
-
// The host harness explicitly endorses `until <check>; do sleep N; done` as
|
|
280
|
-
// the canonical pattern for polling external state (see Bash tool description
|
|
281
|
-
// and Monitor docs). Same for `while !curl ...; do sleep N; done`. These are
|
|
282
|
-
// NOT sleep-poll violations even though they contain `sleep N`.
|
|
283
|
-
const ENDORSED_POLL = /^\s*(until|while)\s+/;
|
|
284
|
-
// gm-skill SKILL.md prescribes the boot probe `cat .gm/exec-spool/.status.json; date +%s%3N`
|
|
285
|
-
// to compare watcher heartbeat against current epoch. The cat is canonical, not a deviation.
|
|
286
|
-
// Same for reading .watcher.log diagnostics directly.
|
|
287
|
-
const CANONICAL_BOOT_PROBE = /\.gm\/exec-spool\/\.(status\.json|watcher\.log|bootstrap-(status|error)\.json|last-session-start\.json)/;
|
|
288
|
-
// Observability surfaces — multi-file pattern scans over JSONL logs and transcript dirs
|
|
289
|
-
// legitimately need grep/tail/cat because Read tool can't stream multi-file or pipe to head -c.
|
|
290
|
-
// gm-log/<day>/*.jsonl, .claude/projects/*/*.jsonl, and *.jsonl in general are the canonical
|
|
291
|
-
// observability targets per AGENTS.md "rs-learn observability" entry.
|
|
292
|
-
const OBSERVABILITY_TARGET = /\.(jsonl|ndjson|log)\b|gm-log\/|\.claude\/projects\//;
|
|
293
|
-
const violations = [];
|
|
294
|
-
for (const ev of all) {
|
|
295
|
-
if (!filter(ev)) continue;
|
|
296
|
-
if (ev.block?.type !== 'tool_use' || ev.block?.name !== 'Bash') continue;
|
|
297
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
298
|
-
const cmd = ev.block?.input?.command || '';
|
|
299
|
-
// `echo > .gm/exec-spool/in/<verb>/N.txt` is the canonical spool-write pattern, not a deviation.
|
|
300
|
-
if (SPOOL_WRITE.test(cmd) && /^\s*echo\b/.test(cmd)) continue;
|
|
301
|
-
// `until ...; do sleep N; done` is the harness-endorsed poll pattern.
|
|
302
|
-
if (ENDORSED_POLL.test(cmd)) continue;
|
|
303
|
-
// Canonical gm-skill boot/diagnostic probes (cat .status.json; date +%s%3N etc.) are prescribed by SKILL.md.
|
|
304
|
-
if (CANONICAL_BOOT_PROBE.test(cmd)) continue;
|
|
305
|
-
// Observability surface reads — grep/cat/tail over JSONL logs and transcript dirs are legit (Read tool can't stream/multi-file).
|
|
306
|
-
if (OBSERVABILITY_TARGET.test(cmd)) continue;
|
|
307
|
-
const kind = SLEEP_POLL.test(cmd) ? 'sleep-poll' : (BAD_LEADING.test(cmd) ? 'bad-leading-cmd' : null);
|
|
308
|
-
if (!kind) continue;
|
|
309
|
-
violations.push({ ts: ev.timestamp, sid: ev.conversation.id, project: path.basename(ev.conversation.cwd || ''), kind, cmd: cmd.slice(0, 200) });
|
|
310
|
-
}
|
|
311
|
-
const byKind = new Map();
|
|
312
|
-
for (const v of violations) byKind.set(v.kind, (byKind.get(v.kind) || 0) + 1);
|
|
313
|
-
if (opts.stats || opts.count) {
|
|
314
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
315
|
-
const subagentNote = includeSubagents ? '' : ' (subagents excluded — pass --include-subagents to include)';
|
|
316
|
-
process.stdout.write(`# ${violations.length} bash-discipline violations${subagentNote}\n`);
|
|
317
|
-
for (const [k, c] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${k}\n`);
|
|
318
|
-
const byProj = new Map();
|
|
319
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
320
|
-
process.stdout.write(`# by project\n`);
|
|
321
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
322
|
-
const byDay = new Map();
|
|
323
|
-
for (const v of violations) {
|
|
324
|
-
const day = new Date(v.ts).toISOString().slice(0, 10);
|
|
325
|
-
byDay.set(day, (byDay.get(day) || 0) + 1);
|
|
326
|
-
}
|
|
327
|
-
if (byDay.size > 1) {
|
|
328
|
-
process.stdout.write(`# by day\n`);
|
|
329
|
-
for (const [d, c] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) process.stdout.write(` ${String(c).padStart(6)} ${d}\n`);
|
|
330
|
-
}
|
|
331
|
-
const byHour = new Map();
|
|
332
|
-
for (const v of violations) {
|
|
333
|
-
const hour = new Date(v.ts).toISOString().slice(0, 13);
|
|
334
|
-
byHour.set(hour, (byHour.get(hour) || 0) + 1);
|
|
335
|
-
}
|
|
336
|
-
if (byHour.size > 1) {
|
|
337
|
-
process.stdout.write(`# by hour (last 12)\n`);
|
|
338
|
-
const sorted = [...byHour.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(-12);
|
|
339
|
-
for (const [h, c] of sorted) process.stdout.write(` ${String(c).padStart(6)} ${h}:00\n`);
|
|
340
|
-
}
|
|
341
|
-
process.exit(0);
|
|
342
|
-
}
|
|
343
|
-
for (const v of violations) {
|
|
344
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(15)} [${v.project}] ${v.cmd}\n`);
|
|
345
|
-
}
|
|
346
|
-
process.stderr.write(`# ${violations.length} violations (${[...byKind.entries()].map(([k, c]) => `${k}:${c}`).join(' ')})\n`);
|
|
347
|
-
process.exit(0);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (opts['git-discipline']) {
|
|
351
|
-
const includeSubagents = opts['include-subagents'];
|
|
352
|
-
const PUSH = /\bgit\s+push\b/;
|
|
353
|
-
const PORCELAIN_CLEAN = /\bgit\s+status\s+(--porcelain|-s)\b/;
|
|
354
|
-
const stripQuoted = (s) => s.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
355
|
-
const bySid = new Map();
|
|
356
|
-
for (const ev of all) {
|
|
357
|
-
if (!filter(ev)) continue;
|
|
358
|
-
if (ev.block?.type !== 'tool_use' || ev.block?.name !== 'Bash') continue;
|
|
359
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
360
|
-
const sid = ev.conversation.id;
|
|
361
|
-
if (!bySid.has(sid)) bySid.set(sid, []);
|
|
362
|
-
bySid.get(sid).push(ev);
|
|
363
|
-
}
|
|
364
|
-
const violations = [];
|
|
365
|
-
for (const [sid, evs] of bySid) {
|
|
366
|
-
evs.sort((a, b) => a.timestamp - b.timestamp);
|
|
367
|
-
for (let i = 0; i < evs.length; i++) {
|
|
368
|
-
const ev = evs[i];
|
|
369
|
-
const cmd = ev.block?.input?.command || '';
|
|
370
|
-
const cmdStripped = stripQuoted(cmd);
|
|
371
|
-
if (!PUSH.test(cmdStripped)) continue;
|
|
372
|
-
const lookback = evs.slice(Math.max(0, i - 20), i);
|
|
373
|
-
const witnessed = lookback.some(e => PORCELAIN_CLEAN.test(stripQuoted(e.block?.input?.command || '')));
|
|
374
|
-
if (witnessed) continue;
|
|
375
|
-
violations.push({ ts: ev.timestamp, sid, project: path.basename(ev.conversation.cwd || ''), kind: 'push-no-porcelain-witness', cmd: cmd.slice(0, 200) });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
if (opts.stats || opts.count) {
|
|
379
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
380
|
-
process.stdout.write(`# ${violations.length} git-discipline violations\n`);
|
|
381
|
-
const byProj = new Map();
|
|
382
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
383
|
-
process.stdout.write(`# by project\n`);
|
|
384
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
385
|
-
process.exit(0);
|
|
386
|
-
}
|
|
387
|
-
for (const v of violations) {
|
|
388
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(28)} [${v.project}] ${v.cmd}\n`);
|
|
389
|
-
}
|
|
390
|
-
process.stderr.write(`# ${violations.length} violations (push-no-porcelain-witness)\n`);
|
|
391
|
-
process.exit(0);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ---------- verb-bypass-discipline (a platform-native capability used where a plugkit verb exists)
|
|
395
|
-
// The class rule: every platform-native tool that has a plugkit verb is forbidden in favor of the
|
|
396
|
-
// verb — WebFetch/WebSearch -> the `fetch` verb; a Task/Agent search subagent -> `codesearch`; raw
|
|
397
|
-
// puppeteer/playwright/chrome -> the `browser` verb; a Write into a platform memory dir -> `memorize-fire`.
|
|
398
|
-
// High-precision per-tool patterns; each violation names the verb it should have used.
|
|
399
|
-
if (opts['verb-bypass-discipline']) {
|
|
400
|
-
const includeSubagents = opts['include-subagents'];
|
|
401
|
-
const MEM_PATH = /[\/\\]\.(?:claude[\/\\]projects[\/\\].*[\/\\]memory|codex[\/\\]memory|cursor)[\/\\]/i;
|
|
402
|
-
const RAW_BROWSER = /\b(?:puppeteer|playwright|chromium|chrome\.exe|google-chrome|chrome-headless)\b|--headless\b/i;
|
|
403
|
-
const TASK_SEARCH = /\b(?:where is|what calls|locate the|search the (?:code|repo|codebase|tree)|grep the|explore the (?:code|repo|tree|codebase)|find (?:the )?(?:definition|usages?|references?|callers?|where))\b/i;
|
|
404
|
-
const violations = [];
|
|
405
|
-
for (const ev of all) {
|
|
406
|
-
if (!filter(ev)) continue;
|
|
407
|
-
if (ev.block?.type !== 'tool_use') continue;
|
|
408
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
409
|
-
const name = ev.block?.name || '';
|
|
410
|
-
const input = ev.block?.input || {};
|
|
411
|
-
let kind = null, should = null, detail = '';
|
|
412
|
-
if (name === 'WebFetch') { kind = 'webfetch-not-fetch-verb'; should = 'fetch'; detail = String(input.url || '').slice(0, 120); }
|
|
413
|
-
else if (name === 'WebSearch') { kind = 'websearch-not-fetch-verb'; should = 'fetch'; detail = String(input.query || '').slice(0, 120); }
|
|
414
|
-
else if ((name === 'Task' || name === 'Agent') && TASK_SEARCH.test(stripQuoted(JSON.stringify(input)).slice(0, 600))) { kind = 'task-search-not-codesearch'; should = 'codesearch'; detail = String(input.description || input.prompt || '').slice(0, 120); }
|
|
415
|
-
else if (name === 'Bash' && RAW_BROWSER.test(stripQuoted(input.command || ''))) { kind = 'raw-browser-not-browser-verb'; should = 'browser'; detail = String(input.command || '').slice(0, 120); }
|
|
416
|
-
else if ((name === 'Write' || name === 'Edit' || name === 'NotebookEdit') && MEM_PATH.test(input.file_path || input.path || input.notebook_path || '')) { kind = 'platform-memory-not-memorize'; should = 'memorize-fire'; detail = String(input.file_path || input.path || input.notebook_path || '').slice(0, 120); }
|
|
417
|
-
if (!kind) continue;
|
|
418
|
-
violations.push({ ts: ev.timestamp, sid: ev.conversation.id, project: path.basename(ev.conversation.cwd || ''), kind, should, detail });
|
|
419
|
-
}
|
|
420
|
-
const byKind = new Map();
|
|
421
|
-
for (const v of violations) byKind.set(v.kind, (byKind.get(v.kind) || 0) + 1);
|
|
422
|
-
if (opts.stats || opts.count) {
|
|
423
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
424
|
-
process.stdout.write(`# ${violations.length} verb-bypass-discipline violations\n`);
|
|
425
|
-
for (const [k, c] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${k}\n`);
|
|
426
|
-
const byProj = new Map();
|
|
427
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
428
|
-
process.stdout.write(`# by project\n`);
|
|
429
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
430
|
-
process.exit(0);
|
|
431
|
-
}
|
|
432
|
-
for (const v of violations) {
|
|
433
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(28)} [${v.project}] use:${v.should} ${v.detail}\n`);
|
|
434
|
-
}
|
|
435
|
-
process.stderr.write(`# ${violations.length} violations (${[...byKind.entries()].map(([k, c]) => `${k}:${c}`).join(' ')})\n`);
|
|
436
|
-
process.exit(0);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// ---------- spool-discipline (a session that dispatches spool requests but reads NO responses)
|
|
440
|
-
// "The Write alone is not a dispatch." A session that writes `.gm/exec-spool/in/<verb>/<N>.txt`
|
|
441
|
-
// requests and reads ZERO `out/<...>.json` responses is fabricating the chain from prose — it never
|
|
442
|
-
// observed a single plugkit response. Session-level by design: batching (write many, read the
|
|
443
|
-
// first/last) is endorsed, so reading even one out/ response clears the session. Only a session that
|
|
444
|
-
// reads none of its responses is flagged — high precision, no batching false-positive.
|
|
445
|
-
if (opts['spool-discipline']) {
|
|
446
|
-
const includeSubagents = opts['include-subagents'];
|
|
447
|
-
const SPOOL_IN_WRITE = /(?:>\s*[^>|]*|file_path["'\s:]+["']?[^"']*)\.gm[\/\\]exec-spool[\/\\]in[\/\\][a-z0-9_-]+[\/\\]\d+\./i;
|
|
448
|
-
const SPOOL_OUT = /\.gm[\/\\]exec-spool[\/\\]out[\/\\]/i;
|
|
449
|
-
const sess = new Map();
|
|
450
|
-
for (const ev of all) {
|
|
451
|
-
if (!filter(ev)) continue;
|
|
452
|
-
if (ev.block?.type !== 'tool_use') continue;
|
|
453
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
454
|
-
const sid = ev.conversation.id;
|
|
455
|
-
if (!sess.has(sid)) sess.set(sid, { writes: 0, reads: 0, project: path.basename(ev.conversation.cwd || ''), firstTs: ev.timestamp, lastTs: ev.timestamp });
|
|
456
|
-
const s = sess.get(sid);
|
|
457
|
-
s.lastTs = ev.timestamp;
|
|
458
|
-
const b = ev.block, inp = b.input || {};
|
|
459
|
-
const blob = b.name === 'Write' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
|
|
460
|
-
if (blob && SPOOL_IN_WRITE.test(blob)) s.writes++;
|
|
461
|
-
const rblob = b.name === 'Read' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
|
|
462
|
-
if (rblob && SPOOL_OUT.test(rblob)) s.reads++;
|
|
463
|
-
}
|
|
464
|
-
const violations = [];
|
|
465
|
-
for (const [sid, s] of sess) {
|
|
466
|
-
if (s.writes >= 1 && s.reads === 0) violations.push({ ts: s.lastTs, sid, project: s.project, writes: s.writes });
|
|
467
|
-
}
|
|
468
|
-
if (opts.stats || opts.count) {
|
|
469
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
470
|
-
process.stdout.write(`# ${violations.length} spool-discipline violations (session dispatched spool writes but read 0 responses)\n`);
|
|
471
|
-
const byProj = new Map();
|
|
472
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
473
|
-
process.stdout.write(`# by project\n`);
|
|
474
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
475
|
-
process.exit(0);
|
|
476
|
-
}
|
|
477
|
-
for (const v of violations) {
|
|
478
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} spool-writes-no-reads [${v.project}] writes:${v.writes} reads:0\n`);
|
|
479
|
-
}
|
|
480
|
-
process.stderr.write(`# ${violations.length} sessions dispatched spool writes but read 0 responses\n`);
|
|
481
|
-
process.exit(0);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ---------- search-discipline (flag native search that should have been codesearch/recall)
|
|
485
|
-
// A native-search bypass (Grep/Glob, the Explore/Task search subagent, or bash grep/rg/find/ag)
|
|
486
|
-
// emits NO plugkit deviation because it never touches the spool — it is invisible to gmsniff and
|
|
487
|
-
// the watcher ledger. ccsniff reads the tool-call stream directly, so it is the only surface that
|
|
488
|
-
// can catch the SKILL.md class-rule violation: code/file/symbol search routes through codesearch,
|
|
489
|
-
// prior-knowledge through recall, never a host-native search tool.
|
|
490
|
-
if (opts['search-discipline']) {
|
|
491
|
-
const includeSubagents = opts['include-subagents'];
|
|
492
|
-
const BASH_SEARCH = /(^|[|&;]|\s)(rg|grep|find|ag|ack|fd|fgrep|egrep)\s/;
|
|
493
|
-
// stripQuoted, targetsOutsideCwd (cwd-override + cross-repo exemption), and targetsSingleFile
|
|
494
|
-
// (single-file read-filter exemption) live in discipline-helpers.js so they are unit-testable
|
|
495
|
-
// without running the CLI. codesearch indexes only the conversation cwd, so a cross-repo or
|
|
496
|
-
// single-file grep has no index to route through and flagging it is a false positive.
|
|
497
|
-
const violations = [];
|
|
498
|
-
for (const ev of all) {
|
|
499
|
-
if (!filter(ev)) continue;
|
|
500
|
-
if (ev.block?.type !== 'tool_use') continue;
|
|
501
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
502
|
-
const name = ev.block?.name || '';
|
|
503
|
-
const project = path.basename(ev.conversation?.cwd || '');
|
|
504
|
-
const ts = ev.timestamp, sid = ev.conversation?.id || '';
|
|
505
|
-
let kind = null, detail = '';
|
|
506
|
-
if (name === 'Grep' || name === 'Glob') {
|
|
507
|
-
// A Grep/Glob whose path points outside the cwd targets a sibling repo with no codesearch
|
|
508
|
-
// index — exempt it, same as a cross-repo bash search.
|
|
509
|
-
const gp = ev.block?.input?.path;
|
|
510
|
-
if (gp && targetsOutsideCwd(gp, ev.conversation?.cwd)) { /* cross-repo, exempt */ }
|
|
511
|
-
else {
|
|
512
|
-
kind = `native-search-${name.toLowerCase()}`;
|
|
513
|
-
detail = (ev.block?.input?.pattern || ev.block?.input?.query || '').slice(0, 120);
|
|
514
|
-
}
|
|
515
|
-
} else if (name === 'Task' || name === 'Agent') {
|
|
516
|
-
const sub = (ev.block?.input?.subagent_type || ev.block?.input?.description || '').toLowerCase();
|
|
517
|
-
if (/explore|search|general-purpose/.test(sub)) {
|
|
518
|
-
kind = 'native-search-subagent';
|
|
519
|
-
detail = sub.slice(0, 120);
|
|
520
|
-
}
|
|
521
|
-
} else if (name === 'Bash') {
|
|
522
|
-
const cmd = ev.block?.input?.command || '';
|
|
523
|
-
// A search tool fed by a pipe (`<cmd> | grep ...`) is filtering another command's stdout,
|
|
524
|
-
// not searching the codebase tree — codesearch has no equivalent for that and it is not the
|
|
525
|
-
// bypass the rule targets. Flag only a search tool that STARTS a pipeline segment (reads the
|
|
526
|
-
// tree directly), never one immediately downstream of a pipe.
|
|
527
|
-
// A line whose first non-space token is `#` is a shell comment, not a command — never a search.
|
|
528
|
-
const isTreeSearchLine = (line) => !/^\s*#/.test(line) && BASH_SEARCH.test(stripQuoted(line).split('|')[0]);
|
|
529
|
-
const hitLine = cmd.split('\n').find(isTreeSearchLine);
|
|
530
|
-
// Exempt a tree-search line that targets a sibling repo outside cwd (no codesearch index exists
|
|
531
|
-
// for it), or that greps ONE explicit file (a read-filter codesearch cannot serve). Each
|
|
532
|
-
// command may cd/git -C first, so evaluate the context on the same line.
|
|
533
|
-
if (hitLine && !targetsOutsideCwd(hitLine, ev.conversation?.cwd) && !targetsSingleFile(hitLine)) {
|
|
534
|
-
kind = 'native-search-bash';
|
|
535
|
-
detail = (hitLine.split('|')[0]).trim().slice(0, 120);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (kind) violations.push({ ts, sid, project, kind, detail });
|
|
539
|
-
}
|
|
540
|
-
if (opts.stats || opts.count) {
|
|
541
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
542
|
-
process.stdout.write(`# ${violations.length} search-discipline violations (native search instead of codesearch/recall)\n`);
|
|
543
|
-
const byKind = new Map(), byProj = new Map();
|
|
544
|
-
for (const v of violations) { byKind.set(v.kind, (byKind.get(v.kind) || 0) + 1); byProj.set(v.project, (byProj.get(v.project) || 0) + 1); }
|
|
545
|
-
process.stdout.write(`# by kind\n`);
|
|
546
|
-
for (const [k, c] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${k}\n`);
|
|
547
|
-
process.stdout.write(`# by project\n`);
|
|
548
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
549
|
-
process.exit(0);
|
|
550
|
-
}
|
|
551
|
-
for (const v of violations) {
|
|
552
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(24)} [${v.project}] ${v.detail}\n`);
|
|
553
|
-
}
|
|
554
|
-
process.stderr.write(`# ${violations.length} violations — use codesearch (code/file/symbol) or recall (prior knowledge) instead\n`);
|
|
555
|
-
process.exit(0);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// ---------- glyph-discipline (flag decorative graphical symbols written into files)
|
|
559
|
-
// The gm SKILL.md rule forbids decorative glyphs (arrows, box/geometric glyphs, stars, dots,
|
|
560
|
-
// bullets, checkmarks, crosses, emojis) in output and source; they must convert to ASCII on sight.
|
|
561
|
-
// A glyph written into a file via Write/Edit is invisible to the spool ledger, so ccsniff reading
|
|
562
|
-
// the tool-call stream is the surface that catches it. Functional operators are ASCII and never match.
|
|
563
|
-
if (opts['glyph-discipline']) {
|
|
564
|
-
const includeSubagents = opts['include-subagents'];
|
|
565
|
-
const GLYPH = /[←-⇿⌀-⏿■-◿☀-➿⬀-⯿]|[\u{1F000}-\u{1FAFF}]/u;
|
|
566
|
-
const GLYPH_G = /[←-⇿⌀-⏿■-◿☀-➿⬀-⯿]|[\u{1F000}-\u{1FAFF}]/gu;
|
|
567
|
-
// Glyphs inside a regex char-class (e.g. /[←-⇿]/) are a detector/range DEFINITION, not decorative
|
|
568
|
-
// prose — blank those bracket bodies before testing so a glyph-rule definition does not flag itself.
|
|
569
|
-
const stripGlyphCharClass = (s) => s.replace(/\[[^\]\n]*\]/g, (m) => GLYPH.test(m) ? '[]' : m);
|
|
570
|
-
const violations = [];
|
|
571
|
-
for (const ev of all) {
|
|
572
|
-
if (!filter(ev)) continue;
|
|
573
|
-
if (ev.block?.type !== 'tool_use') continue;
|
|
574
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
575
|
-
const name = ev.block?.name || '';
|
|
576
|
-
if (name !== 'Write' && name !== 'Edit' && name !== 'NotebookEdit') continue;
|
|
577
|
-
const inp = ev.block?.input || {};
|
|
578
|
-
const filePath = inp.file_path || inp.notebook_path || '';
|
|
579
|
-
const rawContent = [inp.content, inp.new_string, inp.new_source].filter(s => typeof s === 'string').join('\n');
|
|
580
|
-
const content = stripGlyphCharClass(rawContent);
|
|
581
|
-
if (!content || !GLYPH.test(content)) continue;
|
|
582
|
-
const glyphs = [...new Set((content.match(GLYPH_G) || []))].slice(0, 10).join(' ');
|
|
583
|
-
violations.push({ ts: ev.timestamp, sid: ev.conversation?.id || '', project: path.basename(ev.conversation?.cwd || ''), kind: 'glyph-written', file: path.basename(filePath), glyphs });
|
|
584
|
-
}
|
|
585
|
-
if (opts.stats || opts.count) {
|
|
586
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
587
|
-
process.stdout.write(`# ${violations.length} glyph-discipline violations (decorative glyphs written to files)\n`);
|
|
588
|
-
const byProj = new Map();
|
|
589
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
590
|
-
process.stdout.write(`# by project\n`);
|
|
591
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
592
|
-
process.exit(0);
|
|
593
|
-
}
|
|
594
|
-
for (const v of violations) {
|
|
595
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(14)} [${v.project}] ${v.file} ${v.glyphs}\n`);
|
|
596
|
-
}
|
|
597
|
-
process.stderr.write(`# ${violations.length} violations — convert decorative glyphs to ASCII (-> for arrow, - or * for bullet, [x]/[ ] for check/cross)\n`);
|
|
598
|
-
process.exit(0);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// ---------- continuation-discipline (flag the toolless-turn stop: paper §38)
|
|
602
|
-
// An assistant message whose blocks contain NO tool_use, ending in prose, IS the turn ending —
|
|
603
|
-
// the harness reads only tool calls, so the session halts there. Two faces: a backward-facing
|
|
604
|
-
// summary, and deferred intent (a turn-final sentence naming the next move instead of making it,
|
|
605
|
-
// "Let me read X" / "I'll start with Y" / "Now to the core"). The plugkit watchdog catches a
|
|
606
|
-
// permanent stall at runtime; this catches the linguistic signature post-hoc in the transcript,
|
|
607
|
-
// where each block of one assistant message shares a (sid, timestamp) group.
|
|
608
|
-
if (opts['continuation-discipline']) {
|
|
609
|
-
const includeSubagents = opts['include-subagents'];
|
|
610
|
-
// Match against the LAST sentence of the message, extracted first — the deferred-intent or
|
|
611
|
-
// summary phrase opens that final clause. Testing the whole multi-paragraph blob with a
|
|
612
|
-
// start-anchor fails because the trailing sentence rarely begins right after a clean boundary.
|
|
613
|
-
const lastSentenceOf = (t) => {
|
|
614
|
-
const s = t.trimEnd();
|
|
615
|
-
const m = s.match(/[^.!?\n]*[.!?]?\s*$/);
|
|
616
|
-
let sent = (m ? m[0] : s).trim();
|
|
617
|
-
if (sent.length < 4) { const m2 = s.match(/[^\n]*$/); sent = (m2 ? m2[0] : s).trim(); }
|
|
618
|
-
return sent;
|
|
619
|
-
};
|
|
620
|
-
const DEFERRED = /^\s*(let me|let's|i'?ll|i will|i'?m going to|i am going to|now to|now,? to|next,? i|next i'?ll|now i'?ll|now i need to|i need to|i should|time to|i'?m about to)\b/i;
|
|
621
|
-
const SUMMARY = /^\s*(in summary|to summarize|here'?s what i (did|changed)|that'?s (it|done|all)|all done|the work is (now )?(done|complete)|i'?ve (now )?(completed|finished|done))\b/i;
|
|
622
|
-
// Group by the real per-message id (msgId), not (sid,ts): a text block and its tool_use share
|
|
623
|
-
// one message. The discriminator for a genuine stop is stop_reason === 'end_turn' — a message
|
|
624
|
-
// that ends with a tool_use carries stop_reason 'tool_use' and a tool followed, so it is NOT a
|
|
625
|
-
// stop even if its text says "Let me X". Only an end_turn message ending in text is the harness
|
|
626
|
-
// halting on prose. Gating on end_turn is what separates the true stop from think-then-act.
|
|
627
|
-
const byMsg = new Map();
|
|
628
|
-
for (const ev of all) {
|
|
629
|
-
if (!filter(ev)) continue;
|
|
630
|
-
if (ev.role !== 'assistant') continue;
|
|
631
|
-
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
632
|
-
const b = ev.block || {};
|
|
633
|
-
const key = `${ev.conversation?.id || ''}|${b.msgId || ev.timestamp}`;
|
|
634
|
-
if (!byMsg.has(key)) byMsg.set(key, { sid: ev.conversation?.id || '', cwd: ev.conversation?.cwd || '', ts: ev.timestamp, hasTool: false, lastText: '', stopReason: null });
|
|
635
|
-
const m = byMsg.get(key);
|
|
636
|
-
if (b.stopReason) m.stopReason = b.stopReason;
|
|
637
|
-
if (b.type === 'tool_use') m.hasTool = true;
|
|
638
|
-
else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) m.lastText = b.text;
|
|
639
|
-
}
|
|
640
|
-
const violations = [];
|
|
641
|
-
for (const [, m] of byMsg) {
|
|
642
|
-
if (m.hasTool) continue;
|
|
643
|
-
if (m.stopReason !== 'end_turn') continue;
|
|
644
|
-
if (!m.lastText.trim()) continue;
|
|
645
|
-
const lastSentence = lastSentenceOf(m.lastText);
|
|
646
|
-
if (!lastSentence) continue;
|
|
647
|
-
const kind = DEFERRED.test(lastSentence) ? 'deferred-intent' : (SUMMARY.test(lastSentence) ? 'summary' : null);
|
|
648
|
-
if (!kind) continue;
|
|
649
|
-
violations.push({ ts: m.ts, sid: m.sid, project: path.basename(m.cwd || ''), kind, tail: lastSentence.slice(0, 160) });
|
|
650
|
-
}
|
|
651
|
-
violations.sort((a, b) => a.ts - b.ts);
|
|
652
|
-
if (opts.stats || opts.count) {
|
|
653
|
-
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
654
|
-
process.stdout.write(`# ${violations.length} continuation-discipline violations (toolless-turn stops)\n`);
|
|
655
|
-
const byProj = new Map();
|
|
656
|
-
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
657
|
-
process.stdout.write(`# by project\n`);
|
|
658
|
-
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
659
|
-
process.exit(0);
|
|
660
|
-
}
|
|
661
|
-
for (const v of violations) {
|
|
662
|
-
process.stdout.write(`${new Date(v.ts).toISOString().slice(0, 19)} ${v.sid.slice(0, 8)} ${v.kind.padEnd(15)} [${v.project}] ${v.tail}\n`);
|
|
663
|
-
}
|
|
664
|
-
process.stderr.write(`# ${violations.length} violations — a turn ending in prose with no tool call is a stop; take the move instead of announcing it\n`);
|
|
665
|
-
process.exit(0);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// ---------- learning-xref (join transcript turns to gm-log rs_learn signals)
|
|
669
|
-
if (opts['learning-xref']) {
|
|
670
|
-
const days = opts.days || 1;
|
|
671
|
-
const wantSess = opts.sess || null;
|
|
672
|
-
const bySid = new Map();
|
|
673
|
-
for (const ev of all) {
|
|
674
|
-
if (!filter(ev)) continue;
|
|
675
|
-
if (wantSess && !ev.conversation?.id?.startsWith(wantSess)) continue;
|
|
676
|
-
const sid = ev.conversation?.id;
|
|
677
|
-
if (!sid) continue;
|
|
678
|
-
if (!bySid.has(sid)) bySid.set(sid, { cwd: ev.conversation.cwd, evs: [] });
|
|
679
|
-
bySid.get(sid).evs.push(ev);
|
|
680
|
-
}
|
|
681
|
-
const dates = [];
|
|
682
|
-
const now = Date.now();
|
|
683
|
-
for (let i = 0; i < days; i++) {
|
|
684
|
-
dates.push(new Date(now - i * 86400000).toISOString().slice(0, 10));
|
|
685
|
-
}
|
|
686
|
-
const gmLogDir = path.join(os.homedir(), '.claude', 'gm-log');
|
|
687
|
-
const rsLearn = [], bootstrap = [];
|
|
688
|
-
for (const d of dates) {
|
|
689
|
-
for (const [file, sink] of [['rs_learn.jsonl', rsLearn], ['bootstrap.jsonl', bootstrap]]) {
|
|
690
|
-
const fp = path.join(gmLogDir, d, file);
|
|
691
|
-
if (!fs.existsSync(fp)) continue;
|
|
692
|
-
const lines = fs.readFileSync(fp, 'utf8').split('\n');
|
|
693
|
-
for (const ln of lines) {
|
|
694
|
-
if (!ln.trim()) continue;
|
|
695
|
-
try {
|
|
696
|
-
const j = JSON.parse(ln);
|
|
697
|
-
const ts = typeof j.ts === 'number' ? j.ts : Date.parse(j.ts);
|
|
698
|
-
if (Number.isFinite(ts)) { j._ts = ts; sink.push(j); }
|
|
699
|
-
} catch {}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
rsLearn.sort((a, b) => a._ts - b._ts);
|
|
704
|
-
let totals = { turns: 0, tool_uses: 0, memorize: 0, recall: 0, hit: 0, miss: 0, embed_fail: 0 };
|
|
705
|
-
let anyMatched = 0;
|
|
706
|
-
for (const [sid, info] of bySid) {
|
|
707
|
-
info.evs.sort((a, b) => a.timestamp - b.timestamp);
|
|
708
|
-
const project = path.basename(info.cwd || '');
|
|
709
|
-
const skillTs = info.evs
|
|
710
|
-
.filter(e => e.block?.type === 'tool_use' && e.block?.name === 'Skill' && e.block?.input?.skill === 'gm-skill')
|
|
711
|
-
.map(e => e.timestamp);
|
|
712
|
-
if (!skillTs.length) continue;
|
|
713
|
-
const sessFirst = info.evs[0].timestamp;
|
|
714
|
-
const sessLast = info.evs[info.evs.length - 1].timestamp;
|
|
715
|
-
const bounds = [...skillTs, sessLast + 1];
|
|
716
|
-
process.stdout.write(`# session ${sid.slice(0, 8)} [${project}] turns=${skillTs.length}\n`);
|
|
717
|
-
for (let i = 0; i < skillTs.length; i++) {
|
|
718
|
-
const winStart = bounds[i];
|
|
719
|
-
const winEnd = bounds[i + 1];
|
|
720
|
-
const toolUses = info.evs.filter(e => e.timestamp >= winStart && e.timestamp < winEnd && e.block?.type === 'tool_use').length;
|
|
721
|
-
const rsInWin = rsLearn.filter(j => j._ts >= winStart && j._ts < winEnd && (!j.project || j.project === project) && (!wantSess || !j.sess || j.sess === sid || sid.startsWith(j.sess)));
|
|
722
|
-
let memorize = 0, recall = 0, hit = 0, miss = 0, embed_fail = 0;
|
|
723
|
-
for (const j of rsInWin) {
|
|
724
|
-
if (j.event === 'memorize') memorize++;
|
|
725
|
-
else if (j.event === 'recall') { recall++; if (j.hit) hit++; else miss++; }
|
|
726
|
-
else if (j.event === 'embed_fail' || /embed.*fail/i.test(j.event || '')) embed_fail++;
|
|
727
|
-
}
|
|
728
|
-
anyMatched += rsInWin.length;
|
|
729
|
-
totals.turns++;
|
|
730
|
-
totals.tool_uses += toolUses;
|
|
731
|
-
totals.memorize += memorize;
|
|
732
|
-
totals.recall += recall;
|
|
733
|
-
totals.hit += hit;
|
|
734
|
-
totals.miss += miss;
|
|
735
|
-
totals.embed_fail += embed_fail;
|
|
736
|
-
const ts = new Date(winStart).toISOString().slice(0, 19).replace('T', ' ');
|
|
737
|
-
process.stdout.write(`${ts} | tool_uses=${toolUses} | memorize=${memorize} | recall=${recall} (hit=${hit} miss=${miss}) | embed_fail=${embed_fail}\n`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
if (anyMatched === 0 && wantSess) {
|
|
741
|
-
process.stdout.write(`# no rs-learn events for sess ${wantSess} — confirm bootstrap fires gm-log writes\n`);
|
|
742
|
-
}
|
|
743
|
-
process.stderr.write(`# totals: sessions=${bySid.size} turns=${totals.turns} tool_uses=${totals.tool_uses} memorize=${totals.memorize} recall=${totals.recall} (hit=${totals.hit} miss=${totals.miss}) embed_fail=${totals.embed_fail} (scanned ${dates.length}d, rs_learn=${rsLearn.length} bootstrap=${bootstrap.length})\n`);
|
|
744
|
-
process.exit(0);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
267
|
// ---------- list-tools
|
|
748
268
|
if (opts['list-tools']) {
|
|
749
269
|
const tools = new Map();
|
|
@@ -794,6 +314,13 @@ if (opts['tail-n']) rows = rows.slice(-opts['tail-n']);
|
|
|
794
314
|
const limit = opts.limit || opts.head || 0;
|
|
795
315
|
if (limit) rows = rows.slice(0, limit);
|
|
796
316
|
|
|
317
|
+
const DEFAULT_CAP = 500;
|
|
318
|
+
let capped = 0;
|
|
319
|
+
if (!opts['full-history'] && !opts.stats && !opts.count && !opts.unsloth && rows.length > DEFAULT_CAP) {
|
|
320
|
+
capped = rows.length - DEFAULT_CAP;
|
|
321
|
+
rows = rows.slice(-DEFAULT_CAP);
|
|
322
|
+
}
|
|
323
|
+
|
|
797
324
|
// ---------- stats
|
|
798
325
|
if (opts.stats) {
|
|
799
326
|
const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
|
|
@@ -832,8 +359,9 @@ if (opts.unsloth) {
|
|
|
832
359
|
process.exit(0);
|
|
833
360
|
}
|
|
834
361
|
|
|
835
|
-
for (const ev of rows) process.stdout.write(formatRow(ev, opts));
|
|
836
|
-
|
|
362
|
+
for (const ev of rows) process.stdout.write(opts.text ? formatTextBlock(ev) : formatRow(ev, opts));
|
|
363
|
+
const capNote = capped ? ` (${capped} older events hidden by default cap — pass --full-history to see all)` : '';
|
|
364
|
+
process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched${capNote}\n`);
|
|
837
365
|
|
|
838
366
|
}
|
|
839
367
|
}
|
package/src/index.js
CHANGED
|
@@ -20,9 +20,9 @@ export class JsonlWatcher extends EventEmitter {
|
|
|
20
20
|
this._watcher = null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
start() {
|
|
23
|
+
start(since = 0) {
|
|
24
24
|
if (!fs.existsSync(this._dir)) return this;
|
|
25
|
-
this._scan(this._dir, 0);
|
|
25
|
+
this._scan(this._dir, 0, since);
|
|
26
26
|
try {
|
|
27
27
|
this._watcher = fs.watch(this._dir, { recursive: true }, (_, f) => {
|
|
28
28
|
if (f && f.endsWith('.jsonl')) this._debounce(path.join(this._dir, f));
|
|
@@ -40,13 +40,18 @@ export class JsonlWatcher extends EventEmitter {
|
|
|
40
40
|
this._timers.clear(); this._seqs.clear(); this._streaming.clear();
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
_scan(dir, depth) {
|
|
43
|
+
_scan(dir, depth, since = 0) {
|
|
44
44
|
if (depth > 4) return;
|
|
45
|
+
const cutoff = since > 0 ? since - 300000 : 0;
|
|
45
46
|
try {
|
|
46
47
|
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
47
48
|
const fp = path.join(dir, d.name);
|
|
48
|
-
if (d.isFile() && d.name.endsWith('.jsonl'))
|
|
49
|
-
|
|
49
|
+
if (d.isFile() && d.name.endsWith('.jsonl')) {
|
|
50
|
+
if (cutoff > 0) {
|
|
51
|
+
try { if (fs.statSync(fp).mtimeMs < cutoff) continue; } catch (_) {}
|
|
52
|
+
}
|
|
53
|
+
this._debounce(fp);
|
|
54
|
+
} else if (d.isDirectory()) this._scan(fp, depth + 1, since);
|
|
50
55
|
}
|
|
51
56
|
} catch (_) {}
|
|
52
57
|
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/^\/([a-z])\//i, '$1:/').replace(/\/+$/, '').toLowerCase();
|
|
2
|
-
|
|
3
|
-
export const stripQuoted = (s) => s.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
4
|
-
|
|
5
|
-
const isAbs = (d) => d.startsWith('/') || /^[a-z]:/.test(d);
|
|
6
|
-
|
|
7
|
-
export function targetsOutsideCwd(line, cwd) {
|
|
8
|
-
const cwdN = normPath(cwd);
|
|
9
|
-
if (!cwdN) return false;
|
|
10
|
-
const stripped = stripQuoted(line).replace(/\\/g, '/');
|
|
11
|
-
const ctxM = stripped.match(/(?:^|[|&;]\s*)(?:cd|pushd)\s+([^\s|&;]+)/i) || stripped.match(/\bgit\s+-C\s+([^\s|&;]+)/i);
|
|
12
|
-
if (ctxM) { const d = normPath(ctxM[1]); if (isAbs(d) && !d.startsWith(cwdN)) return true; }
|
|
13
|
-
const absArgs = stripped.match(/(?:^|\s)((?:[a-z]:)?\/[^\s|&;"']+)/gi) || [];
|
|
14
|
-
for (const a of absArgs) { const d = normPath(a.trim()); if (isAbs(d) && !d.startsWith(cwdN)) return true; }
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function targetsSingleFile(line) {
|
|
19
|
-
let s = stripQuoted(line).split('|')[0];
|
|
20
|
-
s = s.replace(/\d*>>?\s*&?\s*\S+/g, ' ').replace(/<\s*\S+/g, ' ');
|
|
21
|
-
if (!/\b(grep|egrep|fgrep|rg|ag|ack)\b/.test(s)) return false;
|
|
22
|
-
if (/\s-[a-z]*[rR]\b|--recursive/.test(s)) return false;
|
|
23
|
-
const toks = s.trim().split(/\s+/);
|
|
24
|
-
const last = toks[toks.length - 1];
|
|
25
|
-
if (!last || last.startsWith('-')) return false;
|
|
26
|
-
if (/[*?{}\[\]]/.test(last)) return false;
|
|
27
|
-
if (last.endsWith('/')) return false;
|
|
28
|
-
return /\.[a-z0-9]{1,6}$/i.test(last) && !last.includes('|');
|
|
29
|
-
}
|