@boyingliu01/xp-gate 0.35.20 → 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 (34) hide show
  1. package/bin/xp-gate.js +12 -0
  2. package/hooks/pre-commit +29 -5
  3. package/hooks/pre-push +44 -5
  4. package/lib/__tests__/agent-runtime-journal.test.js +302 -0
  5. package/lib/__tests__/mirror-sync.test.js +277 -0
  6. package/lib/__tests__/runtime-journal.test.js +164 -0
  7. package/lib/__tests__/test-alignment-java-702.test.ts +11 -16
  8. package/lib/agent-runtime-journal.js +347 -0
  9. package/lib/mattpocock-license-verdict.json +1 -1
  10. package/lib/mirror-sync.js +335 -0
  11. package/lib/runtime-journal.js +156 -0
  12. package/mock-policy/AGENTS.md +2 -2
  13. package/mutation/AGENTS.md +2 -2
  14. package/package.json +1 -1
  15. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  17. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  18. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  19. package/plugins/dsh/package.json +1 -1
  20. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  21. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  22. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  23. package/plugins/opencode/package.json +1 -1
  24. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  25. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  26. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  27. package/plugins/qoder/plugin.json +1 -1
  28. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/principles/AGENTS.md +2 -2
  32. package/skills/delphi-review/AGENTS.md +2 -2
  33. package/skills/sprint-flow/AGENTS.md +2 -2
  34. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -17,6 +17,8 @@ const { arch } = require('../lib/arch.js');
17
17
  const { upgrade } = require('../lib/upgrade.js');
18
18
  const { bootstrap } = require('../lib/bootstrap.js');
19
19
  const { updateHooks } = require('../lib/update-hooks.js');
20
+ const { handleRuntimeJournal } = require('../lib/runtime-journal.js');
21
+ const { handleSyncMirrors } = require('../lib/mirror-sync.js');
20
22
  const gateRunner = require('../lib/gate-runner.js');
21
23
 
22
24
  // #392-#6: a user-supplied path embedded in a shell command string lets cmd.exe
@@ -350,6 +352,16 @@ const COMMANDS = {
350
352
  },
351
353
  usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
352
354
  },
355
+ 'runtime-journal': {
356
+ description: 'Measure the AGENTS.md runtime rules (blocking waits, sleep/polling, call batching) from agent session journals (#712)',
357
+ run: subargs => process.exit(handleRuntimeJournal(subargs)),
358
+ usage: 'xp-gate runtime-journal [--days N] [--json] [--sessions-dir <path>]'
359
+ },
360
+ 'sync-mirrors': {
361
+ description: 'Report or repair drift between canonical trees and their npm-package mirrors; dry-run unless --write (#713)',
362
+ run: subargs => process.exit(handleSyncMirrors(subargs)),
363
+ usage: 'xp-gate sync-mirrors [--write] [--json]'
364
+ },
353
365
  'gate-coverage': {
354
366
  description: 'One view of compliance against every gate: PASS / DEBT / SKIP / FAIL, with debt and evidence (#636)',
355
367
  run: subargs => {
package/hooks/pre-commit CHANGED
@@ -857,9 +857,14 @@ echo ""
857
857
  GATE_0_START=$(gate_start_ms)
858
858
  GATE_0_STATUS="PASS"
859
859
 
860
- CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
860
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null) || CURRENT_BRANCH=""
861
+ CURRENT_BRANCH=$(printf '%s' "$CURRENT_BRANCH" | tr -d '\r\n'); [ -n "$CURRENT_BRANCH" ] || CURRENT_BRANCH="HEAD"
861
862
  PROTECTED_BRANCHES="master develop trunk mainline"
862
- ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
863
+ # #711: same shape as the branch/commit captures above — `git rev-parse` can
864
+ # print a partial line before failing, so `|| echo "."` can yield "<something>\n."
865
+ # and every later `cd "$ROOT_DIR"` / path join would silently use a two-line value.
866
+ ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT_DIR=""
867
+ ROOT_DIR=$(printf '%s' "$ROOT_DIR" | tr -d '\r\n'); [ -n "$ROOT_DIR" ] || ROOT_DIR="."
863
868
 
864
869
  # Optional: project-level .protected-branches config file
865
870
  if [ -f "$ROOT_DIR/.protected-branches" ]; then
@@ -2664,7 +2669,8 @@ else
2664
2669
 
2665
2670
  SKIP_5A_BLOCK=false
2666
2671
  if [ "${SKIP_GATE_5A_BLOCK:-0}" = "1" ]; then
2667
- CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
2672
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null) || CURRENT_BRANCH=""
2673
+ CURRENT_BRANCH=$(printf '%s' "$CURRENT_BRANCH" | tr -d '\r\n'); [ -n "$CURRENT_BRANCH" ] || CURRENT_BRANCH="HEAD"
2668
2674
  if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
