@bongos/core 1.19.818 → 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.
Files changed (39) hide show
  1. package/.bongos-core.json +85 -35
  2. package/.claude/skills/strand-fix/SKILL.md +11 -2
  3. package/.gitattributes +9 -0
  4. package/clients/bongos-client/index.d.ts +1 -1
  5. package/docs/api/openapi.json +4 -2
  6. package/docs/copy-inventory.md +23 -18
  7. package/docs/copy-registry.json +76 -31
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/recipes/gds-db-backup.md +46 -0
  10. package/migrations/core_246_session_type.sql +49 -0
  11. package/modules/discord/bug-attachments.js +36 -1
  12. package/modules/hall-ui/public/sessions.css +19 -0
  13. package/modules/hall-ui/public/sessions.html +16 -1
  14. package/modules/hall-ui/public/sessions.js +135 -6
  15. package/modules/sessions/db.js +92 -6
  16. package/modules/sessions/routes/sessions.js +18 -1
  17. package/modules/sessions/session-type.js +132 -0
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/backfill-session-types.js +87 -0
  21. package/scripts/gds/bug-attachment-audit.js +143 -0
  22. package/scripts/gds/capture.js +19 -0
  23. package/scripts/gds/db-backup-nightly.sh +37 -1
  24. package/scripts/gds/fitness.js +12 -1
  25. package/scripts/gds/home-isolation-guard.js +126 -0
  26. package/scripts/gds/ship-merge.js +5 -1
  27. package/scripts/gds/ship-regen.js +29 -0
  28. package/scripts/gds/skill-preflight.js +28 -4
  29. package/src/module-api.js +1 -1
  30. package/tests/bug_attachment_audit.mjs +122 -0
  31. package/tests/cli_package.mjs +18 -2
  32. package/tests/cli_surface.mjs +31 -2
  33. package/tests/hall_roster_world.mjs +10 -5
  34. package/tests/home_isolation_guard.mjs +95 -0
  35. package/tests/module_api_lazy.mjs +32 -1
  36. package/tests/routine_capture_kind.mjs +147 -0
  37. package/tests/session_type.mjs +215 -0
  38. package/tests/ship_regen_copy_inventory.mjs +69 -0
  39. package/tests/skill_preflight.mjs +45 -0
