@bongos/core 1.19.819 → 1.19.820
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/.bongos-core.json +60 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +23 -18
- package/docs/copy-registry.json +76 -31
- package/docs/module-api-changelog.md +3 -1
- package/migrations/core_246_session_type.sql +49 -0
- package/modules/hall-ui/public/sessions.css +19 -0
- package/modules/hall-ui/public/sessions.html +16 -1
- package/modules/hall-ui/public/sessions.js +135 -6
- package/modules/sessions/db.js +92 -6
- package/modules/sessions/routes/sessions.js +18 -1
- package/modules/sessions/session-type.js +132 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-types.js +87 -0
- package/scripts/gds/fitness.js +12 -1
- package/scripts/gds/home-isolation-guard.js +126 -0
- package/scripts/gds/ship-merge.js +5 -1
- package/scripts/gds/ship-regen.js +29 -0
- package/src/module-api.js +1 -1
- package/tests/cli_package.mjs +18 -2
- package/tests/hall_roster_world.mjs +10 -5
- package/tests/home_isolation_guard.mjs +95 -0
- package/tests/session_type.mjs +215 -0
- package/tests/ship_regen_copy_inventory.mjs +69 -0
|
@@ -235,6 +235,34 @@ function regenerateFileMap() {
|
|
|
235
235
|
return 'committed-nofatal';
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// docs/copy-registry.json + docs/copy-inventory.md — the reader-facing copy the UI
|
|
239
|
+
// actually renders (task 1003556).
|
|
240
|
+
//
|
|
241
|
+
// WHY IT HAS TO RUN HERE. Every row in the registry carries a `line`, so editing ANY
|
|
242
|
+
// scanned UI file shifts line numbers and makes tests/copy_inventory.mjs red — on the
|
|
243
|
+
// PR's MERGE ref, not on the branch, which is the worst place to find out. Nothing in
|
|
244
|
+
// the ship path regenerated it (grep for copy-inventory across ship.js, ship-regen.js
|
|
245
|
+
// and ship-preflight-steps.js used to return zero hits) and fitness did not cover the
|
|
246
|
+
// pair either, so the builder got no local signal at all and learned from CI.
|
|
247
|
+
//
|
|
248
|
+
// Task 1003546 fixed the SERVER-side merge heals for the same artifact. Those repair a
|
|
249
|
+
// strand after it happens; this is the prevention, so the PR is not born stale.
|
|
250
|
+
function regenerateCopyInventory() {
|
|
251
|
+
console.log(' regenerating the copy registry + inventory from the shipped tree...');
|
|
252
|
+
shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'copy-inventory.js'))}`);
|
|
253
|
+
const dirty = gitOk('git status --porcelain docs/copy-registry.json docs/copy-inventory.md');
|
|
254
|
+
if (!dirty) {
|
|
255
|
+
console.log(' copy registry already current — nothing to commit');
|
|
256
|
+
return 'current';
|
|
257
|
+
}
|
|
258
|
+
shellOk('git add docs/copy-registry.json docs/copy-inventory.md');
|
|
259
|
+
if (shellOk('git commit -m "chore(copy): auto-regenerate the copy registry + inventory (task 1003556)"')) {
|
|
260
|
+
console.log(' committed auto-regenerated copy registry');
|
|
261
|
+
return 'committed';
|
|
262
|
+
}
|
|
263
|
+
return 'committed-nofatal';
|
|
264
|
+
}
|
|
265
|
+
|
|
238
266
|
// ---------- merge serialization lock (#462 C4; extended in #269 C6) ----------
|
|
239
267
|
//
|
|
240
268
|
// autoMerge() mutates the SHARED main worktree (REPO_ROOT). Two concurrent
|
|
@@ -313,6 +341,7 @@ module.exports = {
|
|
|
313
341
|
regenerateApiClient,
|
|
314
342
|
regenerateApiDocs,
|
|
315
343
|
regenerateDiagrams,
|
|
344
|
+
regenerateCopyInventory,
|
|
316
345
|
regenerateFileMap,
|
|
317
346
|
regenerateRepoMap,
|
|
318
347
|
regenerateSessionIndex,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.820'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/cli_package.mjs
CHANGED
|
@@ -21,6 +21,22 @@ import { spawnSync } from 'node:child_process';
|
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
22
|
|
|
23
23
|
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
// HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003763).
|
|
26
|
+
//
|
|
27
|
+
// os.homedir() reads USERPROFILE on win32 and never consults HOME, so
|
|
28
|
+
// `env: { ...process.env, HOME: home }` isolated nothing there: the installed CLI
|
|
29
|
+
// saw the DEVELOPER'S REAL session file. That is precisely the false green the
|
|
30
|
+
// comment below guards against — an unauthenticated-path assertion passing because
|
|
31
|
+
// the machine happens to be signed in.
|
|
32
|
+
//
|
|
33
|
+
// HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go. Named
|
|
34
|
+
// rather than inlined twice: this is the third site in the repo to need it (after
|
|
35
|
+
// tests/cli_sessions.mjs in task 1003760 and tests/cli_surface.mjs in task 1003941),
|
|
36
|
+
// and every one of them got it half-right first.
|
|
37
|
+
function homelessEnv(home, extra = {}) {
|
|
38
|
+
return { ...process.env, HOME: home, USERPROFILE: home, HOMEDRIVE: '', HOMEPATH: home, ...extra };
|
|
39
|
+
}
|
|
24
40
|
const BUILDER = path.join(REPO_ROOT, 'scripts', 'gds', 'build-cli-package.js');
|
|
25
41
|
// Running npm from a test (task 1003529). On Windows `npm` is a .cmd shim: bare
|
|
26
42
|
// 'npm' is ENOENT and 'npm.cmd' is EINVAL under Node 24's CVE-2024-27980 mitigation,
|
|
@@ -152,7 +168,7 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
|
|
|
152
168
|
// file on this machine cannot make a verb look healthier than it is.
|
|
153
169
|
fs.writeFileSync(path.join(site, 'package.json'), JSON.stringify({ name: 'probe', private: true }));
|
|
154
170
|
const installed = npm(['install', '--no-audit', '--no-fund', '--silent', path.join(site, tarball)], {
|
|
155
|
-
cwd: site, encoding: 'utf8', env:
|
|
171
|
+
cwd: site, encoding: 'utf8', env: homelessEnv(home),
|
|
156
172
|
});
|
|
157
173
|
assert.equal(installed.status, 0, `npm install failed:\n${installed.stderr}`);
|
|
158
174
|
|
|
@@ -166,7 +182,7 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
|
|
|
166
182
|
|
|
167
183
|
const runVerb = (args) => spawnSync(process.execPath, [bongos, ...args], {
|
|
168
184
|
cwd: site, encoding: 'utf8', input: '',
|
|
169
|
-
env:
|
|
185
|
+
env: homelessEnv(home, { CLOUDBONGOS_API_BASE: 'http://127.0.0.1:45999', NO_COLOR: '1' }),
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
// help and version must work with nothing configured at all.
|
|
@@ -271,11 +271,16 @@ test('sessions: every id on a row is a door, the earned figure is shown, the tot
|
|
|
271
271
|
assert.match(html, /<div class="stat-row" id="stats-body"><\/div>/);
|
|
272
272
|
const ths = (html.match(/<th[ >]/g) || []).length;
|
|
273
273
|
// task 1002516: a tenth column, "Plain speech" — the transcript accessibility
|
|
274
|
-
// dimension the criterion asks to surface on the dashboard (ADR 0291).
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
274
|
+
// dimension the criterion asks to surface on the dashboard (ADR 0291).
|
|
275
|
+
// task 1003975: an eleventh, "Kind" — the session's derived shape, so the 54%
|
|
276
|
+
// of rows with no task chip are readable at all. The per-cell contracts (a
|
|
277
|
+
// not-scored session is a dash, never a zero; an unclassified one is a dash,
|
|
278
|
+
// never 'Unclear') live in tests/hall_sessions_ledger.mjs and
|
|
279
|
+
// tests/session_type.mjs; this pin is the column count AND that the empty row
|
|
280
|
+
// still spans them — a header added without widening the colspan leaves the
|
|
281
|
+
// empty state short by a column, which no screenshot pass would catch.
|
|
282
|
+
assert.equal(ths, 11, 'eleven columns: session, builder, kind, tasks, earned, turns, tokens, tool errors, duration, plain speech, recorded');
|
|
283
|
+
assert.match(src, new RegExp(`colspan="${ths}"`), 'the empty row spans them all');
|
|
279
284
|
const fam = html.indexOf('/builders/oversight.css');
|
|
280
285
|
assert.ok(fam > 0 && html.indexOf('/builders/sessions.css') > fam, 'sessions.html links the family sheet before its own');
|
|
281
286
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// tests/home_isolation_guard.mjs — setting HOME for a child isolates nothing on
|
|
2
|
+
// Windows (task 1003763).
|
|
3
|
+
//
|
|
4
|
+
// os.homedir() reads USERPROFILE on win32 and never consults HOME, so
|
|
5
|
+
// `env: { ...process.env, HOME: tmp }` leaves the child looking at the developer's
|
|
6
|
+
// real home — and their real CLI session. Three tasks fixed this one site at a time:
|
|
7
|
+
// 1003760 (cli_sessions, which DESTROYED a live session), 1003941 (cli_surface) and
|
|
8
|
+
// this one (cli_package). The last two are the dangerous shape — a test that isolates
|
|
9
|
+
// nothing does not announce itself, it quietly stops testing what its comment claims.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/home_isolation_guard.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { checkHomeIsolation, offendingLines, stripComments, ALLOW } =
|
|
22
|
+
require('../scripts/gds/home-isolation-guard.js');
|
|
23
|
+
|
|
24
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
|
|
26
|
+
test('the live tree is clean, and the scan is not empty', () => {
|
|
27
|
+
const r = checkHomeIsolation();
|
|
28
|
+
assert.deepEqual(r.violations, [], r.violations.join('\n'));
|
|
29
|
+
// A guard that scans nothing passes for free. The note carries the counts so a
|
|
30
|
+
// silently-broken scan is visible rather than reassuring.
|
|
31
|
+
const scanned = Number(/^(\d+) test file/.exec(r.note)?.[1] ?? 0);
|
|
32
|
+
const sites = Number(/(\d+) of them build a child env/.exec(r.note)?.[1] ?? 0);
|
|
33
|
+
assert.ok(scanned > 100, `expected the whole tests/ tree, scanned ${scanned}`);
|
|
34
|
+
assert.ok(sites > 0, 'no file builds a child env with HOME — the pattern has drifted and this guard is inert');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the original broken shape IS caught', () => {
|
|
38
|
+
const src = 'const r = spawnSync(node, [x], { env: { ...process.env, HOME: home } });';
|
|
39
|
+
assert.equal(offendingLines(src).length, 1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the fixed shape is not', () => {
|
|
43
|
+
const src = 'const r = spawnSync(node, [x], { env: { ...process.env, HOME: home, USERPROFILE: home } });';
|
|
44
|
+
assert.deepEqual(offendingLines(src), []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a HELPER CALL SITE is not a child env', () => {
|
|
48
|
+
// tests/setup_smoke.mjs passes { HOME: fakeHome } to its own runSetup(), which adds
|
|
49
|
+
// USERPROFILE itself. Flagging the callers would point at the wrong line and, worse,
|
|
50
|
+
// train someone to "fix" three call sites instead of the one helper.
|
|
51
|
+
assert.deepEqual(offendingLines("runSetup(['--dry-run'], { HOME: fakeHome });"), []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a COMMENT quoting the bug is not the bug', () => {
|
|
55
|
+
// Every file that fixed this now explains it by quoting the broken line. A raw text
|
|
56
|
+
// scan therefore reports exactly the files that already fixed it — which is how the
|
|
57
|
+
// first run of this guard produced three false positives.
|
|
58
|
+
assert.deepEqual(offendingLines('// env: { ...process.env, HOME: home } isolated nothing'), []);
|
|
59
|
+
assert.deepEqual(offendingLines('/* env: { ...process.env, HOME: home } */'), []);
|
|
60
|
+
assert.ok(!stripComments('// { ...process.env, HOME: x }').includes('HOME'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a multi-line literal is still read', () => {
|
|
64
|
+
const src = [
|
|
65
|
+
'const r = spawnSync(node, [x], {',
|
|
66
|
+
' env: {',
|
|
67
|
+
' ...process.env,',
|
|
68
|
+
' HOME: home,',
|
|
69
|
+
' },',
|
|
70
|
+
'});',
|
|
71
|
+
].join('\n');
|
|
72
|
+
assert.equal(offendingLines(src).length, 1, 'the literal wraps — a single-line matcher misses the real shape');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every allowlisted file exists and still means HOME differently', () => {
|
|
76
|
+
// An allowlist entry is a claim. If the file is gone, the claim is stale and the
|
|
77
|
+
// entry is a hole nobody is watching.
|
|
78
|
+
for (const [name, why] of ALLOW) {
|
|
79
|
+
const p = path.join(REPO_ROOT, 'tests', name);
|
|
80
|
+
let src;
|
|
81
|
+
try { src = readFileSync(p, 'utf8'); } catch {
|
|
82
|
+
assert.fail(`allowlisted tests/${name} no longer exists — remove the entry rather than leaving an unwatched hole`);
|
|
83
|
+
}
|
|
84
|
+
assert.ok(why && why.length > 20, `tests/${name} needs a reason, not just an exemption`);
|
|
85
|
+
assert.match(src, /HOME/, `tests/${name} no longer mentions HOME, so its exemption is dead`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the two sites this task fixed are actually fixed', () => {
|
|
90
|
+
const src = readFileSync(path.join(REPO_ROOT, 'tests', 'cli_package.mjs'), 'utf8');
|
|
91
|
+
assert.deepEqual(offendingLines(src), []);
|
|
92
|
+
assert.match(src, /USERPROFILE/, 'cli_package must set USERPROFILE alongside HOME');
|
|
93
|
+
const surface = readFileSync(path.join(REPO_ROOT, 'tests', 'cli_surface.mjs'), 'utf8');
|
|
94
|
+
assert.deepEqual(offendingLines(surface), []);
|
|
95
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// tests/session_type.mjs — the session-type classifier and its filter
|
|
2
|
+
// (task 1003975, from idea 1001033, goal 1000093).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS FILE PINS, in order of what would actually go wrong:
|
|
5
|
+
// (1) the measured shapes. Each fixture is a real session's counters, taken
|
|
6
|
+
// from the 247-row sample the classifier's header records, so a retuning
|
|
7
|
+
// that looks harmless in the abstract has to face the corpus it was
|
|
8
|
+
// derived from.
|
|
9
|
+
// (2) that the ONE-SHOT boundary is the bimodal gap it was measured to be, and
|
|
10
|
+
// that it is computed as prompts (user turns MINUS tool results) — reading
|
|
11
|
+
// user_turns raw would call every subagent an interactive session, which
|
|
12
|
+
// is exactly the confusion the column exists to end.
|
|
13
|
+
// (3) that task_ids is never consulted. 82 of the sample's 179 automated rows
|
|
14
|
+
// carry a task id (a grader worker inherits the task it ran inside), so a
|
|
15
|
+
// classifier that reached for it would label them build sessions.
|
|
16
|
+
// (4) that a write tool wins over everything, and that Bash is NOT one — Bash
|
|
17
|
+
// is 75% of all tool calls in the corpus, so counting it as a write would
|
|
18
|
+
// collapse the vocabulary to a single value.
|
|
19
|
+
// (5) that the route's filter is validated against the classifier's own list,
|
|
20
|
+
// and that the filter reaches the tally as well as the page — a filter on
|
|
21
|
+
// only one of the two is how a table of 57 rows ends up under a headline
|
|
22
|
+
// claiming 4,595.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/session_type.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const st = require('../modules/sessions/session-type.js');
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Fixtures. Every one is a real sampled session reduced to what the classifier
|
|
36
|
+
// reads; the ids in the comments are session_records ids from the 2026-09-17
|
|
37
|
+
// sample so a later reader can go and look at the row itself.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// id 1007108 — a builder-sequence run: 70 turns, 42 tool calls, files written.
|
|
41
|
+
const BUILD = {
|
|
42
|
+
metrics: { assistant_turns: 70, user_turns: 43, tool_result_count: 42, tool_use_count: 42 },
|
|
43
|
+
detail: [{ role: 'assistant', tools: ['Skill'] }, { role: 'assistant', tools: ['Bash', 'Write'] }],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// id 1007121 — a grader worker: one prompt in, one JSON verdict out, no tools.
|
|
47
|
+
const GRADER = {
|
|
48
|
+
metrics: { assistant_turns: 2, user_turns: 1, tool_result_count: 0, tool_use_count: 0 },
|
|
49
|
+
detail: [{ role: 'user' }, { role: 'assistant', snippet: '{"security_score": 6}' }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// id 1007117 — a panel subagent reading the codebase: 12 reads, but every user
|
|
53
|
+
// turn is one of those reads coming back. One prompt started it.
|
|
54
|
+
const PANEL = {
|
|
55
|
+
metrics: { assistant_turns: 23, user_turns: 13, tool_result_count: 12, tool_use_count: 12 },
|
|
56
|
+
detail: Array.from({ length: 12 }, () => ({ role: 'assistant', tools: ['Read'] })),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// id 1007109 — a person steering: 11 user turns of which 8 are tool results, so
|
|
60
|
+
// three real prompts, and nothing was written.
|
|
61
|
+
const EXPLORING = {
|
|
62
|
+
metrics: { assistant_turns: 13, user_turns: 11, tool_result_count: 8, tool_use_count: 8 },
|
|
63
|
+
detail: [{ role: 'assistant', tools: ['Bash'] }],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// No sampled row had this shape (0 of 247) — a person asking, with no tools at
|
|
67
|
+
// all. It is here because the bucket must work the day it first happens.
|
|
68
|
+
const TALKING = {
|
|
69
|
+
metrics: { assistant_turns: 9, user_turns: 5, tool_result_count: 0, tool_use_count: 0 },
|
|
70
|
+
detail: [{ role: 'user' }, { role: 'assistant' }, { role: 'user' }],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// (1) The measured shapes.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test('each measured session shape classifies as itself', () => {
|
|
78
|
+
assert.equal(st.classifySession(BUILD), 'build');
|
|
79
|
+
assert.equal(st.classifySession(GRADER), 'automated');
|
|
80
|
+
assert.equal(st.classifySession(PANEL), 'automated');
|
|
81
|
+
assert.equal(st.classifySession(EXPLORING), 'exploration');
|
|
82
|
+
assert.equal(st.classifySession(TALKING), 'conversation');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('every verdict is in the closed set', () => {
|
|
86
|
+
for (const fixture of [BUILD, GRADER, PANEL, EXPLORING, TALKING, {}, { metrics: {} }]) {
|
|
87
|
+
assert.ok(
|
|
88
|
+
st.SESSION_TYPES.includes(st.classifySession(fixture)),
|
|
89
|
+
`classify returned a type outside SESSION_TYPES for ${JSON.stringify(fixture).slice(0, 60)}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// (2) The one-shot boundary, and that it is PROMPTS and not user turns.
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
test('prompts are user turns minus tool results, floored at zero', () => {
|
|
99
|
+
assert.equal(st.humanPrompts({ user_turns: 13, tool_result_count: 12 }), 1);
|
|
100
|
+
assert.equal(st.humanPrompts({ user_turns: 11, tool_result_count: 8 }), 3);
|
|
101
|
+
// A disagreeing digest (an old row, a truncated transcript) must not produce a
|
|
102
|
+
// negative, which would sail under the one-shot boundary as if measured.
|
|
103
|
+
assert.equal(st.humanPrompts({ user_turns: 2, tool_result_count: 40 }), 0);
|
|
104
|
+
assert.equal(st.humanPrompts({}), 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the boundary sits between one prompt and two — the measured bimodal gap', () => {
|
|
108
|
+
assert.equal(st.ONE_SHOT_PROMPT_MAX, 1);
|
|
109
|
+
const at = { metrics: { assistant_turns: 20, user_turns: 11, tool_result_count: 10, tool_use_count: 10 }, detail: [{ role: 'assistant', tools: ['Read'] }] };
|
|
110
|
+
const over = { metrics: { assistant_turns: 20, user_turns: 12, tool_result_count: 10, tool_use_count: 10 }, detail: [{ role: 'assistant', tools: ['Read'] }] };
|
|
111
|
+
assert.equal(st.classifySession(at), 'automated', 'one prompt is an invocation');
|
|
112
|
+
assert.equal(st.classifySession(over), 'exploration', 'two prompts is someone steering');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('reading user_turns raw would misread the panel subagent — the whole point', () => {
|
|
116
|
+
// 13 user turns looks like a long conversation; 12 of them are tool results.
|
|
117
|
+
assert.ok(PANEL.metrics.user_turns > 2, 'fixture still has the misleading raw count');
|
|
118
|
+
assert.equal(st.classifySession(PANEL), 'automated');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// (3) task_ids is never consulted.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test('a task id cannot change the verdict', () => {
|
|
126
|
+
// The grader worker ran inside a ship and inherited its task. 82 of the
|
|
127
|
+
// sample's 179 automated rows look exactly like this.
|
|
128
|
+
const withTask = { ...GRADER, task_ids: [1003975, 1003841] };
|
|
129
|
+
assert.equal(st.classifySession(withTask), 'automated');
|
|
130
|
+
const buildWithout = { ...BUILD, task_ids: [] };
|
|
131
|
+
assert.equal(st.classifySession(buildWithout), 'build');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// (4) Write tools win; Bash is not one.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
test('a write tool outranks every other signal', () => {
|
|
139
|
+
// One prompt, one edit: an invocation that nonetheless changed the repo.
|
|
140
|
+
const oneShotEdit = {
|
|
141
|
+
metrics: { assistant_turns: 4, user_turns: 2, tool_result_count: 2, tool_use_count: 2 },
|
|
142
|
+
detail: [{ role: 'assistant', tools: ['Read'] }, { role: 'assistant', tools: ['Edit'] }],
|
|
143
|
+
};
|
|
144
|
+
assert.equal(st.classifySession(oneShotEdit), 'build');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('Bash alone is not a build — it reads at least as often as it writes', () => {
|
|
148
|
+
assert.ok(!st.WRITE_TOOLS.has('Bash'));
|
|
149
|
+
const bashOnly = {
|
|
150
|
+
metrics: { assistant_turns: 21, user_turns: 13, tool_result_count: 11, tool_use_count: 11 },
|
|
151
|
+
detail: [{ role: 'assistant', tools: ['Bash'] }],
|
|
152
|
+
};
|
|
153
|
+
assert.equal(st.classifySession(bashOnly), 'exploration');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('every editor the harness offers counts as a write', () => {
|
|
157
|
+
for (const tool of ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']) {
|
|
158
|
+
const row = { metrics: { assistant_turns: 2, user_turns: 1, tool_result_count: 1, tool_use_count: 1 }, detail: [{ role: 'assistant', tools: [tool] }] };
|
|
159
|
+
assert.equal(st.classifySession(row), 'build', `${tool} should count as a write`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a malformed detail never throws', () => {
|
|
164
|
+
assert.equal(st.usedWriteTool(null), false);
|
|
165
|
+
assert.equal(st.usedWriteTool('Edit'), false);
|
|
166
|
+
assert.equal(st.usedWriteTool([null, { tools: null }, { tools: [null, 7] }]), false);
|
|
167
|
+
assert.equal(st.usedWriteTool([{ tools: ['Edit'] }]), true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// (5) The vocabulary guard + the shared filter clause.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
test('isSessionType accepts exactly the closed set', () => {
|
|
175
|
+
for (const t of st.SESSION_TYPES) assert.ok(st.isSessionType(t), `${t} should be valid`);
|
|
176
|
+
for (const bad of ['Build', 'build ', 'ops', '', null, undefined, 7, {}]) {
|
|
177
|
+
assert.ok(!st.isSessionType(bad), `${JSON.stringify(bad)} should be rejected`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('an empty digest is unknown, not a quiet conversation', () => {
|
|
182
|
+
assert.equal(st.classifySession({}), 'unknown');
|
|
183
|
+
assert.equal(st.classifySession({ detail: [], metrics: {} }), 'unknown');
|
|
184
|
+
assert.equal(st.classifySession(), 'unknown');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the type filter reaches the page query AND the tally, through one clause', async () => {
|
|
188
|
+
// The clause is shared by searchSessions and summarizeSessions precisely so the
|
|
189
|
+
// two cannot drift; this captures the SQL both emit and proves the predicate and
|
|
190
|
+
// its bound value are in each.
|
|
191
|
+
const emitted = [];
|
|
192
|
+
const pool = { query: async (sql, params) => { emitted.push({ sql, params }); return { rows: [{}] }; } };
|
|
193
|
+
const moduleApi = require('../src/module-api.js');
|
|
194
|
+
const realPool = moduleApi.pool;
|
|
195
|
+
Object.defineProperty(moduleApi, 'pool', { value: pool, configurable: true, writable: true });
|
|
196
|
+
delete require.cache[require.resolve('../modules/sessions/db.js')];
|
|
197
|
+
const db = require('../modules/sessions/db.js');
|
|
198
|
+
try {
|
|
199
|
+
await db.searchSessions({ sessionType: 'build' });
|
|
200
|
+
await db.summarizeSessions({ sessionType: 'build' });
|
|
201
|
+
assert.equal(emitted.length, 2);
|
|
202
|
+
for (const { sql, params } of emitted) {
|
|
203
|
+
assert.ok(/sr\.session_type = \$8/.test(sql), 'the type predicate is missing from one of the two queries');
|
|
204
|
+
assert.equal(params[7], 'build', 'the type is not bound as $8');
|
|
205
|
+
}
|
|
206
|
+
// Absent filter binds NULL, which the clause reads as "every kind" — not as a
|
|
207
|
+
// literal that would match no row.
|
|
208
|
+
emitted.length = 0;
|
|
209
|
+
await db.searchSessions({});
|
|
210
|
+
assert.equal(emitted[0].params[7], null);
|
|
211
|
+
} finally {
|
|
212
|
+
Object.defineProperty(moduleApi, 'pool', { value: realPool, configurable: true, writable: true });
|
|
213
|
+
delete require.cache[require.resolve('../modules/sessions/db.js')];
|
|
214
|
+
}
|
|
215
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// tests/ship_regen_copy_inventory.mjs — the ship path keeps the copy registry fresh
|
|
2
|
+
// (task 1003556).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. docs/copy-registry.json carries a LINE NUMBER per row, so editing
|
|
5
|
+
// any scanned UI file (modules/hall-ui/public, modules/status-ui/public,
|
|
6
|
+
// modules/public-landing/public) shifts them and stales the artifact. Nothing in the
|
|
7
|
+
// ship path regenerated it — grep for copy-inventory across ship.js, ship-regen.js
|
|
8
|
+
// and ship-preflight-steps.js used to return zero hits — and fitness did not gate it,
|
|
9
|
+
// so the first signal a builder got was CI failing on the PR's MERGE ref. That is the
|
|
10
|
+
// hardest place to attribute a failure: the branch is green, and what broke is the
|
|
11
|
+
// combination.
|
|
12
|
+
//
|
|
13
|
+
// Task 1003546 fixed the SERVER-side merge heals for the same artifact; those repair a
|
|
14
|
+
// strand after it happens. This is the prevention, and these tests pin the wiring
|
|
15
|
+
// rather than the behaviour, because the failure mode is "nobody calls it".
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/ship_regen_copy_inventory.mjs
|
|
18
|
+
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const read = (rel) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
|
29
|
+
|
|
30
|
+
test('ship-regen exports regenerateCopyInventory beside its siblings', () => {
|
|
31
|
+
const regen = require('../scripts/gds/ship-regen.js');
|
|
32
|
+
assert.equal(typeof regen.regenerateCopyInventory, 'function');
|
|
33
|
+
// The siblings it must sit beside — if one of these disappears the convention moved
|
|
34
|
+
// and this file should move with it, rather than silently guarding nothing.
|
|
35
|
+
for (const sib of ['regenerateFileMap', 'regenerateRepoMap', 'regenerateSessionIndex']) {
|
|
36
|
+
assert.equal(typeof regen[sib], 'function', `${sib} is the convention this follows`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the merge path CALLS it — the whole bug was that nobody did', () => {
|
|
41
|
+
const src = read('scripts/gds/ship-merge.js');
|
|
42
|
+
assert.match(src, /regenerateCopyInventory\(\)/,
|
|
43
|
+
'ship-merge must invoke it, not merely import it');
|
|
44
|
+
// Called on the same beat as the other generated artifacts, not somewhere that runs
|
|
45
|
+
// only on a branch of the flow.
|
|
46
|
+
const idx = src.indexOf('regenerateCopyInventory()');
|
|
47
|
+
const fileMapIdx = src.indexOf('regenerateFileMap()');
|
|
48
|
+
assert.ok(fileMapIdx > 0 && Math.abs(idx - fileMapIdx) < 600,
|
|
49
|
+
'it should run beside the other regenerators, not in a separate branch of the flow');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('fitness gates the pair, so a local run says so before CI does', () => {
|
|
53
|
+
const src = read('scripts/gds/fitness.js');
|
|
54
|
+
assert.match(src, /copy-inventory\.js.*--check|'--check'\]/s);
|
|
55
|
+
assert.ok(src.includes("'copy-inventory.js'"),
|
|
56
|
+
'the freshness check must run copy-inventory --check');
|
|
57
|
+
assert.match(src, /copy-registry\.json \+ docs\/copy-inventory\.md are STALE/,
|
|
58
|
+
'and name both artifacts plus the fix command in the violation');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the registry really is line-sensitive — the premise of all of the above', () => {
|
|
62
|
+
// If rows stopped carrying line numbers, none of this wiring would be needed and
|
|
63
|
+
// this file would be guarding a problem that no longer exists.
|
|
64
|
+
const reg = JSON.parse(read('docs/copy-registry.json'));
|
|
65
|
+
const rows = Array.isArray(reg) ? reg : (reg.entries || reg.rows || Object.values(reg).find(Array.isArray));
|
|
66
|
+
assert.ok(Array.isArray(rows) && rows.length > 0, 'the registry must have rows to be stale-able');
|
|
67
|
+
assert.ok(rows.some((r) => r && typeof r === 'object' && 'line' in r),
|
|
68
|
+
'rows carry a `line`, which is why any UI edit stales this artifact');
|
|
69
|
+
});
|