@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.
- package/bin/xp-gate.js +12 -0
- package/hooks/pre-commit +29 -5
- package/hooks/pre-push +44 -5
- package/lib/__tests__/agent-runtime-journal.test.js +302 -0
- package/lib/__tests__/mirror-sync.test.js +277 -0
- package/lib/__tests__/runtime-journal.test.js +164 -0
- package/lib/__tests__/test-alignment-java-702.test.ts +11 -16
- package/lib/agent-runtime-journal.js +347 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/mirror-sync.js +335 -0
- package/lib/runtime-journal.js +156 -0
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- 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 ||
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
4992
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
+
});
|