@boyingliu01/xp-gate 0.35.21 → 0.35.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.
Files changed (31) hide show
  1. package/bin/xp-gate.js +12 -0
  2. package/lib/__tests__/agent-runtime-journal.test.js +302 -0
  3. package/lib/__tests__/mirror-sync.test.js +277 -0
  4. package/lib/__tests__/runtime-journal.test.js +164 -0
  5. package/lib/agent-runtime-journal.js +347 -0
  6. package/lib/mattpocock-license-verdict.json +1 -1
  7. package/lib/mirror-sync.js +335 -0
  8. package/lib/runtime-journal.js +156 -0
  9. package/mock-policy/AGENTS.md +2 -2
  10. package/mutation/AGENTS.md +2 -2
  11. package/package.json +1 -1
  12. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  13. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  14. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  15. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  16. package/plugins/dsh/package.json +1 -1
  17. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  18. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  19. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  20. package/plugins/opencode/package.json +1 -1
  21. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  22. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  23. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  24. package/plugins/qoder/plugin.json +1 -1
  25. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  26. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/principles/AGENTS.md +2 -2
  29. package/skills/delphi-review/AGENTS.md +2 -2
  30. package/skills/sprint-flow/AGENTS.md +2 -2
  31. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,164 @@
1
+ /**
2
+ * TDD: written RED first (per AGENTS.md TDD-GATE spec).
3
+ * @test REQ-712 Agent runtime efficiency must be measurable from session journals
4
+ * @intent The runtime-journal CLI must resolve a session store, honour --days,
5
+ * and report NOT MEASURABLE instead of a silent all-zero report.
6
+ * @covers AC-712-07
7
+ * @covers AC-712-08
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const { handleRuntimeJournal, sessionRootCandidates, pickSessionRoot, parseDays } = require('../runtime-journal');
16
+
17
+ let TMP_DIR;
18
+ let outSpy;
19
+ let captured;
20
+
21
+ beforeEach(() => {
22
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-cli-'));
23
+ captured = [];
24
+ outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
25
+ captured.push(String(chunk));
26
+ return true;
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ outSpy.mockRestore();
32
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
33
+ });
34
+
35
+ /**
36
+ * Write a session dir containing an uncompressed journal.
37
+ * @param {string} root
38
+ * @param {string} name
39
+ * @param {Array<object>} events
40
+ */
41
+ function writeSession(root, name, events) {
42
+ const dir = path.join(root, name);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), events.map((e) => JSON.stringify(e)).join('\n') + '\n');
45
+ }
46
+
47
+ /**
48
+ * Emit a tool/call + tool/result pair.
49
+ * @param {string} callId
50
+ * @param {string} name
51
+ * @param {object} args
52
+ * @param {number} startT
53
+ * @param {number} endT
54
+ * @returns {Array<object>}
55
+ */
56
+ function pair(callId, name, args, startT, endT) {
57
+ return [
58
+ { type: 'tool/call', time: startT, data: { turn: 1, step: 1, callId, name, arguments: JSON.stringify(args) } },
59
+ { type: 'tool/result', time: endT, data: { message: { toolCallId: callId, content: [{ type: 'text', text: 'ok' }] } } },
60
+ ];
61
+ }
62
+
63
+ describe('parseDays', () => {
64
+ it('defaults when the flag is absent', () => {
65
+ expect(parseDays(['--json'], 3)).toBe(3);
66
+ });
67
+
68
+ it('reads --days N', () => {
69
+ expect(parseDays(['--days', '7'], 3)).toBe(7);
70
+ });
71
+
72
+ it('rejects a non-numeric value rather than treating it as 0', () => {
73
+ expect(parseDays(['--days', 'abc'], 3)).toBe(3);
74
+ });
75
+ });
76
+
77
+ describe('sessionRootCandidates', () => {
78
+ it('honours DSH_SESSIONS_DIR as the highest-priority override', () => {
79
+ const prev = process.env.DSH_SESSIONS_DIR;
80
+ process.env.DSH_SESSIONS_DIR = 'C:/custom/store';
81
+ try {
82
+ expect(sessionRootCandidates('xp-gate')[0]).toBe('C:/custom/store');
83
+ } finally {
84
+ if (prev === undefined) delete process.env.DSH_SESSIONS_DIR;
85
+ else process.env.DSH_SESSIONS_DIR = prev;
86
+ }
87
+ });
88
+
89
+ it('derives the store slug from the full path, not the basename', () => {
90
+ const prev = process.env.DSH_SESSIONS_DIR;
91
+ delete process.env.DSH_SESSIONS_DIR;
92
+ try {
93
+ // DSH slugs `D:\projects\xp-gate` to `--D-projects-xp-gate--`. A
94
+ // basename-only guess would produce `--xp-gate--`, which never exists.
95
+ const candidates = sessionRootCandidates('D:\\projects\\xp-gate');
96
+ expect(candidates.some((c) => c.endsWith('--D-projects-xp-gate--'))).toBe(true);
97
+ } finally {
98
+ if (prev !== undefined) process.env.DSH_SESSIONS_DIR = prev;
99
+ }
100
+ });
101
+ });
102
+
103
+ describe('pickSessionRoot', () => {
104
+ it('picks the first candidate that actually holds a journal', () => {
105
+ const store = path.join(TMP_DIR, 'store');
106
+ writeSession(store, 's1', pair('a', 'read', {}, 1000, 1010));
107
+ const missing = path.join(TMP_DIR, 'nope');
108
+ expect(pickSessionRoot([missing, store])).toBe(store);
109
+ });
110
+
111
+ it('returns null when no candidate holds a journal', () => {
112
+ expect(pickSessionRoot([path.join(TMP_DIR, 'nope')])).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe('handleRuntimeJournal — AC-712-07 NOT MEASURABLE', () => {
117
+ it('reports NOT MEASURABLE and exits 0 when no journal is found', () => {
118
+ const code = handleRuntimeJournal(['--sessions-dir', path.join(TMP_DIR, 'absent')]);
119
+ expect(code).toBe(0);
120
+ expect(captured.join('')).toMatch(/NOT MEASURABLE/i);
121
+ });
122
+
123
+ it('reports measurable:false in JSON form so a machine reader sees the gap', () => {
124
+ handleRuntimeJournal(['--json', '--sessions-dir', path.join(TMP_DIR, 'absent')]);
125
+ const payload = JSON.parse(captured.join(''));
126
+ expect(payload.measurable).toBe(false);
127
+ expect(payload.reason).toMatch(/no session journals/i);
128
+ });
129
+ });
130
+
131
+ describe('handleRuntimeJournal — AC-712-08 report content', () => {
132
+ it('measures a real journal and emits the anti-pattern numbers', () => {
133
+ const store = path.join(TMP_DIR, 'store');
134
+ const now = Date.now();
135
+ writeSession(store, 's1', [
136
+ ...pair('j1', 'job_output', { job_id: 'b', wait: true }, now - 600000, now - 60000),
137
+ ...pair('s1', 'bash', { command: 'sleep 120' }, now - 50000, now - 1000),
138
+ ]);
139
+
140
+ const code = handleRuntimeJournal(['--json', '--sessions-dir', store, '--days', '1']);
141
+ expect(code).toBe(0);
142
+ const payload = JSON.parse(captured.join(''));
143
+ expect(payload.measurable).toBe(true);
144
+ expect(payload.blockingJobOutput.explicitWaitTrue).toBe(1);
145
+ expect(payload.idleWait.sleep.count).toBe(1);
146
+ });
147
+
148
+ it('excludes calls outside the --days window', () => {
149
+ const store = path.join(TMP_DIR, 'store');
150
+ const now = Date.now();
151
+ const oldT = now - 30 * 24 * 60 * 60 * 1000;
152
+ writeSession(store, 's1', pair('j1', 'job_output', { wait: true }, oldT, oldT + 600000));
153
+
154
+ handleRuntimeJournal(['--json', '--sessions-dir', store, '--days', '1']);
155
+ const payload = JSON.parse(captured.join(''));
156
+ expect(payload.blockingJobOutput.explicitWaitTrue).toBe(0);
157
+ });
158
+
159
+ it('always exits 0 — this is a diagnostic, not a gate', () => {
160
+ const store = path.join(TMP_DIR, 'store');
161
+ writeSession(store, 's1', pair('a', 'read', {}, 1000, 1010));
162
+ expect(handleRuntimeJournal(['--sessions-dir', store])).toBe(0);
163
+ });
164
+ });
@@ -0,0 +1,347 @@
1
+ /**
2
+ * xp-gate agent-runtime-journal — measure the AGENTS.md runtime anti-patterns.
3
+ *
4
+ * `AGENTS.md` §AGENT RUNTIME DISCIPLINE has named three rules since 2026-10-30.
5
+ * Rule 1 (native read/grep/glob instead of shell) took effect — measurement on
6
+ * 2026-11-02 showed pure-read shell calls fall from 32.7% to 0.2%. Rules 2 and
7
+ * 3 did not: calls/step stayed at 1.02, and `job_output` was called with an
8
+ * explicit `wait: true` 103 times across three days, blocking 8.9 h.
9
+ *
10
+ * The reason a prose rule was ignored is that nothing measured it. This module
11
+ * turns those rules into numbers read from the agent's own session journals, so
12
+ * `retro`/CI can show the cost instead of restating the prohibition.
13
+ *
14
+ * Zero npm dependencies (Node built-ins only) — `src/npm-package/` ships
15
+ * zero-install, and `zlib.zstdDecompressSync` covers the journal format.
16
+ *
17
+ * @module agent-runtime-journal
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const zlib = require('zlib');
25
+
26
+ /**
27
+ * A tool call cannot plausibly run longer than this. Beyond it, the recorded
28
+ * span measures a session resume or log flush, not work: the 2026-10-30
29
+ * diagnosis found single `pwsh` calls "lasting" 78 hours because the matching
30
+ * result was only written at the next compaction/prune. Excluding them is what
31
+ * makes the totals mean anything.
32
+ */
33
+ const DEFAULT_MAX_PLAUSIBLE_MS = 30 * 60 * 1000;
34
+
35
+ /** A `job_output` call counts as blocking once it waits longer than this. */
36
+ const DEFAULT_BLOCKING_THRESHOLD_MS = 30 * 1000;
37
+
38
+ /** Journal filename used by the DSH session store. */
39
+ const JOURNAL_BASENAME = 'session.v4.jsonl';
40
+
41
+ /**
42
+ * Parse a session journal buffer, accepting both the zstd-compressed on-disk
43
+ * form and plain UTF-8 (so fixtures need no encoder).
44
+ *
45
+ * Malformed lines are skipped rather than discarding the whole journal: one
46
+ * truncated tail line must not erase an otherwise usable session.
47
+ *
48
+ * @param {Buffer|string} buffer
49
+ * @returns {Array<object>} parsed events
50
+ */
51
+ function parseSessionBuffer(buffer) {
52
+ let text;
53
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(String(buffer), 'utf8');
54
+ // zstd magic number: 0x28 0xB5 0x2F 0xFD (little endian 0xFD2FB528).
55
+ if (buf.length >= 4 && buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd) {
56
+ try {
57
+ text = zlib.zstdDecompressSync(buf).toString('utf8');
58
+ } catch {
59
+ return [];
60
+ }
61
+ } else {
62
+ text = buf.toString('utf8');
63
+ }
64
+
65
+ const events = [];
66
+ for (const line of text.split('\n')) {
67
+ if (!line) continue;
68
+ try {
69
+ events.push(JSON.parse(line));
70
+ } catch {
71
+ // Skip a malformed line; the rest of the journal is still valid.
72
+ }
73
+ }
74
+ return events;
75
+ }
76
+
77
+ /**
78
+ * Resolve the journal file inside a session directory.
79
+ * @param {string} dir
80
+ * @returns {string|null} absolute path, or null when absent
81
+ */
82
+ function journalPathIn(dir) {
83
+ for (const name of [JOURNAL_BASENAME, `${JOURNAL_BASENAME}.zstd`]) {
84
+ const p = path.join(dir, name);
85
+ if (fs.existsSync(p)) return p;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Classify a shell command against the waiting anti-patterns rule 3 forbids.
92
+ *
93
+ * Returned flags are independent: a command can be both a poll loop and a
94
+ * literal sleep, and reporting them separately keeps the cause visible.
95
+ *
96
+ * @param {string} command
97
+ * @returns {{sleep: boolean, pollLoop: boolean, ciPolling: boolean, oversizedTimeout: boolean}}
98
+ */
99
+ function classifyCommand(command) {
100
+ const cmd = String(command || '');
101
+ return {
102
+ // `sleep N` as a command, not the word "sleepless" inside an argument.
103
+ sleep: /(?:^|[;&|(]\s*|\s)sleep\s+\d+/.test(cmd),
104
+ pollLoop: /for\s+\w+\s+in\s+\$\(seq|while\s+true|while\s*:\s*;/.test(cmd),
105
+ ciPolling: /gh\s+run\s+(?:list|watch|view)/.test(cmd),
106
+ // `timeout` only counts as oversized past 10 minutes; the diagnosis found
107
+ // 375 such calls whose p50 runtime was 6.3 s — budget far above reality.
108
+ oversizedTimeout: /timeout\s+(\d{3,})\b/.test(cmd) && Number((cmd.match(/timeout\s+(\d{3,})\b/) || [])[1]) >= 600,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Median of a numeric array (0 for an empty input).
114
+ * @param {number[]} values
115
+ * @returns {number}
116
+ */
117
+ function median(values) {
118
+ if (!values.length) return 0;
119
+ const sorted = values.slice().sort((a, b) => a - b);
120
+ const mid = sorted.length >> 1;
121
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
122
+ }
123
+
124
+ /**
125
+ * Round to one decimal place without leaving float noise in JSON output.
126
+ * @param {number} value
127
+ * @returns {number}
128
+ */
129
+ function round1(value) {
130
+ return Math.round(value * 10) / 10;
131
+ }
132
+
133
+ /**
134
+ * Build a measured record for every tool call in one journal.
135
+ *
136
+ * @param {Array<object>} events
137
+ * @param {number} maxPlausibleMs
138
+ * @returns {Array<{name: string, t: number, durMs: number|null, args: object, implausible: boolean, turn: number, step: number}>}
139
+ */
140
+ function extractCalls(events, maxPlausibleMs) {
141
+ /** @type {Map<string, number>} callId -> result timestamp */
142
+ const endedAt = new Map();
143
+ for (const e of events) {
144
+ if (e && e.type === 'tool/result') {
145
+ const id = e.data && e.data.message && e.data.message.toolCallId;
146
+ if (id && typeof e.time === 'number') endedAt.set(id, e.time);
147
+ }
148
+ }
149
+
150
+ const calls = [];
151
+ for (const e of events) {
152
+ if (!e || e.type !== 'tool/call') continue;
153
+ const d = e.data || {};
154
+ let args = {};
155
+ try {
156
+ args = JSON.parse(d.arguments || '{}');
157
+ } catch {
158
+ args = {};
159
+ }
160
+ const end = endedAt.get(d.callId);
161
+ const durMs = typeof end === 'number' && typeof e.time === 'number' ? end - e.time : null;
162
+ calls.push({
163
+ name: d.name || 'unknown',
164
+ t: e.time,
165
+ durMs,
166
+ args,
167
+ implausible: durMs !== null && (durMs < 0 || durMs > maxPlausibleMs),
168
+ turn: d.turn ?? 0,
169
+ step: d.step ?? 0,
170
+ });
171
+ }
172
+ return calls;
173
+ }
174
+
175
+ /**
176
+ * Summarise tool-call efficiency across a set of session directories.
177
+ *
178
+ * @param {string[]} sessionDirs directories containing `session.v4.jsonl[.zstd]`
179
+ * @param {{maxPlausibleMs?: number, blockingThresholdMs?: number, sinceMs?: number, untilMs?: number}} [options]
180
+ * `sinceMs`/`untilMs` bound the calls considered, so the same journal supports a
181
+ * before/after comparison instead of only an all-time total.
182
+ * @returns {object} report
183
+ */
184
+ function analyseSessions(sessionDirs, options = {}) {
185
+ const maxPlausibleMs = options.maxPlausibleMs ?? DEFAULT_MAX_PLAUSIBLE_MS;
186
+ const blockingThresholdMs = options.blockingThresholdMs ?? DEFAULT_BLOCKING_THRESHOLD_MS;
187
+ const sinceMs = typeof options.sinceMs === 'number' ? options.sinceMs : -Infinity;
188
+ const untilMs = typeof options.untilMs === 'number' ? options.untilMs : Infinity;
189
+
190
+ const allCalls = [];
191
+ let sessions = 0;
192
+
193
+ for (const dir of sessionDirs || []) {
194
+ const jp = journalPathIn(dir);
195
+ if (!jp) continue;
196
+ let buf;
197
+ try {
198
+ buf = fs.readFileSync(jp);
199
+ } catch {
200
+ continue;
201
+ }
202
+ const events = parseSessionBuffer(buf);
203
+ if (!events.length) continue;
204
+ sessions += 1;
205
+ for (const call of extractCalls(events, maxPlausibleMs)) {
206
+ if (typeof call.t === 'number' && call.t >= sinceMs && call.t < untilMs) allCalls.push(call);
207
+ }
208
+ }
209
+
210
+ const plausible = allCalls.filter((c) => c.durMs !== null ? !c.implausible : true);
211
+ const implausible = allCalls.filter((c) => c.implausible);
212
+ const timed = plausible.filter((c) => c.durMs !== null);
213
+
214
+ // ---- rule 3a: job_output blocking ----
215
+ const jobCalls = plausible.filter((c) => c.name === 'job_output');
216
+ const blocking = jobCalls.filter((c) => c.durMs !== null && c.durMs > blockingThresholdMs);
217
+ const explicitWaitTrue = jobCalls.filter((c) => c.args && c.args.wait === true);
218
+
219
+ // ---- rule 3b: idle waiting inside bash ----
220
+ const bashCalls = plausible.filter((c) => c.name === 'bash' || c.name === 'pwsh' || c.name === 'powershell');
221
+ const sleep = [];
222
+ const pollLoop = [];
223
+ const ciPolling = [];
224
+ const oversizedTimeout = [];
225
+ for (const c of bashCalls) {
226
+ const flags = classifyCommand(c.args && c.args.command);
227
+ if (flags.sleep) sleep.push(c);
228
+ if (flags.pollLoop) pollLoop.push(c);
229
+ if (flags.ciPolling) ciPolling.push(c);
230
+ if (flags.oversizedTimeout) oversizedTimeout.push(c);
231
+ }
232
+
233
+ // ---- rule 2: call batching ----
234
+ const perStep = new Map();
235
+ for (const c of plausible) {
236
+ if (!c.turn && !c.step) continue;
237
+ const key = `${c.turn}:${c.step}`;
238
+ perStep.set(key, (perStep.get(key) || 0) + 1);
239
+ }
240
+ const stepCounts = Array.from(perStep.values());
241
+ const stepsWithMultiple = stepCounts.filter((n) => n >= 2).length;
242
+
243
+ const total = (arr) => arr.reduce((acc, c) => acc + (c.durMs || 0), 0);
244
+
245
+ return {
246
+ sessions,
247
+ toolCalls: allCalls.length,
248
+ timedCalls: timed.length,
249
+ implausible: { count: implausible.length, totalMs: total(implausible) },
250
+ maxPlausibleMs,
251
+ blockingJobOutput: {
252
+ count: blocking.length,
253
+ totalMs: total(blocking),
254
+ explicitWaitTrue: explicitWaitTrue.length,
255
+ maxMs: blocking.length ? Math.max(...blocking.map((c) => c.durMs)) : 0,
256
+ thresholdMs: blockingThresholdMs,
257
+ },
258
+ idleWait: {
259
+ sleep: { count: sleep.length, totalMs: total(sleep) },
260
+ pollLoop: { count: pollLoop.length, totalMs: total(pollLoop) },
261
+ ciPolling: { count: ciPolling.length, totalMs: total(ciPolling) },
262
+ oversizedTimeout: { count: oversizedTimeout.length, totalMs: total(oversizedTimeout) },
263
+ },
264
+ batching: {
265
+ stepsWithCalls: stepCounts.length,
266
+ stepsWithMultipleCalls: stepsWithMultiple,
267
+ batchedRatioPct: stepCounts.length ? round1((stepsWithMultiple / stepCounts.length) * 100) : 0,
268
+ callsPerStep: stepCounts.length ? round1((plausible.length / stepCounts.length) * 100) / 100 : 0,
269
+ },
270
+ toolMedianMs: median(timed.map((c) => c.durMs)),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Reduce a report to a one-screen summary for `retro`/CI output.
276
+ *
277
+ * Deliberately reports NOT MEASURABLE instead of 0% when the journal holds
278
+ * nothing to measure — a zero that means "no data" reads as "no problem", the
279
+ * same false-green the gate-coverage work (#636) set out to kill.
280
+ *
281
+ * @param {object} report
282
+ * @returns {string[]} at most 15 lines
283
+ */
284
+ function summariseForSlot(report) {
285
+ if (!report || report.toolCalls === 0) {
286
+ return ['Runtime efficiency: NOT MEASURABLE (no tool calls in journal)'];
287
+ }
288
+
289
+ const hours = (ms) => `${round1(ms / 3600000)} h`;
290
+ const lines = [];
291
+
292
+ const blocked = report.blockingJobOutput;
293
+ lines.push(
294
+ `job_output blocked >${Math.round(blocked.thresholdMs / 1000)}s: ${blocked.count} call(s), ${hours(blocked.totalMs)}` +
295
+ ` (explicit wait:true = ${blocked.explicitWaitTrue})`
296
+ );
297
+
298
+ const idle = report.idleWait;
299
+ lines.push(`literal sleep: ${idle.sleep.count} call(s), ${hours(idle.sleep.totalMs)}`);
300
+ lines.push(`CI status polling: ${idle.ciPolling.count} call(s), ${hours(idle.ciPolling.totalMs)}`);
301
+ lines.push(`poll loops (seq/while): ${idle.pollLoop.count} call(s), ${hours(idle.pollLoop.totalMs)}`);
302
+ lines.push(`oversized timeout (>=600s): ${idle.oversizedTimeout.count} call(s), ${hours(idle.oversizedTimeout.totalMs)}`);
303
+
304
+ const batch = report.batching;
305
+ const batchLine = batch.stepsWithCalls
306
+ ? `call batching: ${batch.callsPerStep} calls/step, ${batch.batchedRatioPct}% of steps batched (target >=2.0)`
307
+ : 'call batching: NOT MEASURABLE (no steps in journal)';
308
+ lines.push(batchLine);
309
+
310
+ if (report.implausible.count) {
311
+ lines.push(`excluded ${report.implausible.count} implausible duration(s) (resume/log-flush artifacts)`);
312
+ }
313
+
314
+ return lines.slice(0, 15);
315
+ }
316
+
317
+ /**
318
+ * Discover session directories under a root (one level deep).
319
+ * @param {string} root
320
+ * @returns {string[]}
321
+ */
322
+ function discoverSessionDirs(root) {
323
+ let entries;
324
+ try {
325
+ entries = fs.readdirSync(root, { withFileTypes: true });
326
+ } catch {
327
+ return [];
328
+ }
329
+ const dirs = [];
330
+ for (const entry of entries) {
331
+ if (!entry.isDirectory()) continue;
332
+ const full = path.join(root, entry.name);
333
+ if (journalPathIn(full)) dirs.push(full);
334
+ }
335
+ return dirs;
336
+ }
337
+
338
+ module.exports = {
339
+ DEFAULT_MAX_PLAUSIBLE_MS,
340
+ DEFAULT_BLOCKING_THRESHOLD_MS,
341
+ parseSessionBuffer,
342
+ journalPathIn,
343
+ classifyCommand,
344
+ analyseSessions,
345
+ summariseForSlot,
346
+ discoverSessionDirs,
347
+ };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-11-02T04:04:20.814Z",
5
+ "checked_at": "2026-11-02T06:58:08.096Z",
6
6
  "source": "network"
7
7
  }