2669
2675
  REASON="${SKIP_GATE_5A_BLOCK_REASON:-not specified}"
2670
2676
  mkdir -p .xp-gate/reports
@@ -4988,8 +4994,26 @@ generate_quality_report() {
4988
4994
  local WARN_COUNT=0
4989
4995
  local NONPASS_COUNT=0
4990
4996
 
4991
- COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
4992
- BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
4997
+ # #711: capture the probe output first, then fall back — never `|| echo` a
4998
+ # command whose stdout is only shown on success.
4999
+ #
5000
+ # On a repository with no commits yet, `git rev-parse --abbrev-ref HEAD`
5001
+ # PRINTS "HEAD" and then exits non-zero. With `$(cmd || echo unknown)` the
5002
+ # captured value becomes "HEAD\nunknown" — two lines — and it is embedded
5003
+ # verbatim into the JSON report as `"branch": "HEAD\nunknown"`, which is not
5004
+ # valid JSON (a raw newline cannot appear inside a string literal).
5005
+ #
5006
+ # Gate 12 then reads that report on the NEXT commit and BLOCKS with
5007
+ # "YAML/JSON syntax errors" against a file the gate itself wrote. The first
5008
+ # commit of every freshly-initialised consumer repo therefore fails once for a
5009
+ # reason that has nothing to do with the user's change.
5010
+ COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null) || COMMIT_HASH=""
5011
+ [ -n "$COMMIT_HASH" ] || COMMIT_HASH="unknown"
5012
+ BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || BRANCH=""
5013
+ # A detached HEAD reports the literal word "HEAD"; keep it (it is the truth)
5014
+ # but never allow a multi-line value into the report.
5015
+ BRANCH=$(printf '%s' "$BRANCH" | tr -d '\r\n')
5016
+ [ -n "$BRANCH" ] || BRANCH="unknown"
4993
5017
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
4994
5018
 
4995
5019
  # Scoring per #507 S3: SKIP removes a gate from the denominator (a missing