@@ -0,0 +1,122 @@
1
+ // tests/bug_attachment_audit.mjs — the described-but-missing probe (task 1003918).
2
+ //
3
+ // WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
4
+ // ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
5
+ // two months. They cannot be re-fetched: the Discord CDN URL they were copied from
6
+ // is signed and expires in about a day, so our copy IS the image. The probe is what
7
+ // should have said so in July.
8
+ //
9
+ // A probe like this fails by finding nothing, and a clean bill of health looks
10
+ // identical to working — which is exactly what happened while building it: the
11
+ // first cut queried GET /tasks without naming a status, the route omits abandoned
12
+ // rows by default, and every task describing these files is now abandoned or
13
+ // shipped. It printed "0 linked, 0 missing". So the cases below lean on the ways
14
+ // the extraction can go quietly empty.
15
+ //
16
+ // Run: node tests/bug_attachment_audit.mjs
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { describedAttachments, auditRows } = require('../scripts/gds/bug-attachment-audit.js');
24
+ const { attachmentsDir } = require('../modules/discord/bug-attachments.js');
25
+ const path = require('node:path');
26
+
27
+ // The real link shape, taken verbatim from task 1003718's description.
28
+ const REAL = '/api/gds/bug-attachments/bug-1523688670492627115-8bb16de1f5b4c20e.png';
29
+
30
+ test('extracts the attachment name from a real embedded link', () => {
31
+ const got = describedAttachments(`![screenshot](https://cloudbongos.com${REAL})`);
32
+ assert.deepEqual(got, ['bug-1523688670492627115-8bb16de1f5b4c20e.png']);
33
+ });
34
+
35
+ test('the 19-digit Discord message id is not too long for the name pattern', () => {
36
+ // The id in every real filename here is 19 characters. A pattern sized for
37
+ // something shorter would match nothing and report a clean board.
38
+ assert.equal(describedAttachments(REAL).length, 1);
39
+ });
40
+
41
+ test('both /gds/ and /bongos/ spellings of the route are recognised', () => {
42
+ const a = describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.png');
43
+ const b = describedAttachments('/api/bongos/bug-attachments/bug-1-0123456789abcdef.png');
44
+ assert.deepEqual(a, b, 'the route was renamed; a description can carry either');
45
+ assert.equal(a.length, 1);
46
+ });
47
+
48
+ test('the same file linked twice in one description is counted once', () => {
49
+ const text = `![a](${REAL}) and again ![a](${REAL})`;
50
+ assert.equal(describedAttachments(text).length, 1);
51
+ });
52
+
53
+ test('a description with no attachment yields nothing — and that is not an error', () => {
54
+ assert.deepEqual(describedAttachments('a bug about the hall sidebar'), []);
55
+ assert.deepEqual(describedAttachments(null), []);
56
+ assert.deepEqual(describedAttachments(undefined), []);
57
+ });
58
+
59
+ test('a malformed name is NOT extracted — the pattern is the traversal defence too', () => {
60
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/../../etc/passwd'), []);
61
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-short.png'), []);
62
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.exe'), []);
63
+ });
64
+
65
+ test('auditRows names the TASK for each missing file, not just the file', () => {
66
+ const rows = [
67
+ { id: '1003718', title: 'a re-filed bug', description: `![x](${REAL})` },
68
+ { id: '1003719', title: 'no images here', description: 'plain prose' },
69
+ ];
70
+ const r = auditRows(rows, () => false);
71
+ assert.equal(r.described.length, 1);
72
+ assert.equal(r.missing.length, 1);
73
+ assert.equal(r.missing[0].taskId, '1003718', '"a file is gone" is not actionable; "the image in task N is gone" is');
74
+ assert.equal(r.present, 0);
75
+ });
76
+
77
+ test('a present file is not reported missing', () => {
78
+ const rows = [{ id: '1', title: 't', description: `![x](${REAL})` }];
79
+ const r = auditRows(rows, () => true);
80
+ assert.deepEqual(r.missing, []);
81
+ assert.equal(r.present, 1);
82
+ });
83
+
84
+ test('the same file described by two tasks is reported for BOTH', () => {
85
+ // task 1002076 was re-filed as 1003718 and both descriptions carry the links;
86
+ // each renders a broken image, so each is worth naming.
87
+ const rows = [
88
+ { id: '1002076', title: 'original', description: `![x](${REAL})` },
89
+ { id: '1003718', title: 're-file', description: `![x](${REAL})` },
90
+ ];
91
+ const r = auditRows(rows, () => false);
92
+ assert.equal(r.missing.length, 2);
93
+ });
94
+
95
+ test('the store resolves against the INSTANCE root, not the core package (task 1003918)', () => {
96
+ // The old default was __dirname/../../var, which on a vendored core is
97
+ // node_modules/@bongos/core/var — wiped by every reinstall, and these files
98
+ // cannot be regenerated.
99
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
100
+ delete process.env.BUG_ATTACHMENTS_DIR;
101
+ try {
102
+ const dir = attachmentsDir();
103
+ assert.ok(!dir.includes('node_modules'), `the store must not live inside an installed package: ${dir}`);
104
+ assert.equal(path.basename(dir), 'bug-attachments');
105
+ assert.equal(path.basename(path.dirname(dir)), 'var');
106
+ } finally {
107
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
108
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
109
+ }
110
+ });
111
+
112
+ test('BUG_ATTACHMENTS_DIR still wins outright', () => {
113
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
114
+ process.env.BUG_ATTACHMENTS_DIR = '/srv/persistent/bug-attachments';
115
+ try {
116
+ assert.equal(attachmentsDir(), '/srv/persistent/bug-attachments',
117
+ 'on a box whose instance root is impermanent, the override is the answer');
118
+ } finally {
119
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
120
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
121
+ }
122
+ });
@@ -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: { ...process.env, HOME: home },
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: { ...process.env, HOME: home, CLOUDBONGOS_API_BASE: 'http://127.0.0.1:45999', NO_COLOR: '1' },
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.
@@ -169,18 +169,47 @@ function runGuidance(fn, env) {
169
169
  const code =
170
170
  `const l=require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'))});` +
171
171
  `Promise.resolve(l[${JSON.stringify(fn)}]()).catch(()=>{});`;
172
+ // HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003941). cli-lib resolves the
173
+ // session through os.homedir(), which on win32 reads USERPROFILE and ignores
174
+ // HOME entirely — so the child found the DEVELOPER'S REAL session, requireSession
175
+ // succeeded, and the guidance these tests exist to read was never printed. That
176
+ // failed on any Windows box whose builder happened to be signed in, and went
177
+ // green again once their CLI session expired, which made it read as a flake.
178
+ //
179
+ // HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go.
180
+ const HOMELESS = path.join(REPO_ROOT, 'no-such-home-for-tests');
172
181
  const r = spawnSync(process.execPath, ['-e', code], {
173
182
  encoding: 'utf8',
174
- env: { ...process.env, ...env, HOME: path.join(REPO_ROOT, 'no-such-home-for-tests') },
183
+ env: {
184
+ ...process.env,
185
+ ...env,
186
+ HOME: HOMELESS,
187
+ USERPROFILE: HOMELESS,
188
+ HOMEDRIVE: '',
189
+ HOMEPATH: HOMELESS,
190
+ },
175
191
  });
176
192
  return `${r.stdout || ''}${r.stderr || ''}`;
177
193
  }
