@bongos/core 1.24.4 → 1.24.5

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.
@@ -91,7 +91,8 @@ test('GET /me carries the block and resolves the settings slices through the loc
91
91
  assert.match(src, /government\.lawsForSession\(\)/, '/me reads the laws through the government port');
92
92
  assert.match(src, /settings\.describeWithLaws\(behaviorPrefs, \(sessionLaws && sessionLaws\.laws\) \|\| \[\]/, 'the slices are resolved with the laws applied');
93
93
  for (const slice of ['wandering', 'interaction', 'model_allocation']) {
94
- assert.match(src, new RegExp(`${slice}: lawed \\? lawed\\.${slice} :`), `${slice} reads the locked slice`);
94
+ // interaction is wrapped by withStandingRules (task 1005015); the locked slice is still what it wraps.
95
+ assert.match(src, new RegExp(`${slice}: (?:withStandingRules\\()?lawed \\? lawed\\.${slice} :`), `${slice} reads the locked slice`);
95
96
  }
96
97
  assert.match(src, /laws: sessionLaws \? \{/, '/me answers a laws block');
97
98
  assert.equal(typeof settingsPort.describeWithLaws, 'function');
@@ -328,6 +328,17 @@ try {
328
328
  assert.equal(b.el('tw-notice').hidden, true);
329
329
  });
330
330
 
331
+ // task 1004972: a blank round (opened, nothing written) used to read as
332
+ // 'unknown' and the editor told the viewer it was "submitted and on its way".
333
+ await test('a blank round nobody holds offers the take button, never "submitted"', async () => {
334
+ const read = editorRead({ held: false, state: 'ready', draftLines: null });
335
+ assert.equal(read.round.state, 'blank');
336
+ const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(read) } });
337
+ await b.settle();
338
+ assert.ok(b.el('tw-claim'), 'the notice carries the take button');
339
+ assert.doesNotMatch(b.el('tw-notice').innerHTML, /submitted/);
340
+ });
341
+
331
342
  await test('someone else writing it: read-only, and named', async () => {
332
343
  const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead({ holder: '8' })) } });
333
344
  await b.settle();
@@ -30,6 +30,7 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
  // Every test file permitted to skip itself wholesale, with the topology it needs.
31
31
  // Adding a line here is a deliberate, reviewable act — that is the point.
