ccsniff 1.1.25 → 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.25",
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
@@ -30,7 +30,7 @@ const FLAGS = {
30
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'],
31
31
  multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
32
32
  number: ['limit', 'head', 'tail-n', 'ctx', 'truncate', 'days'],
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'],
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'],
34
34
  };
35
35
 
36
36
  function parseArgs(argv) {
@@ -109,6 +109,11 @@ OUTPUT
109
109
  --unsloth <out> write Unsloth training JSONL (one conversation per session per line)
110
110
  --unsloth-format <fmt> messages (OpenAI/ChatML, default) | sharegpt
111
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
+
112
117
  LIMITS
113
118
  by default, output caps at the 500 most recent matching events (after --limit/--tail-n/--ctx apply)
114
119
  --full-history disable the default 500-event cap and dump everything matched
@@ -244,6 +249,17 @@ if (opts.tail) {
244
249
  // ---------- one-shot collection (everything else needs the full set)
245
250
  const { stats, all } = collect(opts, since);
246
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
+
247
263
  // ---------- list-projects
248
264
  if (opts['list-projects']) {
249
265
  const projects = new Map();
@@ -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
+ }