ccsniff 1.1.24 → 1.1.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/README.md CHANGED
@@ -56,8 +56,13 @@ npx ccsniff -f # tail new events live
56
56
  npx ccsniff --rollup out.ndjson --since 7d
57
57
  npx ccsniff --unsloth train.jsonl --since 7d --no-subagents
58
58
  npx ccsniff --unsloth train.jsonl --unsloth-format sharegpt --since 7d
59
+ npx ccsniff --git-discipline --since 7d --project myrepo
60
+ npx ccsniff --search-discipline --since 7d
61
+ npx ccsniff --glyph-discipline --since 24h
59
62
  ```
60
63
 
64
+ Discipline audits: `--git-discipline` flags `git push` without a prior separate `git status --porcelain` Bash event and raw git push/commit inside gm (spool-dispatching) sessions; `--search-discipline` flags Grep/Glob discovery events inside gm sessions; `--glyph-discipline` flags decorative non-ASCII glyphs in assistant text (code blocks excluded). All compose with `--project`/`--since`.
65
+
61
66
  ### Unsloth training export
62
67
 
63
68
  `--unsloth <out>` writes one JSONL line per Claude Code session, ready for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline', 'verb-bypass-discipline', 'spool-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
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', 'git-discipline', 'search-discipline', 'glyph-discipline'],
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 --bash-discipline [--stats] Bash calls that should have used Read/Glob/Grep
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,22 @@ 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
+ AUDIT
113
+ --git-discipline flag git push without prior separate porcelain event; raw git push/commit in gm sessions
114
+ --search-discipline flag Grep/Glob discovery events inside gm (spool-dispatching) sessions
115
+ --glyph-discipline flag decorative non-ASCII glyphs in assistant text (code blocks excluded)
116
+
117
+ LIMITS
118
+ by default, output caps at the 500 most recent matching events (after --limit/--tail-n/--ctx apply)
119
+ --full-history disable the default 500-event cap and dump everything matched
120
+
126
121
  EXAMPLES
127
122
  ccsniff --since 24h --grep "rs-exec" --limit 50
128
123
  ccsniff --since 7d --until 1d --role user --json
@@ -172,6 +167,23 @@ function formatRow(ev, opts) {
172
167
  return `[${t}] [${repo}${tag}] ${ev.role}/${block.type || '?'}${tool}: ${out}\n`;
173
168
  }
174
169
 
170
+ // Human-readable demarcated block per event — for watching an agent's thinking, tool calls,
171
+ // and tool results go by as a live transcript rather than a dense single-line dump.
172
+ function formatTextBlock(ev) {
173
+ const conv = ev.conversation || {};
174
+ const block = ev.block || {};
175
+ const t = new Date(ev.timestamp).toISOString().slice(0, 19).replace('T', ' ');
176
+ const repo = path.basename(conv.cwd || '');
177
+ const tag = conv.isSubagent ? ' (subagent)' : '';
178
+ const kind = block.type === 'tool_use' ? `tool_use: ${block.name || '?'}`
179
+ : block.type === 'tool_result' ? 'tool_result'
180
+ : block.type === 'thinking' ? 'thinking'
181
+ : block.type || ev.role;
182
+ const header = `==== [${t}] ${repo}${tag} :: ${ev.role} :: ${kind} ====`;
183
+ const body = blockText(block).trim();
184
+ return `${header}\n${body}\n\n`;
185
+ }
186
+
175
187
  function collect(opts, since) {
176
188
  const r = new JsonlReplayer();
177
189
  const all = [];
@@ -227,9 +239,9 @@ if (opts.rollup) {
227
239
  // ---------- live tail (filter applied to live events)
228
240
  if (opts.tail) {
229
241
  const r = new JsonlReplayer();
230
- r.on('streaming_progress', ev => { if (filter(ev)) process.stdout.write(formatRow(ev, opts)); });
242
+ r.on('streaming_progress', ev => { if (filter(ev)) process.stdout.write(opts.text ? formatTextBlock(ev) : formatRow(ev, opts)); });
231
243
  r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
232
- r.start();
244
+ r.start(since);
233
245
  process.stdout.write('# tailing... (Ctrl-C to exit)\n');
234
246
  process.stdin.resume();
235
247
  } else {
@@ -237,6 +249,17 @@ if (opts.tail) {
237
249
  // ---------- one-shot collection (everything else needs the full set)
238
250
  const { stats, all } = collect(opts, since);
239
251
 
252
+ // ---------- discipline audits
253
+ if (opts['git-discipline'] || opts['search-discipline'] || opts['glyph-discipline']) {
254
+ const { gitDiscipline, searchDiscipline, glyphDiscipline } = await import('./discipline.js');
255
+ const rows = all.filter(filter);
256
+ if (opts['git-discipline']) gitDiscipline(rows);
257
+ if (opts['search-discipline']) searchDiscipline(rows);
258
+ if (opts['glyph-discipline']) glyphDiscipline(rows);
259
+ process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} in scope\n`);
260
+ process.exit(0);
261
+ }
262
+
240
263
  // ---------- list-projects
