ccsniff 1.1.20 → 1.1.22
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 +106 -24
- package/src/discipline-helpers.js +29 -0
package/package.json
CHANGED
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', 'continuation-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', 'verb-bypass-discipline', 'spool-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
function parseArgs(argv) {
|
|
@@ -73,6 +74,11 @@ USAGE
|
|
|
73
74
|
ccsniff --continuation-discipline [--stats] assistant turn that ends in prose with no tool call:
|
|
74
75
|
a summary, or deferred intent ("Let me X" / "I'll X" / "Now to")
|
|
75
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)
|
|
76
82
|
ccsniff --stats [filters]
|
|
77
83
|
|
|
78
84
|
TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
|
|
@@ -374,6 +380,96 @@ if (opts['git-discipline']) {
|
|
|
374
380
|
process.exit(0);
|
|
375
381
|
}
|
|
376
382
|
|
|
383
|
+
// ---------- verb-bypass-discipline (a platform-native capability used where a plugkit verb exists)
|
|
384
|
+
// The class rule: every platform-native tool that has a plugkit verb is forbidden in favor of the
|
|
385
|
+
// verb — WebFetch/WebSearch -> the `fetch` verb; a Task/Agent search subagent -> `codesearch`; raw
|
|
386
|
+
// puppeteer/playwright/chrome -> the `browser` verb; a Write into a platform memory dir -> `memorize-fire`.
|
|
387
|
+
// High-precision per-tool patterns; each violation names the verb it should have used.
|
|
388
|
+
if (opts['verb-bypass-discipline']) {
|
|
389
|
+
const includeSubagents = opts['include-subagents'];
|
|
390
|
+
const MEM_PATH = /[\/\\]\.(?:claude[\/\\]projects[\/\\].*[\/\\]memory|codex[\/\\]memory|cursor)[\/\\]/i;
|
|
391
|
+
const RAW_BROWSER = /\b(?:puppeteer|playwright|chromium|chrome\.exe|google-chrome|chrome-headless)\b|--headless\b/i;
|
|
392
|
+
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;
|
|
393
|
+
const violations = [];
|
|
394
|
+
for (const ev of all) {
|
|
395
|
+
if (!filter(ev)) continue;
|
|
396
|
+
if (ev.block?.type !== 'tool_use') continue;
|
|
397
|
+
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
398
|
+
const name = ev.block?.name || '';
|
|
399
|
+
const input = ev.block?.input || {};
|
|
400
|
+
let kind = null, should = null, detail = '';
|
|
401
|
+
if (name === 'WebFetch') { kind = 'webfetch-not-fetch-verb'; should = 'fetch'; detail = String(input.url || '').slice(0, 120); }
|
|
402
|
+
else if (name === 'WebSearch') { kind = 'websearch-not-fetch-verb'; should = 'fetch'; detail = String(input.query || '').slice(0, 120); }
|
|
403
|
+
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); }
|
|
404
|
+
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); }
|
|
405
|
+
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); }
|
|
406
|
+
if (!kind) continue;
|
|
407
|
+
violations.push({ ts: ev.timestamp, sid: ev.conversation.id, project: path.basename(ev.conversation.cwd || ''), kind, should, detail });
|
|
408
|
+
}
|
|
409
|
+
const byKind = new Map();
|
|
410
|
+
for (const v of violations) byKind.set(v.kind, (byKind.get(v.kind) || 0) + 1);
|
|
411
|
+
if (opts.stats || opts.count) {
|
|
412
|
+
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
413
|
+
process.stdout.write(`# ${violations.length} verb-bypass-discipline violations\n`);
|
|
414
|
+
for (const [k, c] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${k}\n`);
|
|
415
|
+
const byProj = new Map();
|
|
416
|
+
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
417
|
+
process.stdout.write(`# by project\n`);
|
|
418
|
+
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
for (const v of violations) {
|
|
422
|
+
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`);
|
|
423
|
+
}
|
|
424
|
+
process.stderr.write(`# ${violations.length} violations (${[...byKind.entries()].map(([k, c]) => `${k}:${c}`).join(' ')})\n`);
|
|
425
|
+
process.exit(0);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------- spool-discipline (a session that dispatches spool requests but reads NO responses)
|
|
429
|
+
// "The Write alone is not a dispatch." A session that writes `.gm/exec-spool/in/<verb>/<N>.txt`
|
|
430
|
+
// requests and reads ZERO `out/<...>.json` responses is fabricating the chain from prose — it never
|
|
431
|
+
// observed a single plugkit response. Session-level by design: batching (write many, read the
|
|
432
|
+
// first/last) is endorsed, so reading even one out/ response clears the session. Only a session that
|
|
433
|
+
// reads none of its responses is flagged — high precision, no batching false-positive.
|
|
434
|
+
if (opts['spool-discipline']) {
|
|
435
|
+
const includeSubagents = opts['include-subagents'];
|
|
436
|
+
const SPOOL_IN_WRITE = /(?:>\s*[^>|]*|file_path["'\s:]+["']?[^"']*)\.gm[\/\\]exec-spool[\/\\]in[\/\\][a-z0-9_-]+[\/\\]\d+\./i;
|
|
437
|
+
const SPOOL_OUT = /\.gm[\/\\]exec-spool[\/\\]out[\/\\]/i;
|
|
438
|
+
const sess = new Map();
|
|
439
|
+
for (const ev of all) {
|
|
440
|
+
if (!filter(ev)) continue;
|
|
441
|
+
if (ev.block?.type !== 'tool_use') continue;
|
|
442
|
+
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
443
|
+
const sid = ev.conversation.id;
|
|
444
|
+
if (!sess.has(sid)) sess.set(sid, { writes: 0, reads: 0, project: path.basename(ev.conversation.cwd || ''), firstTs: ev.timestamp, lastTs: ev.timestamp });
|
|
445
|
+
const s = sess.get(sid);
|
|
446
|
+
s.lastTs = ev.timestamp;
|
|
447
|
+
const b = ev.block, inp = b.input || {};
|
|
448
|
+
const blob = b.name === 'Write' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
|
|
449
|
+
if (blob && SPOOL_IN_WRITE.test(blob)) s.writes++;
|
|
450
|
+
const rblob = b.name === 'Read' ? (inp.file_path || '') : (b.name === 'Bash' ? (inp.command || '') : '');
|
|
451
|
+
if (rblob && SPOOL_OUT.test(rblob)) s.reads++;
|
|
452
|
+
}
|
|
453
|
+
const violations = [];
|
|
454
|
+
for (const [sid, s] of sess) {
|
|
455
|
+
if (s.writes >= 1 && s.reads === 0) violations.push({ ts: s.lastTs, sid, project: s.project, writes: s.writes });
|
|
456
|
+
}
|
|
457
|
+
if (opts.stats || opts.count) {
|
|
458
|
+
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
459
|
+
process.stdout.write(`# ${violations.length} spool-discipline violations (session dispatched spool writes but read 0 responses)\n`);
|
|
460
|
+
const byProj = new Map();
|
|
461
|
+
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
462
|
+
process.stdout.write(`# by project\n`);
|
|
463
|
+
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
464
|
+
process.exit(0);
|
|
465
|
+
}
|
|
466
|
+
for (const v of violations) {
|
|
467
|
+
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`);
|
|
468
|
+
}
|
|
469
|
+
process.stderr.write(`# ${violations.length} sessions dispatched spool writes but read 0 responses\n`);
|
|
470
|
+
process.exit(0);
|
|
471
|
+
}
|
|
472
|
+
|
|
377
473
|
// ---------- search-discipline (flag native search that should have been codesearch/recall)
|
|
378
474
|
// A native-search bypass (Grep/Glob, the Explore/Task search subagent, or bash grep/rg/find/ag)
|
|
379
475
|
// emits NO plugkit deviation because it never touches the spool — it is invisible to gmsniff and
|
|
@@ -383,26 +479,10 @@ if (opts['git-discipline']) {
|
|
|
383
479
|
if (opts['search-discipline']) {
|
|
384
480
|
const includeSubagents = opts['include-subagents'];
|
|
385
481
|
const BASH_SEARCH = /(^|[|&;]|\s)(rg|grep|find|ag|ack|fd|fgrep|egrep)\s/;
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
// sibling repo outside cwd has NO codesearch index to route through, so the agent is forced to
|
|
391
|
-
// native search and flagging it is a false positive. Exempt a line that targets an absolute path
|
|
392
|
-
// or cd's into a directory that is not under the conversation cwd.
|
|
393
|
-
const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
394
|
-
const targetsOutsideCwd = (line, cwd) => {
|
|
395
|
-
const cwdN = normPath(cwd);
|
|
396
|
-
if (!cwdN) return false;
|
|
397
|
-
const stripped = stripQuoted(line);
|
|
398
|
-
// explicit `cd <dir>` to a path outside cwd
|
|
399
|
-
const cdM = stripped.match(/(?:^|[|&;]\s*)cd\s+([^\s|&;]+)/i);
|
|
400
|
-
if (cdM) { const d = normPath(cdM[1]); if (d.startsWith('/') || /^[a-z]:/.test(d)) { if (!d.startsWith(cwdN)) return true; } }
|
|
401
|
-
// absolute path argument to the search tool that is outside cwd
|
|
402
|
-
const absArgs = stripped.match(/(?:^|\s)((?:[a-z]:)?\/[^\s|&;"']+)/gi) || [];
|
|
403
|
-
for (const a of absArgs) { const d = normPath(a.trim()); if ((d.startsWith('/') || /^[a-z]:/.test(d)) && !d.startsWith(cwdN)) return true; }
|
|
404
|
-
return false;
|
|
405
|
-
};
|
|
482
|
+
// stripQuoted, targetsOutsideCwd (cwd-override + cross-repo exemption), and targetsSingleFile
|
|
483
|
+
// (single-file read-filter exemption) live in discipline-helpers.js so they are unit-testable
|
|
484
|
+
// without running the CLI. codesearch indexes only the conversation cwd, so a cross-repo or
|
|
485
|
+
// single-file grep has no index to route through and flagging it is a false positive.
|
|
406
486
|
const violations = [];
|
|
407
487
|
for (const ev of all) {
|
|
408
488
|
if (!filter(ev)) continue;
|
|
@@ -433,11 +513,13 @@ if (opts['search-discipline']) {
|
|
|
433
513
|
// not searching the codebase tree — codesearch has no equivalent for that and it is not the
|
|
434
514
|
// bypass the rule targets. Flag only a search tool that STARTS a pipeline segment (reads the
|
|
435
515
|
// tree directly), never one immediately downstream of a pipe.
|
|
436
|
-
|
|
516
|
+
// A line whose first non-space token is `#` is a shell comment, not a command — never a search.
|
|
517
|
+
const isTreeSearchLine = (line) => !/^\s*#/.test(line) && BASH_SEARCH.test(stripQuoted(line).split('|')[0]);
|
|
437
518
|
const hitLine = cmd.split('\n').find(isTreeSearchLine);
|
|
438
519
|
// Exempt a tree-search line that targets a sibling repo outside cwd (no codesearch index exists
|
|
439
|
-
// for it)
|
|
440
|
-
|
|
520
|
+
// for it), or that greps ONE explicit file (a read-filter codesearch cannot serve). Each
|
|
521
|
+
// command may cd/git -C first, so evaluate the context on the same line.
|
|
522
|
+
if (hitLine && !targetsOutsideCwd(hitLine, ev.conversation?.cwd) && !targetsSingleFile(hitLine)) {
|
|
441
523
|
kind = 'native-search-bash';
|
|
442
524
|
detail = (hitLine.split('|')[0]).trim().slice(0, 120);
|
|
443
525
|
}
|
|
@@ -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
|
+
}
|