178
194
 
195
+ // The isolation above either worked or it did not, and the difference is visible:
196
+ // a child with no session PRINTS guidance, and a child that found one prints
197
+ // nothing. Every assertion below that looks for something ABSENT is satisfied for
198
+ // free by that silence, so the silence is checked for first (task 1003941).
199
+ function guidanceOutput(fn, env) {
200
+ const out = runGuidance(fn, env);
201
+ assert.ok(out.trim().length > 0,
202
+ `${fn} printed NOTHING. That means the child found a real CLI session instead of `
203
+ + 'the blanked home, so this test is reading an unisolated environment and every '
204
+ + '"must not contain" assertion here is vacuous (task 1003941).');
205
+ return out;
206
+ }
207
+
179
208
  const GUIDANCE = ['printUnauthGuidanceAndExit', 'printBoxScopeGuidanceAndExit', 'requireSession'];
180
209
 
181
210
  test('no guidance path speaks slash commands to a terminal reader', () => {
182
211
  for (const fn of GUIDANCE) {
183
- const out = runGuidance(fn, { BONGOS_SURFACE: 'terminal' });
212
+ const out = guidanceOutput(fn, { BONGOS_SURFACE: 'terminal' });
184
213
  const leaks = [...out.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
185
214
  assert.deepEqual(leaks, [], `${fn} leaked to a terminal reader: ${leaks.join(', ')}\n${out}`);
186
215
  assert.ok(!/node scripts\/gds\//.test(out), `${fn} told a terminal reader to run a repo script:\n${out}`);
@@ -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). The
275
- // per-cell contract (a not-scored session is a dash, never a zero) lives in
276
- // tests/hall_sessions_ledger.mjs; this pin is only the column count.
277
- assert.equal(ths, 10, 'ten columns: session, builder, tasks, earned, turns, tokens, tool errors, duration, plain speech, recorded');
278
- assert.match(src, /colspan="10"/, 'the empty row spans them all');
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
+ });
@@ -35,8 +35,13 @@ function loadedAfter(spec, read = null) {
35
35
  const ROOT = ${JSON.stringify(ROOT)};
36
36
  const m = require(path.join(ROOT, ${JSON.stringify(spec)}));
37
37
  ${read ? `void m[${JSON.stringify(read)}];` : ''}
38
+ // POSIX-normalised before filtering (task 1003941). path.relative returns
39
+ // backslashes on win32, so 'src/bongos/' matched NOTHING and every list came
40
+ // back empty — which made the two laziness assertions below pass VACUOUSLY on
41
+ // Windows: they assert that a filtered-empty list is empty. An eager require
42
+ // reintroduced at the doorway would have shipped green.
38
43
  const files = Object.keys(require.cache)
39
- .map((f) => path.relative(ROOT, f))
44
+ .map((f) => path.relative(ROOT, f).split(path.sep).join('/'))
40
45
  .filter((f) => f.startsWith('src/bongos/'));
41
46
  process.stdout.write(JSON.stringify(files));
42
47
  `;
@@ -50,6 +55,32 @@ function loadedAfter(spec, read = null) {
50
55
  return JSON.parse(out);
51
56
  }
52
57
 
58
+ // CALIBRATION — can the instrument see anything at all? (task 1003941)
59
+ //
60
+ // Every other assertion in this file has the shape "nothing heavy is in this
61
+ // list", which an EMPTY list satisfies for free. So an empty list is not evidence
62
+ // of laziness; it is evidence of nothing, and the two tests that guard the ADR
63
+ // 0083 doorway invariant cannot tell the difference.
64
+ //
65
+ // That is not hypothetical. loadedAfter filtered on a 'src/bongos/' prefix after
66
+ // path.relative, which returns BACKSLASHES on win32 — so on Windows the filter
67
+ // matched nothing, every list was empty, and both doorway tests passed vacuously.
68
+ // An eager require reintroduced at the doorway would have shipped green.
69
+ //
70
+ // The fix normalises separators, but a normalisation can break again silently, and
71
+ // asserting the doorway's own list is non-empty would be wrong — loading NOTHING
72
+ // from src/bongos/ is precisely what the doorway is supposed to do. So the probe
73
+ // is calibrated against a module that certainly does load one. If this test fails,
74
+ // every emptiness assertion below is meaningless and should be read as untested.
75
+ test('CALIBRATION: the probe can see a src/bongos/ require at all', () => {
76
+ const files = loadedAfter('src/bongos/pool.js');
77
+ assert.ok(files.includes('src/bongos/pool.js'),
78
+ 'requiring src/bongos/pool.js must show up in the probe\'s own list. It does not, so '
79
+ + 'the list is empty for reasons unrelated to laziness — check the path normalisation '
80
+ + 'in loadedAfter (task 1003941). Until this passes, every "loads nothing" assertion '
81
+ + `in this file is vacuous. Saw: ${JSON.stringify(files.slice(0, 5))}`);
82
+ });
83
+
53
84
  test('importing the doorway loads no server: no pool, no auth, no routes', () => {
54
85
  const files = loadedAfter('src/module-api.js');
55
86
  const heavy = files.filter((f) => HEAVY.test(f));
@@ -0,0 +1,147 @@
1
+ // tests/routine_capture_kind.mjs — a routine that files its findings must name a
2
+ // kind the server accepts (task 1003694).
3
+ //
4
+ // THE FAILURE THIS PINS. .claude/scheduled-tasks/dep-audit-nightly/SKILL.md filed
5
+ // with `--kind security`. That is not one of the kinds — captureIdea throws BAD_KIND
6
+ // and the route 400s — so the capture step of that nightly routine had NEVER filed
7
+ // anything.
8
+ //
9
+ // It is quiet in the worst way. The routine still runs, still does the audit, and
10
+ // only the step that turns a finding into a durable row fails. There is no
11
+ // accumulating queue whose emptiness would look wrong, so nothing ever looked wrong.
12
+ // A scheduled routine cannot notice its own silence, which is exactly why this is a
13
+ // test and not a runtime check.
14
+ //
15
+ // Run: node tests/routine_capture_kind.mjs
16
+
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { VALID_KINDS } = require('../modules/ideas/inbox.js');
26
+
27
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+
29
+ // Every SKILL.md that could carry a capture command: the scheduled routines and the
30
+ // skills. A routine is the dangerous case (nobody reads its output), but a skill with
31
+ // a bad kind wastes a builder's time the same way.
32
+ function skillFiles() {
33
+ const out = [];
34
+ const roots = [
35
+ path.join(REPO_ROOT, '.claude', 'scheduled-tasks'),
36
+ path.join(REPO_ROOT, '.claude', 'skills'),
37
+ ];
38
+ for (const root of roots) {
39
+ if (!existsSync(root)) continue;
40
+ for (const d of readdirSync(root, { withFileTypes: true })) {
41
+ if (!d.isDirectory()) continue;
42
+ const p = path.join(root, d.name, 'SKILL.md');
43
+ if (existsSync(p)) out.push(p);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // `--kind foo` — a LITERAL kind, on a CAPTURE command only.
50
+ //
51
+ // Both narrowings are load-bearing, and I got each wrong first:
52
+ //
53
+ // - CAPTURE ONLY. `--kind` is not unique to capture.js. recall.js takes its own
54
+ // `--kind doc|db`, and a matcher that reads every `--kind` in the tree reports
55
+ // three recall invocations as broken. A guard whose first run is mostly noise
56
+ // is a guard people learn to suppress.
57
+ // - LITERAL ONLY. A placeholder (`--kind <bug|cleanup>`, `--kind cleanup|refactor`)
58
+ // documents a CHOICE rather than passing a value; overnight-code-review and
59
+ // repo-simplify-sweep both use that form deliberately.
60
+ const CAPTURE_LINE_RE = /capture\.js/;
61
+ const KIND_RE = /--kind\s+([a-z][a-z0-9-]*)(?=[\s`'"]|$)/g;
62
+
63
+ // A capture command can wrap across lines with a trailing backslash, so the unit of
64
+ // inspection is the command, not the line: a run of lines joined while the previous
65
+ // one ends in `\`.
66
+ function captureCommands(text) {
67
+ const lines = String(text || '').split('\n');
68
+ const out = [];
69
+ let buf = null;
70
+ for (const line of lines) {
71
+ const continues = /\\\s*$/.test(line);
72
+ if (buf !== null) {
73
+ buf += ' ' + line;
74
+ if (!continues) { out.push(buf); buf = null; }
75
+ } else if (CAPTURE_LINE_RE.test(line)) {
76
+ if (continues) buf = line;
77
+ else out.push(line);
78
+ }
79
+ }
80
+ if (buf !== null) out.push(buf);
81
+ return out;
82
+ }
83
+
84
+ function literalKinds(text) {
85
+ const out = [];
86
+ for (const cmd of captureCommands(text)) {
87
+ for (const m of cmd.matchAll(KIND_RE)) {
88
+ const after = cmd.slice(m.index + m[0].length, m.index + m[0].length + 1);
89
+ if (after === '|') continue; // `--kind cleanup|refactor` — a menu, not a value
90
+ out.push(m[1]);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ test('every literal --kind in a routine or skill is a kind the server accepts', () => {
97
+ const files = skillFiles();
98
+ // The scan must not pass by finding nothing: these files exist, and at least one of
99
+ // them documents a capture command.
100
+ assert.ok(files.length > 0, 'found no SKILL.md files at all — the scan is broken, not clean');
101
+
102
+ const bad = [];
103
+ let seen = 0;
104
+ for (const f of files) {
105
+ for (const kind of literalKinds(readFileSync(f, 'utf8'))) {
106
+ seen += 1;
107
+ if (!VALID_KINDS.includes(kind)) bad.push(`${path.relative(REPO_ROOT, f)}: --kind ${kind}`);
108
+ }
109
+ }
110
+ assert.ok(seen > 0, 'no literal --kind found anywhere — the pattern has drifted, and this test would pass an empty repo');
111
+ assert.deepEqual(bad, [],
112
+ `these file a kind the server rejects with a 400 — for a SCHEDULED routine that means its findings are silently never filed:\n ${bad.join('\n ')}\n`
113
+ + `Valid kinds: ${VALID_KINDS.join(', ')}`);
114
+ });
115
+
116
+ test('the matcher reads a literal kind on a capture command', () => {
117
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind infra --body-file x'), ['infra']);
118
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind security'), ['security'],
119
+ 'the real bug is still caught');
120
+ });
121
+
122
+ test('a DIFFERENT tool\'s --kind is not this guard\'s business', () => {
123
+ // recall.js takes --kind doc|db. Reading every --kind in the tree reported three
124
+ // recall invocations as broken on the first run of this guard.
125
+ assert.deepEqual(literalKinds('node scripts/gds/recall.js nightly backup --kind doc'), []);
126
+ assert.deepEqual(literalKinds('add `--kind doc` for ADRs/recipes'), []);
127
+ });
128
+
129
+ test('a documented menu is not a value', () => {
130
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind <bug|cleanup|refactor>'), []);
131
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind cleanup|refactor \\'), []);
132
+ });
133
+
134
+ test('a capture command wrapped over lines is still read', () => {
135
+ const text = ['node scripts/gds/capture.js "t" \\', ' --kind security \\', ' --body-file /tmp/x'].join('\n');
136
+ assert.deepEqual(literalKinds(text), ['security'],
137
+ 'dep-audit-nightly writes exactly this shape — a line-at-a-time matcher misses it');
138
+ });
139
+
140
+ test('dep-audit-nightly specifically now files with an accepted kind (task 1003694)', () => {
141
+ const p = path.join(REPO_ROOT, '.claude', 'scheduled-tasks', 'dep-audit-nightly', 'SKILL.md');
142
+ const src = readFileSync(p, 'utf8');
143
+ assert.ok(!/--kind\s+security\b/.test(src), 'security is not a kind and never was');
144
+ const kinds = literalKinds(src);
145
+ assert.ok(kinds.length > 0, 'the routine must still file something');
146
+ for (const k of kinds) assert.ok(VALID_KINDS.includes(k), `${k} is not a valid kind`);
147
+ });