241
264
  if (opts['list-projects']) {
242
265
  const projects = new Map();
@@ -257,493 +280,6 @@ if (opts['list-projects']) {
257
280
  process.exit(0);
258
281
  }
259
282
 
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
283
  // ---------- list-tools
748
284
  if (opts['list-tools']) {
749
285
  const tools = new Map();
@@ -794,6 +330,13 @@ if (opts['tail-n']) rows = rows.slice(-opts['tail-n']);
794
330
  const limit = opts.limit || opts.head || 0;
795
331
  if (limit) rows = rows.slice(0, limit);
796
332
 
333
+ const DEFAULT_CAP = 500;
334
+ let capped = 0;
335
+ if (!opts['full-history'] && !opts.stats && !opts.count && !opts.unsloth && rows.length > DEFAULT_CAP) {
336
+ capped = rows.length - DEFAULT_CAP;
337
+ rows = rows.slice(-DEFAULT_CAP);
338
+ }
339
+
797
340
  // ---------- stats
798
341
  if (opts.stats) {
799
342
  const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
@@ -832,8 +375,9 @@ if (opts.unsloth) {
832
375
  process.exit(0);
833
376
  }
834
377
 
835
- for (const ev of rows) process.stdout.write(formatRow(ev, opts));
836
- process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched\n`);
378
+ for (const ev of rows) process.stdout.write(opts.text ? formatTextBlock(ev) : formatRow(ev, opts));
379
+ const capNote = capped ? ` (${capped} older events hidden by default cap pass --full-history to see all)` : '';
380
+ process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched${capNote}\n`);
837
381
 
838
382
  }
839
383
  }
