ccsniff 1.1.18 → 1.1.20
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 +71 -1
- package/src/index.js +13 -3
- package/src/store.js +9 -1
package/package.json
CHANGED
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', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-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', 'bash-discipline', 'git-discipline', 'search-discipline', 'glyph-discipline', 'continuation-discipline', 'learning-xref', 'include-subagents', 'stats', 'count', 'help', 'h'],
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
function parseArgs(argv) {
|
|
@@ -70,6 +70,9 @@ USAGE
|
|
|
70
70
|
ccsniff --glyph-discipline [--stats] decorative glyphs (arrows/box/star/dot/check/emoji) written into files
|
|
71
71
|
(excludes subagents by default — --include-subagents to opt in;
|
|
72
72
|
excludes 'echo > .gm/exec-spool/in/...' as canonical spool-write)
|
|
73
|
+
ccsniff --continuation-discipline [--stats] assistant turn that ends in prose with no tool call:
|
|
74
|
+
a summary, or deferred intent ("Let me X" / "I'll X" / "Now to")
|
|
75
|
+
as the final sentence — the toolless-turn stop (paper §38)
|
|
73
76
|
ccsniff --stats [filters]
|
|
74
77
|
|
|
75
78
|
TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
|
|
@@ -502,6 +505,73 @@ if (opts['glyph-discipline']) {
|
|
|
502
505
|
process.exit(0);
|
|
503
506
|
}
|
|
504
507
|
|
|
508
|
+
// ---------- continuation-discipline (flag the toolless-turn stop: paper §38)
|
|
509
|
+
// An assistant message whose blocks contain NO tool_use, ending in prose, IS the turn ending —
|
|
510
|
+
// the harness reads only tool calls, so the session halts there. Two faces: a backward-facing
|
|
511
|
+
// summary, and deferred intent (a turn-final sentence naming the next move instead of making it,
|
|
512
|
+
// "Let me read X" / "I'll start with Y" / "Now to the core"). The plugkit watchdog catches a
|
|
513
|
+
// permanent stall at runtime; this catches the linguistic signature post-hoc in the transcript,
|
|
514
|
+
// where each block of one assistant message shares a (sid, timestamp) group.
|
|
515
|
+
if (opts['continuation-discipline']) {
|
|
516
|
+
const includeSubagents = opts['include-subagents'];
|
|
517
|
+
// Match against the LAST sentence of the message, extracted first — the deferred-intent or
|
|
518
|
+
// summary phrase opens that final clause. Testing the whole multi-paragraph blob with a
|
|
519
|
+
// start-anchor fails because the trailing sentence rarely begins right after a clean boundary.
|
|
520
|
+
const lastSentenceOf = (t) => {
|
|
521
|
+
const s = t.trimEnd();
|
|
522
|
+
const m = s.match(/[^.!?\n]*[.!?]?\s*$/);
|
|
523
|
+
let sent = (m ? m[0] : s).trim();
|
|
524
|
+
if (sent.length < 4) { const m2 = s.match(/[^\n]*$/); sent = (m2 ? m2[0] : s).trim(); }
|
|
525
|
+
return sent;
|
|
526
|
+
};
|
|
527
|
+
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;
|
|
528
|
+
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;
|
|
529
|
+
// Group by the real per-message id (msgId), not (sid,ts): a text block and its tool_use share
|
|
530
|
+
// one message. The discriminator for a genuine stop is stop_reason === 'end_turn' — a message
|
|
531
|
+
// that ends with a tool_use carries stop_reason 'tool_use' and a tool followed, so it is NOT a
|
|
532
|
+
// stop even if its text says "Let me X". Only an end_turn message ending in text is the harness
|
|
533
|
+
// halting on prose. Gating on end_turn is what separates the true stop from think-then-act.
|
|
534
|
+
const byMsg = new Map();
|
|
535
|
+
for (const ev of all) {
|
|
536
|
+
if (!filter(ev)) continue;
|
|
537
|
+
if (ev.role !== 'assistant') continue;
|
|
538
|
+
if (!includeSubagents && ev.conversation?.isSubagent) continue;
|
|
539
|
+
const b = ev.block || {};
|
|
540
|
+
const key = `${ev.conversation?.id || ''}|${b.msgId || ev.timestamp}`;
|
|
541
|
+
if (!byMsg.has(key)) byMsg.set(key, { sid: ev.conversation?.id || '', cwd: ev.conversation?.cwd || '', ts: ev.timestamp, hasTool: false, lastText: '', stopReason: null });
|
|
542
|
+
const m = byMsg.get(key);
|
|
543
|
+
if (b.stopReason) m.stopReason = b.stopReason;
|
|
544
|
+
if (b.type === 'tool_use') m.hasTool = true;
|
|
545
|
+
else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) m.lastText = b.text;
|
|
546
|
+
}
|
|
547
|
+
const violations = [];
|
|
548
|
+
for (const [, m] of byMsg) {
|
|
549
|
+
if (m.hasTool) continue;
|
|
550
|
+
if (m.stopReason !== 'end_turn') continue;
|
|
551
|
+
if (!m.lastText.trim()) continue;
|
|
552
|
+
const lastSentence = lastSentenceOf(m.lastText);
|
|
553
|
+
if (!lastSentence) continue;
|
|
554
|
+
const kind = DEFERRED.test(lastSentence) ? 'deferred-intent' : (SUMMARY.test(lastSentence) ? 'summary' : null);
|
|
555
|
+
if (!kind) continue;
|
|
556
|
+
violations.push({ ts: m.ts, sid: m.sid, project: path.basename(m.cwd || ''), kind, tail: lastSentence.slice(0, 160) });
|
|
557
|
+
}
|
|
558
|
+
violations.sort((a, b) => a.ts - b.ts);
|
|
559
|
+
if (opts.stats || opts.count) {
|
|
560
|
+
if (opts.count) { process.stdout.write(`${violations.length}\n`); process.exit(0); }
|
|
561
|
+
process.stdout.write(`# ${violations.length} continuation-discipline violations (toolless-turn stops)\n`);
|
|
562
|
+
const byProj = new Map();
|
|
563
|
+
for (const v of violations) byProj.set(v.project, (byProj.get(v.project) || 0) + 1);
|
|
564
|
+
process.stdout.write(`# by project\n`);
|
|
565
|
+
for (const [p, c] of [...byProj.entries()].sort((a, b) => b[1] - a[1])) process.stdout.write(` ${String(c).padStart(6)} ${p}\n`);
|
|
566
|
+
process.exit(0);
|
|
567
|
+
}
|
|
568
|
+
for (const v of violations) {
|
|
569
|
+
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`);
|
|
570
|
+
}
|
|
571
|
+
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`);
|
|
572
|
+
process.exit(0);
|
|
573
|
+
}
|
|
574
|
+
|
|
505
575
|
// ---------- learning-xref (join transcript turns to gm-log rs_learn signals)
|
|
506
576
|
if (opts['learning-xref']) {
|
|
507
577
|
const days = opts.days || 1;
|
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;
|
|
@@ -195,7 +195,7 @@ export function watch(projectsDir) {
|
|
|
195
195
|
export class JsonlReplayer extends JsonlWatcher {
|
|
196
196
|
constructor(projectsDir = DEFAULT_DIR) { super(projectsDir); }
|
|
197
197
|
|
|
198
|
-
replay({ since = 0, files: fileFilter = null } = {}) {
|
|
198
|
+
replay({ since = 0, files: fileFilter = null, maxEvents = 0 } = {}) {
|
|
199
199
|
const all = [];
|
|
200
200
|
const collect = (dir, depth) => {
|
|
201
201
|
if (depth > 5) return;
|
|
@@ -208,9 +208,19 @@ export class JsonlReplayer extends JsonlWatcher {
|
|
|
208
208
|
} catch {}
|
|
209
209
|
};
|
|
210
210
|
if (fs.existsSync(this._dir)) collect(this._dir, 0);
|
|
211
|
-
|
|
211
|
+
let chosen = fileFilter ? all.filter(fileFilter) : all;
|
|
212
|
+
// When a maxEvents budget is set, read newest files first and stop once the
|
|
213
|
+
// budget is met — so a huge projects tree never has its full backlog parsed
|
|
214
|
+
// into the heap at once (the load-time memory peak this guards against).
|
|
215
|
+
if (maxEvents > 0) {
|
|
216
|
+
chosen = chosen
|
|
217
|
+
.map(fp => { try { return { fp, m: fs.statSync(fp).mtimeMs }; } catch { return { fp, m: 0 }; } })
|
|
218
|
+
.sort((a, b) => b.m - a.m)
|
|
219
|
+
.map(x => x.fp);
|
|
220
|
+
}
|
|
212
221
|
let emitted = 0;
|
|
213
222
|
for (const fp of chosen) {
|
|
223
|
+
if (maxEvents > 0 && emitted >= maxEvents) break;
|
|
214
224
|
const fallbackSid = path.basename(fp, '.jsonl');
|
|
215
225
|
let data;
|
|
216
226
|
try { data = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
package/src/store.js
CHANGED
|
@@ -97,8 +97,16 @@ export class Store {
|
|
|
97
97
|
if (this.events.length > softCap) this.events.splice(0, this.events.length - this.maxEvents);
|
|
98
98
|
});
|
|
99
99
|
r.on('streaming_error', ev => { this.errors.push({ ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable }); });
|
|
100
|
-
|
|
100
|
+
// Bound the replay to a little above the retention cap and read newest-first,
|
|
101
|
+
// so a large projects tree never parses its entire backlog into the heap at
|
|
102
|
+
// once. The post-replay trim then lands exactly on maxEvents.
|
|
103
|
+
const stats = r.replay({ maxEvents: Math.floor(this.maxEvents * 1.2) });
|
|
101
104
|
this.fileCount = stats.files;
|
|
105
|
+
// Newest-first file read can leave events out of chronological order; restore
|
|
106
|
+
// it (downstream sessions/search/snippet logic assumes ascending ts) and
|
|
107
|
+
// renumber the positional index the BM25 index keys on.
|
|
108
|
+
this.events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
109
|
+
this.events.forEach((e, k) => { e.i = k; });
|
|
102
110
|
this.trimEvents();
|
|
103
111
|
this.rebuildIndex();
|
|
104
112
|
return stats;
|