package/hooks/pre-push CHANGED
@@ -53,7 +53,11 @@ write_pre_push_report() {
53
53
  report_ts=$(date -u +"%Y-%m-%d-%H%M%S")
54
54
  reports_dir=".xp-gate/reports/pre-push"
55
55
  report_file="${reports_dir}/${report_ts}.json"
56
- commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
56
+ # #710: `git rev-parse` prints a partial line before it fails, so
57
+ # `$(cmd || echo x)` captures "<partial>\nx". Strip newlines before the value
58
+ # reaches this JSON journal — a raw newline cannot appear inside a JSON string.
59
+ commit_hash=$(git rev-parse HEAD 2>/dev/null) || commit_hash=""
60
+ commit_hash=$(printf '%s' "$commit_hash" | tr -d '\r\n'); [ -n "$commit_hash" ] || commit_hash="unknown"
57
61
  verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
58
62
  mkdir -p "$reports_dir"
59
63
  # #544: the survivor detail is a JSON document, so it cannot be interpolated
@@ -352,7 +356,9 @@ _mp_now_ms() {
352
356
  esac
353
357
  }
354
358
  MP_START_MS="$(_mp_now_ms)"
355
- MP_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
359
+ MP_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || MP_ROOT=""
360
+ MP_ROOT=$(printf '%s' "$MP_ROOT" | tr -d '
361
+ '); [ -n "$MP_ROOT" ] || MP_ROOT="."
356
362
  if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
357
363
  if (cd "$MP_ROOT" && bash scripts/check-hook-mirror.sh); then
358
364
  GATE_MP_STATUS="PASS"
@@ -418,7 +424,9 @@ fi
418
424
  # Uses sprint-gate.sh for standalone validation logic
419
425
  # ============================================================================
420
426
  GATE_S_STATUS="PASS"
421
- PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
427
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_ROOT=""
428
+ PROJECT_ROOT=$(printf '%s' "$PROJECT_ROOT" | tr -d '
429
+ '); [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="."
422
430
  SPRINT_GATE_SCRIPT=""
423
431
 
424
432
  # Same chain as pre-commit's Gate 11 (#476). The two tiers here both point at
@@ -1128,7 +1136,36 @@ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
1128
1136
  exit 1
1129
1137
  fi
1130
1138
  echo "⚠️ UI Gate BYPASSED — reason: $XP_GATE_BYPASS_REASON"
1131
- AUDIT_ENTRY="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"branch\":\"$CURRENT_BRANCH\",\"commit\":\"$(git rev-parse HEAD)\",\"user\":\"$(git config user.name || echo 'unknown')\",\"reason\":\"$XP_GATE_BYPASS_REASON\",\"bypass_type\":\"ui-gates\"}"
1139
+ # #710: build the whole record in node rather than interpolating shell
1140
+ # variables into a JSON string. Values come from the environment so no shell
1141
+ # quoting layer can mangle them (an earlier sed-based escaper silently
1142
+ # dropped the reason entirely when its backslash expression broke).
1143
+ #
1144
+ # The reason is free-form USER INPUT: one double quote in it used to end the
1145
+ # JSON string early and turn the audit line into garbage. An audit trail that
1146
+ # can be corrupted by the thing it is auditing is not an audit trail.
1147
+ AUDIT_ENTRY=$(
1148
+ _AJ_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
1149
+ _AJ_BRANCH="$CURRENT_BRANCH" \
1150
+ _AJ_COMMIT="$(git rev-parse HEAD 2>/dev/null)" \
1151
+ _AJ_USER="$(git config user.name 2>/dev/null)" \
1152
+ _AJ_REASON="$XP_GATE_BYPASS_REASON" \
1153
+ node -e '
1154
+ const env = process.env;
1155
+ const clean = (v, fallback) => {
1156
+ const s = String(v === undefined ? "" : v).replace(/[\r\n]+/g, " ").trim();
1157
+ return s === "" ? fallback : s;
1158
+ };
1159
+ process.stdout.write(JSON.stringify({
1160
+ timestamp: env._AJ_TS,
1161
+ branch: clean(env._AJ_BRANCH, "unknown"),
1162
+ commit: clean(env._AJ_COMMIT, "unknown"),
1163
+ user: clean(env._AJ_USER, "unknown"),
1164
+ reason: clean(env._AJ_REASON, ""),
1165
+ bypass_type: "ui-gates",
1166
+ }));
1167
+ ' 2>/dev/null
1168
+ )
1132
1169
  echo "$AUDIT_ENTRY" >> .audit-log.jsonl
1133
1170
  else
1134
1171
  # Detect UI changes in pushed files.
@@ -1310,7 +1347,9 @@ CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1310
1347
  # evidence (#574). It is passed explicitly instead of letting the validator
1311
1348
  # infer it from cwd: the gate's subject is the repo being pushed, and a cwd-
1312
1349
  # derived lookup would bind xp-gate's own config whenever the suite runs here.
1313
- MW_PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
1350
+ MW_PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || MW_PROJECT_ROOT=""
1351
+ MW_PROJECT_ROOT=$(printf '%s' "$MW_PROJECT_ROOT" | tr -d '
1352
+ ')
1314
1353
  if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "$CURRENT_BRANCH" "$CURRENT_TIME" "$MW_PROJECT_ROOT" 2>&1); then
1315
1354
  echo ""
1316
1355
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,302 @@
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 Expose the four AGENTS.md runtime anti-patterns (blocking job_output,
5
+ * literal sleep, CI polling loops, serial tool calls) as machine-checkable
6
+ * numbers instead of a prose rule that nobody measures.
7
+ * @covers AC-712-01
8
+ * @covers AC-712-02
9
+ * @covers AC-712-03
10
+ * @covers AC-712-04
11
+ * @covers AC-712-05
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const zlib = require('zlib');
19
+
20
+ const {
21
+ analyseSessions,
22
+ parseSessionBuffer,
23
+ classifyCommand,
24
+ summariseForSlot,
25
+ DEFAULT_MAX_PLAUSIBLE_MS,
26
+ } = require('../agent-runtime-journal');
27
+
28
+ let TMP_DIR;
29
+
30
+ beforeAll(() => {
31
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-journal-'));
32
+ });
33
+
34
+ afterAll(() => {
35
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
36
+ });
37
+
38
+ /**
39
+ * Build a session journal buffer from a compact event list.
40
+ * @param {Array<object>} events
41
+ * @returns {string}
42
+ */
43
+ function journalText(events) {
44
+ return events.map((e) => JSON.stringify(e)).join('\n') + '\n';
45
+ }
46
+
47
+ /**
48
+ * Emit a tool/call + tool/result pair at the given times.
49
+ * @param {string} callId
50
+ * @param {string} name
51
+ * @param {object} args
52
+ * @param {number} startT
53
+ * @param {number} endT
54
+ * @param {{isError?: boolean, text?: string}} [result]
55
+ * @param {{turn?: number, step?: number}} [pos]
56
+ * @returns {Array<object>}
57
+ */
58
+ function toolPair(callId, name, args, startT, endT, result = {}, pos = {}) {
59
+ const turn = pos.turn ?? 1;
60
+ const step = pos.step ?? 1;
61
+ return [
62
+ { type: 'tool/call', time: startT, data: { turn, step, callId, name, arguments: JSON.stringify(args) } },
63
+ {
64
+ type: 'tool/result',
65
+ time: endT,
66
+ data: {
67
+ turn,
68
+ step,
69
+ message: {
70
+ role: 'tool',
71
+ toolCallId: callId,
72
+ isError: !!result.isError,
73
+ content: [{ type: 'text', text: result.text ?? 'ok' }],
74
+ },
75
+ },
76
+ },
77
+ ];
78
+ }
79
+
80
+ describe('parseSessionBuffer', () => {
81
+ it('reads a zstd-compressed session journal', () => {
82
+ const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
83
+ const buf = zlib.zstdCompressSync(Buffer.from(journalText(events), 'utf8'));
84
+ const parsed = parseSessionBuffer(buf);
85
+ expect(parsed).toHaveLength(2);
86
+ expect(parsed[0].type).toBe('tool/call');
87
+ });
88
+
89
+ it('reads an uncompressed journal too, so fixtures need no encoder', () => {
90
+ const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
91
+ const parsed = parseSessionBuffer(Buffer.from(journalText(events), 'utf8'));
92
+ expect(parsed).toHaveLength(2);
93
+ });
94
+
95
+ it('skips malformed lines instead of throwing away the whole journal', () => {
96
+ const good = toolPair('c1', 'read', {}, 1000, 1010).map((e) => JSON.stringify(e));
97
+ const text = [good[0], 'not json at all', good[1]].join('\n');
98
+ const parsed = parseSessionBuffer(Buffer.from(text, 'utf8'));
99
+ expect(parsed).toHaveLength(2);
100
+ });
101
+ });
102
+
103
+ describe('classifyCommand', () => {
104
+ it('flags a literal sleep', () => {
105
+ expect(classifyCommand('cd /repo && sleep 600; echo ok').sleep).toBe(true);
106
+ });
107
+
108
+ it('does not flag words that merely contain "sleep"', () => {
109
+ expect(classifyCommand('echo sleepless').sleep).toBe(false);
110
+ });
111
+
112
+ it('flags a seq-driven polling loop', () => {
113
+ expect(classifyCommand('for i in $(seq 1 20); do gh run list; done').pollLoop).toBe(true);
114
+ });
115
+
116
+ it('flags gh run status polling', () => {
117
+ expect(classifyCommand('gh run list --limit 4 --json status').ciPolling).toBe(true);
118
+ expect(classifyCommand('gh run watch 123').ciPolling).toBe(true);
119
+ });
120
+
121
+ it('flags an oversized explicit timeout', () => {
122
+ expect(classifyCommand('timeout 1800 npx vitest run').oversizedTimeout).toBe(true);
123
+ expect(classifyCommand('timeout 60 npx vitest run').oversizedTimeout).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('analyseSessions — AC-712-01 blocking job_output', () => {
128
+ it('counts job_output calls that block longer than the threshold', () => {
129
+ const events = [
130
+ ...toolPair('j1', 'job_output', { job_id: 'b1', wait: true }, 0, 120000),
131
+ ...toolPair('j2', 'job_output', { job_id: 'b2' }, 200000, 200010),
132
+ ];
133
+ const dir = path.join(TMP_DIR, 'a');
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
136
+
137
+ const report = analyseSessions([dir]);
138
+ expect(report.blockingJobOutput.count).toBe(1);
139
+ expect(report.blockingJobOutput.totalMs).toBe(120000);
140
+ });
141
+
142
+ it('counts explicit wait:true separately — the rule names that exact mistake', () => {
143
+ const events = [
144
+ ...toolPair('j1', 'job_output', { job_id: 'b1', wait: true }, 0, 1000),
145
+ ...toolPair('j2', 'job_output', { job_id: 'b2', wait: true }, 2000, 3000),
146
+ ...toolPair('j3', 'job_output', { job_id: 'b3' }, 4000, 5000),
147
+ ];
148
+ const dir = path.join(TMP_DIR, 'b');
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
151
+
152
+ const report = analyseSessions([dir]);
153
+ expect(report.blockingJobOutput.explicitWaitTrue).toBe(2);
154
+ });
155
+ });
156
+
157
+ describe('analyseSessions — AC-712-02 idle waiting', () => {
158
+ it('totals literal-sleep commands', () => {
159
+ const events = [
160
+ ...toolPair('s1', 'bash', { command: 'sleep 300; echo done' }, 0, 300000),
161
+ ...toolPair('s2', 'bash', { command: 'npx tsc --noEmit' }, 310000, 320000),
162
+ ];
163
+ const dir = path.join(TMP_DIR, 'c');
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
166
+
167
+ const report = analyseSessions([dir]);
168
+ expect(report.idleWait.sleep.count).toBe(1);
169
+ expect(report.idleWait.sleep.totalMs).toBe(300000);
170
+ });
171
+
172
+ it('totals CI polling separately from sleep so the cause stays visible', () => {
173
+ const events = [
174
+ ...toolPair('p1', 'bash', { command: 'gh run list --limit 3' }, 0, 5000),
175
+ ...toolPair('p2', 'bash', { command: 'for i in $(seq 1 30); do sleep 10; done' }, 10000, 600000),
176
+ ];
177
+ const dir = path.join(TMP_DIR, 'd');
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
180
+
181
+ const report = analyseSessions([dir]);
182
+ expect(report.idleWait.ciPolling.count).toBe(1);
183
+ expect(report.idleWait.pollLoop.count).toBe(1);
184
+ });
185
+ });
186
+
187
+ describe('analyseSessions — AC-712-03 call batching', () => {
188
+ it('reports calls per step and the share of batched steps', () => {
189
+ // step 1: two concurrent calls; step 2: one call.
190
+ const s1 = [
191
+ ...toolPair('a', 'read', {}, 0, 5, {}, { step: 1 }),
192
+ ...toolPair('b', 'grep', {}, 0, 5, {}, { step: 1 }),
193
+ ];
194
+ const s2 = toolPair('c', 'bash', { command: 'git status' }, 100, 150, {}, { step: 2 });
195
+ const dir = path.join(TMP_DIR, 'e');
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText([...s1, ...s2]));
198
+
199
+ const report = analyseSessions([dir]);
200
+ expect(report.batching.stepsWithCalls).toBe(2);
201
+ expect(report.batching.stepsWithMultipleCalls).toBe(1);
202
+ expect(report.batching.batchedRatioPct).toBe(50);
203
+ expect(report.batching.callsPerStep).toBeCloseTo(1.5, 5);
204
+ });
205
+ });
206
+
207
+ describe('analyseSessions — AC-712-04 implausible durations', () => {
208
+ it('excludes resume/flush artifacts above the plausible ceiling', () => {
209
+ const huge = DEFAULT_MAX_PLAUSIBLE_MS + 1;
210
+ const events = [
211
+ ...toolPair('x1', 'bash', { command: 'echo hi' }, 0, huge),
212
+ ...toolPair('x2', 'bash', { command: 'echo hi' }, huge + 1000, huge + 1010),
213
+ ];
214
+ const dir = path.join(TMP_DIR, 'f');
215
+ fs.mkdirSync(dir, { recursive: true });
216
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
217
+
218
+ const report = analyseSessions([dir]);
219
+ expect(report.implausible.count).toBe(1);
220
+ // The surviving sample must not be averaged with the excluded one.
221
+ expect(report.toolCalls).toBe(2);
222
+ expect(report.timedCalls).toBe(1);
223
+ });
224
+ });
225
+
226
+ describe('summariseForSlot — AC-712-05 output contract', () => {
227
+ it('emits a one-screen summary naming every measured anti-pattern', () => {
228
+ const events = [
229
+ ...toolPair('j1', 'job_output', { wait: true }, 0, 600000),
230
+ ...toolPair('s1', 'bash', { command: 'sleep 60' }, 700000, 760000),
231
+ ];
232
+ const dir = path.join(TMP_DIR, 'g');
233
+ fs.mkdirSync(dir, { recursive: true });
234
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
235
+
236
+ const report = analyseSessions([dir]);
237
+ const lines = summariseForSlot(report);
238
+ expect(Array.isArray(lines)).toBe(true);
239
+ expect(lines.length).toBeLessThanOrEqual(15);
240
+ const text = lines.join('\n');
241
+ expect(text).toMatch(/job_output/i);
242
+ expect(text).toMatch(/sleep/i);
243
+ });
244
+
245
+ it('says NOT MEASURABLE rather than 0% when there are no steps to measure', () => {
246
+ const dir = path.join(TMP_DIR, 'h');
247
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
248
+ // Journal with no tool calls at all.
249
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText([{ type: 'session', time: 0 }]));
250
+
251
+ const report = analyseSessions([dir]);
252
+ const text = summariseForSlot(report).join('\n');
253
+ expect(text).toMatch(/NOT MEASURABLE/i);
254
+ });
255
+ });
256
+
257
+ describe('analyseSessions — AC-712-06 time window', () => {
258
+ it('counts only calls inside [sinceMs, untilMs) so a before/after A/B is possible', () => {
259
+ const events = [
260
+ // Before the window: one 600s sleep that must NOT be counted.
261
+ ...toolPair('old', 'bash', { command: 'sleep 600' }, 1000, 601000),
262
+ // Inside the window: one 60s sleep that must be counted.
263
+ ...toolPair('new', 'bash', { command: 'sleep 60' }, 2_000_000, 2_060_000),
264
+ ];
265
+ const dir = path.join(TMP_DIR, 'win');
266
+ fs.mkdirSync(dir, { recursive: true });
267
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
268
+
269
+ const report = analyseSessions([dir], { sinceMs: 1_500_000 });
270
+ expect(report.idleWait.sleep.count).toBe(1);
271
+ expect(report.idleWait.sleep.totalMs).toBe(60_000);
272
+ });
273
+
274
+ it('excludes calls at or after untilMs', () => {
275
+ const events = [
276
+ ...toolPair('in', 'bash', { command: 'sleep 60' }, 2_000_000, 2_060_000),
277
+ ...toolPair('after', 'bash', { command: 'sleep 300' }, 3_000_000, 3_300_000),
278
+ ];
279
+ const dir = path.join(TMP_DIR, 'win2');
280
+ fs.mkdirSync(dir, { recursive: true });
281
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl'), journalText(events));
282
+
283
+ const report = analyseSessions([dir], { sinceMs: 1_000_000, untilMs: 2_500_000 });
284
+ expect(report.idleWait.sleep.count).toBe(1);
285
+ expect(report.idleWait.sleep.totalMs).toBe(60_000);
286
+ });
287
+ });
288
+
289
+ describe('analyseSessions — robustness', () => {
290
+ it('ignores a directory with no journal instead of crashing', () => {
291
+ const dir = path.join(TMP_DIR, 'empty');
292
+ fs.mkdirSync(dir, { recursive: true });
293
+ const report = analyseSessions([dir]);
294
+ expect(report.toolCalls).toBe(0);
295
+ });
296
+
297
+ it('returns an empty-but-shaped report for an empty session list', () => {
298
+ const report = analyseSessions([]);
299
+ expect(report.sessions).toBe(0);
300
+ expect(report.batching.stepsWithCalls).toBe(0);
301
+ });
302
+ });