@@ -0,0 +1,115 @@
1
+ import path from 'path';
2
+
3
+ const GM_SPOOL_RE = /\.gm[\\\/]exec-spool[\\\/]in[\\\/]/;
4
+ const GIT_PUSH_RE = /\bgit\s+push\b/;
5
+ const GIT_COMMIT_RE = /\bgit\s+(commit|push)\b/;
6
+ const PORCELAIN_RE = /git\s+status\s+--porcelain/;
7
+ const GLYPH_RE = /[\u{2190}-\u{21FF}\u{2500}-\u{25FF}\u{2600}-\u{27BF}\u{1F000}-\u{1FAFF}]/gu;
8
+
9
+ function groupSessions(rows) {
10
+ const sessions = new Map();
11
+ for (const ev of rows) {
12
+ const sid = ev.conversation?.id || '?';
13
+ if (!sessions.has(sid)) sessions.set(sid, []);
14
+ sessions.get(sid).push(ev);
15
+ }
16
+ for (const evs of sessions.values()) evs.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
17
+ return sessions;
18
+ }
19
+
20
+ function isGmSession(evs) {
21
+ for (const ev of evs) {
22
+ const b = ev.block || {};
23
+ if (b.type !== 'tool_use') continue;
24
+ if (b.name === 'Skill' && b.input?.skill === 'gm') return true;
25
+ if (b.name === 'Write' && GM_SPOOL_RE.test(String(b.input?.file_path || ''))) return true;
26
+ if (b.name === 'Bash' && GM_SPOOL_RE.test(String(b.input?.command || ''))) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ function sample(ev, detail) {
32
+ const sid = (ev.conversation?.id || '?').slice(0, 8);
33
+ const iso = new Date(ev.timestamp || 0).toISOString().slice(0, 19).replace('T', ' ');
34
+ const repo = path.basename(ev.conversation?.cwd || '');
35
+ const text = String(detail).replace(/\s+/g, ' ').slice(0, 160);
36
+ return ` [${iso}] [${repo}] ${sid} ${text}\n`;
37
+ }
38
+
39
+ function report(label, findings, maxSamples) {
40
+ process.stdout.write(`# ${label}: ${findings.length} finding(s)\n`);
41
+ for (const f of findings.slice(0, maxSamples)) process.stdout.write(f);
42
+ if (findings.length > maxSamples) process.stdout.write(` ... ${findings.length - maxSamples} more\n`);
43
+ }
44
+
45
+ export function gitDiscipline(rows, maxSamples = 10) {
46
+ const sessions = groupSessions(rows);
47
+ const pushNoPorcelain = [];
48
+ const gmRawGit = [];
49
+ let bashGit = 0;
50
+ for (const evs of sessions.values()) {
51
+ const gm = isGmSession(evs);
52
+ let porcelainSeen = false;
53
+ for (const ev of evs) {
54
+ const b = ev.block || {};
55
+ if (b.type !== 'tool_use' || b.name !== 'Bash') continue;
56
+ const cmd = String(b.input?.command || '');
57
+ if (!/\bgit\b/.test(cmd)) continue;
58
+ bashGit++;
59
+ if (PORCELAIN_RE.test(cmd) && !GIT_PUSH_RE.test(cmd)) porcelainSeen = true;
60
+ if (GIT_PUSH_RE.test(cmd) && !porcelainSeen) pushNoPorcelain.push(sample(ev, cmd));
61
+ if (gm && GIT_COMMIT_RE.test(cmd)) gmRawGit.push(sample(ev, cmd));
62
+ if (GIT_PUSH_RE.test(cmd)) porcelainSeen = false;
63
+ }
64
+ }
65
+ process.stdout.write(`# git-discipline: ${sessions.size} sessions, ${bashGit} raw git Bash events\n`);
66
+ report('push without prior separate porcelain event', pushNoPorcelain, maxSamples);
67
+ report('raw git push/commit inside gm session (spool bypass)', gmRawGit, maxSamples);
68
+ return pushNoPorcelain.length + gmRawGit.length;
69
+ }
70
+
71
+ export function searchDiscipline(rows, maxSamples = 10) {
72
+ const sessions = groupSessions(rows);
73
+ const findings = [];
74
+ let gmSessions = 0;
75
+ for (const evs of sessions.values()) {
76
+ if (!isGmSession(evs)) continue;
77
+ gmSessions++;
78
+ for (const ev of evs) {
79
+ const b = ev.block || {};
80
+ if (b.type !== 'tool_use') continue;
81
+ if (b.name !== 'Grep' && b.name !== 'Glob') continue;
82
+ const detail = `${b.name} ${b.input?.pattern || ''}`;
83
+ findings.push(sample(ev, detail));
84
+ }
85
+ }
86
+ process.stdout.write(`# search-discipline: ${sessions.size} sessions, ${gmSessions} gm sessions\n`);
87
+ report('Grep/Glob discovery inside gm session', findings, maxSamples);
88
+ return findings.length;
89
+ }
90
+
91
+ function stripCode(text) {
92
+ return text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '');
93
+ }
94
+
95
+ export function glyphDiscipline(rows, maxSamples = 10) {
96
+ const findings = [];
97
+ let scanned = 0;
98
+ let glyphTotal = 0;
99
+ for (const ev of rows) {
100
+ const b = ev.block || {};
101
+ if (ev.role !== 'assistant' || b.type !== 'text') continue;
102
+ scanned++;
103
+ const text = stripCode(String(b.text || ''));
104
+ const matches = text.match(GLYPH_RE);
105
+ if (!matches || !matches.length) continue;
106
+ glyphTotal += matches.length;
107
+ const uniq = [...new Set(matches)].slice(0, 8).join(' ');
108
+ const ctxIdx = text.search(GLYPH_RE);
109
+ const ctx = text.slice(Math.max(0, ctxIdx - 40), ctxIdx + 40);
110
+ findings.push(sample(ev, `${matches.length}x [${uniq}] ...${ctx}...`));
111
+ }
112
+ process.stdout.write(`# glyph-discipline: ${scanned} assistant text blocks scanned, ${glyphTotal} decorative glyphs\n`);
113
+ report('assistant text with decorative non-ASCII glyphs', findings, maxSamples);
114
+ return findings.length;
115
+ }
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')) this._debounce(fp);
49
- else if (d.isDirectory()) this._scan(fp, depth + 1);
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
- }