ccsniff 1.1.19 → 1.1.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
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,6 +2,7 @@
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';
5
6
  import fs from 'fs';
6
7
  import path from 'path';
7
8
  import os from 'os';
@@ -30,7 +31,7 @@ const FLAGS = {
30
31
  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
32
  multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd', 'exclude-sess', 'exclude-sid', 'exclude-cwd', 'exclude-project'],
32
33
  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', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
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', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
34
35
  };
35
36
 
36
37
  function parseArgs(argv) {
@@ -70,6 +71,9 @@ USAGE
70
71
  ccsniff --glyph-discipline [--stats] decorative glyphs (arrows/box/star/dot/check/emoji) written into files
71
72
  (excludes subagents by default — --include-subagents to opt in;
72
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)
73
77
  ccsniff --stats [filters]
74
78
 
75
79
  TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
@@ -380,26 +384,10 @@ if (opts['git-discipline']) {
380
384
  if (opts['search-discipline']) {
381
385
  const includeSubagents = opts['include-subagents'];
382
386
  const BASH_SEARCH = /(^|[|&;]|\s)(rg|grep|find|ag|ack|fd|fgrep|egrep)\s/;
383
- // A search-tool token inside a quoted string (echo/printf/node -e payloads) is text, not a shell
384
- // invocation; blank quoted bodies before matching, like git-discipline strips commit-message bodies.
385
- const stripQuoted = (s) => s.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'(?:\\.|[^'\\])*'/g, "''");
386
- // codesearch indexes ONLY the conversation's own cwd (the gm repo). A search whose target is a
387
- // sibling repo outside cwd has NO codesearch index to route through, so the agent is forced to
388
- // native search and flagging it is a false positive. Exempt a line that targets an absolute path
389
- // or cd's into a directory that is not under the conversation cwd.
390
- const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
391
- const targetsOutsideCwd = (line, cwd) => {
392
- const cwdN = normPath(cwd);
393
- if (!cwdN) return false;
394
- const stripped = stripQuoted(line);
395
- // explicit `cd <dir>` to a path outside cwd
396
- const cdM = stripped.match(/(?:^|[|&;]\s*)cd\s+([^\s|&;]+)/i);
397
- if (cdM) { const d = normPath(cdM[1]); if (d.startsWith('/') || /^[a-z]:/.test(d)) { if (!d.startsWith(cwdN)) return true; } }
398
- // absolute path argument to the search tool that is outside cwd
399
- const absArgs = stripped.match(/(?:^|\s)((?:[a-z]:)?\/[^\s|&;"']+)/gi) || [];
400
- for (const a of absArgs) { const d = normPath(a.trim()); if ((d.startsWith('/') || /^[a-z]:/.test(d)) && !d.startsWith(cwdN)) return true; }
401
- return false;
402
- };
387
+ // stripQuoted, targetsOutsideCwd (cwd-override + cross-repo exemption), and targetsSingleFile
388
+ // (single-file read-filter exemption) live in discipline-helpers.js so they are unit-testable
389
+ // without running the CLI. codesearch indexes only the conversation cwd, so a cross-repo or
390
+ // single-file grep has no index to route through and flagging it is a false positive.
403
391
  const violations = [];
404
392
  for (const ev of all) {
405
393
  if (!filter(ev)) continue;
@@ -430,11 +418,13 @@ if (opts['search-discipline']) {
430
418
  // not searching the codebase tree — codesearch has no equivalent for that and it is not the
431
419
  // bypass the rule targets. Flag only a search tool that STARTS a pipeline segment (reads the
432
420
  // tree directly), never one immediately downstream of a pipe.
433
- const isTreeSearchLine = (line) => BASH_SEARCH.test(stripQuoted(line).split('|')[0]);
421
+ // A line whose first non-space token is `#` is a shell comment, not a command — never a search.
422
+ const isTreeSearchLine = (line) => !/^\s*#/.test(line) && BASH_SEARCH.test(stripQuoted(line).split('|')[0]);
434
423
  const hitLine = cmd.split('\n').find(isTreeSearchLine);
435
424
  // Exempt a tree-search line that targets a sibling repo outside cwd (no codesearch index exists
436
- // for it). Each command may `cd` first, so evaluate the cd context on the same line.
437
- if (hitLine && !targetsOutsideCwd(hitLine, ev.conversation?.cwd)) {
425
+ // for it), or that greps ONE explicit file (a read-filter codesearch cannot serve). Each
426
+ // command may cd/git -C first, so evaluate the context on the same line.
427
+ if (hitLine && !targetsOutsideCwd(hitLine, ev.conversation?.cwd) && !targetsSingleFile(hitLine)) {
438
428
  kind = 'native-search-bash';
439
429
  detail = (hitLine.split('|')[0]).trim().slice(0, 120);
440
430
  }
@@ -502,6 +492,73 @@ if (opts['glyph-discipline']) {
502
492
  process.exit(0);
503
493
  }
504
494
 
495
+ // ---------- continuation-discipline (flag the toolless-turn stop: paper §38)
496
+ // An assistant message whose blocks contain NO tool_use, ending in prose, IS the turn ending —
497
+ // the harness reads only tool calls, so the session halts there. Two faces: a backward-facing
498
+ // summary, and deferred intent (a turn-final sentence naming the next move instead of making it,
499
+ // "Let me read X" / "I'll start with Y" / "Now to the core"). The plugkit watchdog catches a
500
+ // permanent stall at runtime; this catches the linguistic signature post-hoc in the transcript,
501
+ // where each block of one assistant message shares a (sid, timestamp) group.
502
+ if (opts['continuation-discipline']) {
503
+ const includeSubagents = opts['include-subagents'];
504
+ // Match against the LAST sentence of the message, extracted first — the deferred-intent or
505
+ // summary phrase opens that final clause. Testing the whole multi-paragraph blob with a
506
+ // start-anchor fails because the trailing sentence rarely begins right after a clean boundary.
507
+ const lastSentenceOf = (t) => {
508
+ const s = t.trimEnd();
509
+ const m = s.match(/[^.!?\n]*[.!?]?\s*$/);
510
+ let sent = (m ? m[0] : s).trim();
511
+ if (sent.length < 4) { const m2 = s.match(/[^\n]*$/); sent = (m2 ? m2[0] : s).trim(); }
512
+ return sent;
513
+ };
514
+ 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;
515
+ 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;
516
+ // Group by the real per-message id (msgId), not (sid,ts): a text block and its tool_use share
517
+ // one message. The discriminator for a genuine stop is stop_reason === 'end_turn' — a message
518
+ // that ends with a tool_use carries stop_reason 'tool_use' and a tool followed, so it is NOT a
519
+ // stop even if its text says "Let me X". Only an end_turn message ending in text is the harness
520
+ // halting on prose. Gating on end_turn is what separates the true stop from think-then-act.
521
+ const byMsg = new Map();
522
+ for (const ev of all) {
523
+ if (!filter(ev)) continue;
524
+ if (ev.role !== 'assistant') continue;
525
+ if (!includeSubagents && ev.conversation?.isSubagent) continue;
526
+ const b = ev.block || {};
527
+ const key = `${ev.conversation?.id || ''}|${b.msgId || ev.timestamp}`;
528
+ if (!byMsg.has(key)) byMsg.set(key, { sid: ev.conversation?.id || '', cwd: ev.conversation?.cwd || '', ts: ev.timestamp, hasTool: false, lastText: '', stopReason: null });
529
+ const m = byMsg.get(key);
530
+ if (b.stopReason) m.stopReason = b.stopReason;
531
+ if (b.type === 'tool_use') m.hasTool = true;
532
+ else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) m.lastText = b.text;
533
+ }
534
+ const violations = [];
535
+ for (const [, m] of byMsg) {
536
+ if (m.hasTool) continue;
537
+ if (m.stopReason !== 'end_turn') continue;
538
+ if (!m.lastText.trim()) continue;
539
+ const lastSentence = lastSentenceOf(m.lastText);
540
+ if (!lastSentence) continue;
541
+ const kind = DEFERRED.test(lastSentence) ? 'deferred-intent' : (SUMMARY.test(lastSentence) ? 'summary' : null);
542
+ if (!kind) continue;
543
+ violations.push({ ts: m.ts, sid: m.sid, project: path.basename(m.cwd || ''), kind, tail: lastSentence.slice(0, 160) });
544
+ }
545
+ violations.sort((a, b) => a.ts - b.ts);
546
+ if (opts.stats || opts.count) {
547
+ if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
548
+ process.stdout.write(`# ${violations.length} continuation-discipline violations (toolless-turn stops)\n`);
549
+ const byProj = new Map();
550
+ for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
551
+ process.stdout.write(`# by project\n`);
552
+ for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
553
+ process.exit(0);
554
+ }
555
+ for (const v of violations) {
556
+ 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`);
557
+ }
558
+ 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`);
559
+ process.exit(0);
560
+ }
561
+
505
562
  // ---------- learning-xref (join transcript turns to gm-log rs_learn signals)
506
563
  if (opts['learning-xref']) {
507
564
  const days = opts.days || 1;
@@ -0,0 +1,29 @@
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);
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
+ }
package/src/index.js CHANGED
@@ -160,7 +160,7 @@ export class JsonlWatcher extends EventEmitter {
160
160
  const newBlocks = e.message.content.slice(prev);
161
161
  if (newBlocks.length > 0) {
162
162
  this._emitted.set(key, e.message.content.length);
163
- for (const b of newBlocks) if (b?.type) this._push(conv, sid, b, 'assistant', ets);
163
+ for (const b of newBlocks) if (b?.type) this._push(conv, sid, { ...b, msgId: e.message.id, stopReason: e.message.stop_reason || null }, 'assistant', ets);
164
164
  }
165
165
  if (e.message.stop_reason) this._emitted.delete(key);
166
166
  return;