32
32
  const ALLOWED = {
33
+ agent_standing_rules: '.claude/hooks/conductor.js + stop-slopcheck.js (package-excluded)',
33
34
  allow_rule_guard: '.claude/hooks/allow-rule-guard.js (package-excluded)',
34
35
  art_stage: 'scripts/bongos/art-stage.js (art-pipeline tooling, not in the core)',
35
36
  bongos_downloads: 'config/bongos-cli.json (instance content)',
@@ -45,6 +46,7 @@ const ALLOWED = {
45
46
  preview_server: 'src/preview-server.js (game module, not in the core)',
46
47
  recall_hints: '.claude/hooks/recall-hints.js (package-excluded)',
47
48
  rubric_fixtures: 'modules/art-pipeline/template/rubric_fixtures (module not installed)',
49
+ run_node_reroute: '.claude/hooks/run-node.sh (package-excluded)',
48
50
  run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
49
51
  'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
50
52
  session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
@@ -0,0 +1,136 @@
1
+ // tests/run_node_reroute.mjs — the hook resolver runs the SESSION's copy of a hook
2
+ // (.claude/hooks/run-node.sh, task 1005015). Claude Code sets $CLAUDE_PROJECT_DIR to the
3
+ // main checkout even in a worktree session, so every hook ran from there; on a stale main
4
+ // checkout that hid the human-task markers (task 1004922) entirely. Each case builds fake
5
+ // checkouts, SPAWNS the real resolver with the real settings.json argument shape, and
6
+ // lets a probe hook report which file ran and what CLAUDE_PROJECT_DIR it saw.
7
+ //
8
+ // The guard hooks run through the resolver too, so the decoy cases matter as much as
9
+ // the happy path: only a linked git worktree OF THIS PROJECT may have its copy run.
10
+ //
11
+ // .claude/hooks/ is package-excluded, so a clean core checkout skips this file
12
+ // (tests/host_topology_skips.mjs carries the allowlist entry).
13
+ //
14
+ // Run: node tests/run_node_reroute.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { spawnSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { skipUnlessPresent } from './helpers.mjs';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const RESOLVER = path.join(ROOT, '.claude', 'hooks', 'run-node.sh');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ function t(name, fn) {
30
+ try { fn(); passed++; console.log(` PASS ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
31
+ }
32
+
33
+ skipUnlessPresent(['.claude/hooks/run-node.sh']);
34
+
35
+ const PROBE = "process.stdout.write(JSON.stringify({ file: __filename, pd: process.env.CLAUDE_PROJECT_DIR }))\n";
36
+
37
+ // A fake Bongos checkout. `linkTo` makes it a linked worktree of that main checkout the
38
+ // way git does: a .git FILE naming <main>/.git/worktrees/<name>, whose gitdir file points
39
+ // back (git writes forward slashes on every platform). `backTo` forges a wrong back-pointer.
40
+ const fwd = (p) => p.split(path.sep).join('/');
41
+ function checkout(dir, { linkTo = null, backTo = null, core = true, probe = true } = {}) {
42
+ fs.mkdirSync(path.join(dir, '.claude', 'hooks'), { recursive: true });
43
+ fs.copyFileSync(RESOLVER, path.join(dir, '.claude', 'hooks', 'run-node.sh'));
44
+ if (probe) fs.writeFileSync(path.join(dir, '.claude', 'hooks', 'probe.js'), PROBE);
45
+ if (linkTo) {
46
+ const admin = path.join(linkTo, '.git', 'worktrees', path.basename(dir));
47
+ fs.mkdirSync(admin, { recursive: true });
48
+ fs.writeFileSync(path.join(admin, 'gitdir'), `${fwd(path.join(backTo || dir, '.git'))}\n`);
49
+ fs.writeFileSync(path.join(dir, '.git'), `gitdir: ${fwd(admin)}\n`);
50
+ } else {
51
+ fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
52
+ }
53
+ if (core) fs.mkdirSync(path.join(dir, 'src', 'bongos'), { recursive: true });
54
+ return dir;
55
+ }
56
+
57
+ // The argument shape settings.json uses: "$CLAUDE_PROJECT_DIR/.claude/hooks/<hook>.js".
58
+ function run({ main, cwd, env = {} }) {
59
+ const r = spawnSync('sh', [path.join(main, '.claude', 'hooks', 'run-node.sh'), `${main}/.claude/hooks/probe.js`], {
60
+ cwd,
61
+ env: { ...process.env, CLAUDE_PROJECT_DIR: main, OTB_NODE_BIN: process.execPath, OTB_HOOKS_FROM_PROJECT_DIR: '', ...env },
62
+ encoding: 'utf8',
63
+ });
64
+ if (r.error) throw r.error;
65
+ assert.equal(r.status, 0, `resolver exited ${r.status}: ${r.stderr}`);
66
+ return JSON.parse(r.stdout);
67
+ }
68
+
69
+ // Windows paths are case-insensitive, and the resolver hands node a lower-case drive.
70
+ const canon = (p) => (process.platform === 'win32' ? fs.realpathSync.native(p).toLowerCase() : fs.realpathSync(p));
71
+ const same = (a, b) => canon(a) === canon(b);
72
+
73
+ if (spawnSync('sh', ['-c', 'exit 0']).status !== 0) {
74
+ console.log('SKIP run_node_reroute: no `sh` on PATH');
75
+ process.exit(0);
76
+ }
77
+
78
+ // realpath: on Windows os.tmpdir() can be an 8.3 short name (GMK1_P~1), which git never
79
+ // writes and the shell's $PWD never shows, so the fixture's paths would not compare.
80
+ const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'run-node-reroute-')));
81
+ const main = checkout(path.join(tmp, 'main'));
82
+ const MAIN_PROBE = path.join(main, '.claude', 'hooks', 'probe.js');
83
+ const wt = checkout(path.join(main, '.claude', 'worktrees', 'wt'), { linkTo: main });
84
+ fs.mkdirSync(path.join(wt, 'deep', 'er'), { recursive: true });
85
+ const ranMain = (out) => assert.ok(same(out.file, MAIN_PROBE), `ran ${out.file}`);
86
+
87
+ t('a hook fired from inside a worktree runs the worktree\'s copy, with CLAUDE_PROJECT_DIR pointed at it', () => {
88
+ const out = run({ main, cwd: path.join(wt, 'deep', 'er') });
89
+ assert.ok(same(out.file, path.join(wt, '.claude', 'hooks', 'probe.js')), `ran ${out.file}`);
90
+ assert.ok(same(out.pd, wt), `CLAUDE_PROJECT_DIR was ${out.pd}`);
91
+ });
92
+
93
+ t('a linked worktree outside the main folder reroutes too', () => {
94
+ const linked = checkout(path.join(tmp, 'linked'), { linkTo: main });
95
+ const out = run({ main, cwd: linked });
96
+ assert.ok(same(out.file, path.join(linked, '.claude', 'hooks', 'probe.js')), `ran ${out.file}`);
97
+ });
98
+
99
+ t('a hook fired from the main checkout itself runs unchanged', () => {
100
+ const out = run({ main, cwd: main });
101
+ ranMain(out);
102
+ assert.equal(out.pd, main);
103
+ });
104
+
105
+ t('OTB_HOOKS_FROM_PROJECT_DIR=1 turns the reroute off', () => {
106
+ ranMain(run({ main, cwd: wt, env: { OTB_HOOKS_FROM_PROJECT_DIR: '1' } }));
107
+ });
108
+
109
+ t('a worktree that cannot resolve the core (no src/bongos, no node_modules) keeps the main copy', () => {
110
+ ranMain(run({ main, cwd: checkout(path.join(tmp, 'bare'), { linkTo: main, core: false }) }));
111
+ });
112
+
113
+ t('a worktree whose branch lacks the hook keeps the main copy', () => {
114
+ ranMain(run({ main, cwd: checkout(path.join(tmp, 'old'), { linkTo: main, probe: false }) }));
115
+ });
116
+
117
+ t('a folder outside any checkout keeps the main copy', () => {
118
+ ranMain(run({ main, cwd: fs.mkdtempSync(path.join(os.tmpdir(), 'run-node-loose-')) }));
119
+ });
120
+
121
+ t('a look-alike with a .git dir and its own hooks (not a worktree) keeps the main copy', () => {
122
+ ranMain(run({ main, cwd: checkout(path.join(tmp, 'decoy')) }));
123
+ });
124
+
125
+ t('a decoy whose .git file names a gitdir outside the project keeps the main copy', () => {
126
+ const other = checkout(path.join(tmp, 'other-main'));
127
+ ranMain(run({ main, cwd: checkout(path.join(tmp, 'decoy2'), { linkTo: other }) }));
128
+ });
129
+
130
+ t('a decoy pointing into the project whose back-pointer names another folder keeps the main copy', () => {
131
+ ranMain(run({ main, cwd: checkout(path.join(tmp, 'decoy3'), { linkTo: main, backTo: wt }) }));
132
+ });
133
+
134
+ fs.rmSync(tmp, { recursive: true, force: true });
135
+ console.log(`\n${passed} passed, ${failed} failed`);
136
+ process.exitCode = failed ? 1 : 0;