@bongos/core 1.24.3 → 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.
- package/.bongos-core.json +142 -67
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/config/branding.neutral.json +0 -1
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
- package/docs/api/openapi.json +121 -4
- package/docs/api-reference.md +8 -1
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +11 -11
- package/docs/copy-registry.json +12 -12
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-readings.json +1 -1
- package/modules/copy-desk/auto-close.js +46 -8
- package/modules/copy-desk/page-status.js +11 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- package/modules/discord/ship-broadcast.js +7 -0
- package/modules/economy/credit-wording.js +93 -0
- package/modules/economy/credits.js +26 -12
- package/modules/economy/reward-policy.js +49 -3
- package/modules/economy/reward.js +5 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/ideas/routes/inbox.js +6 -0
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/routes/project-settings.js +6 -4
- package/modules/market-watch/ask.js +425 -0
- package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
- package/modules/market-watch/module.json +10 -3
- package/modules/market-watch/pollers/weekly-ask.js +89 -0
- package/modules/market-watch/public/hall-widget.js +271 -0
- package/modules/market-watch/public/weekly-ask.css +39 -0
- package/modules/market-watch/routes/ask.js +95 -0
- package/modules/onboarding/routes/access-requests.js +38 -10
- package/modules/platform-identity/account-logins.js +13 -0
- package/modules/platform-identity/routes/my-projects.js +10 -7
- package/modules/platform-identity/routes/sso.js +11 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +37 -1
- package/scripts/bongos/backfill-subagent-rewards.js +7 -7
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/hall-preview/server.js +13 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-idp.js +17 -5
- package/src/bongos/db-kernel.js +14 -1
- package/src/bongos/module-scope-map.js +4 -2
- package/src/bongos/routes/me.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/account_logins.mjs +6 -4
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/backfill_subagent_rewards.mjs +7 -7
- package/tests/claims_batch_routes.mjs +36 -13
- package/tests/copy_stuck_page_tweaks.mjs +3 -1
- package/tests/credit_grant.mjs +7 -7
- package/tests/credit_wording.mjs +154 -0
- package/tests/google_halls.mjs +211 -0
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/market_watch_ask.mjs +369 -0
- package/tests/market_watch_ask_db.mjs +122 -0
- package/tests/market_watch_data_db.mjs +4 -0
- package/tests/market_watch_module.mjs +9 -5
- package/tests/project_settings_page.mjs +1 -1
- package/tests/reward_cost_scale.mjs +200 -0
- package/tests/run_node_reroute.mjs +136 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/reward_cost_scale.mjs — task 1004968 (goal 1000092, ADR 0387).
|
|
2
|
+
//
|
|
3
|
+
// The reward sizing factor was called `usdPeg`, which read as an exchange rate
|
|
4
|
+
// between a credit and a dollar. It is now `costScale`. This file pins three
|
|
5
|
+
// things the rename must not change:
|
|
6
|
+
//
|
|
7
|
+
// 1. AWARDS ARE BYTE-IDENTICAL. The amounts below are LITERALS computed with
|
|
8
|
+
// the pre-rename formula (round(cost × 1.20 × peg)), not re-derived from the
|
|
9
|
+
// code under test, so a change to the formula reds here. They are checked
|
|
10
|
+
// for the neutral pack and for a host pack that sets ONLY the old key.
|
|
11
|
+
// 2. THE OLD KEY STILL WORKS. A pack carrying only `usdPeg` resolves to the
|
|
12
|
+
// same factor, through the REAL branding read (a child process per pack,
|
|
13
|
+
// because branding() is memoised), with a one-line deprecation note printed
|
|
14
|
+
// once. When both keys are set, `costScale` wins.
|
|
15
|
+
// 3. NO LEDGER ROW IS REWRITTEN. No migration names either key: the rename
|
|
16
|
+
// lives in config and code only, so nothing converts, rescales or rewrites
|
|
17
|
+
// an existing credit_log row.
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { makeRunner } from './helpers.mjs';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const {
|
|
31
|
+
rewardPolicy, resolveCostScale, rewardPolicyDeprecation, USD_PEG_DEPRECATION,
|
|
32
|
+
} = require('../modules/economy/reward-policy.js');
|
|
33
|
+
const { computeSessionTokenReward } = require('../modules/economy/credits.js');
|
|
34
|
+
|
|
35
|
+
const { test, summary } = makeRunner();
|
|
36
|
+
|
|
37
|
+
// [true cost USD, awarded credits at scale 1, awarded credits at scale 2.5].
|
|
38
|
+
// Computed with the PRE-rename formula; literals on purpose.
|
|
39
|
+
const PINNED = [
|
|
40
|
+
[0, 0, 0],
|
|
41
|
+
[0.40, 0, 1],
|
|
42
|
+
[0.42, 1, 1],
|
|
43
|
+
[1.65, 2, 5],
|
|
44
|
+
[7.3333, 9, 22],
|
|
45
|
+
[15, 18, 45],
|
|
46
|
+
[100, 120, 300],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function award(policy, cost) {
|
|
50
|
+
return computeSessionTokenReward(cost, policy.marginPct, policy.costScale);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await test('neutral pack: awards and descriptions are byte-identical to before the rename', () => {
|
|
54
|
+
const neutral = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/branding.neutral.json'), 'utf8'));
|
|
55
|
+
// The neutral pack carries NEITHER key: a host pack deep-merges over it, so a
|
|
56
|
+
// neutral `costScale: 1` would win over a host's `usdPeg` and silently reset
|
|
57
|
+
// that host's awards to scale 1. The default 1 lives in code instead.
|
|
58
|
+
assert.equal(neutral.reward.costScale, undefined, 'neutral must not shadow a host usdPeg');
|
|
59
|
+
assert.equal(neutral.reward.usdPeg, undefined, 'and no longer carries the old key');
|
|
60
|
+
const p = rewardPolicy(neutral.reward);
|
|
61
|
+
for (const [cost, at1] of PINNED) {
|
|
62
|
+
const r = award(p, cost);
|
|
63
|
+
assert.equal(r.drachmae, at1, `$${cost} at the neutral pack`);
|
|
64
|
+
}
|
|
65
|
+
// At scale 1 the description carries no factor at all, so it is unchanged too.
|
|
66
|
+
assert.equal(award(p, 15).description, '$15.00 true cost × 1.20 = 18 drachmae (token reward)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('a pack that sets ONLY usdPeg awards exactly what it did before', () => {
|
|
70
|
+
const p = rewardPolicy({ marginPct: 20, usdPeg: 2.5 });
|
|
71
|
+
assert.equal(p.costScale, 2.5, 'the old key is still read');
|
|
72
|
+
for (const [cost, , at25] of PINNED) {
|
|
73
|
+
assert.equal(award(p, cost).drachmae, at25, `$${cost} at usdPeg 2.5`);
|
|
74
|
+
}
|
|
75
|
+
// A pack using the old key and one using the new key are indistinguishable.
|
|
76
|
+
const q = rewardPolicy({ marginPct: 20, costScale: 2.5 });
|
|
77
|
+
for (const [cost] of PINNED) assert.deepEqual(award(q, cost), award(p, cost));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('the factor is written as a scale, never a peg (ADR 0387)', () => {
|
|
81
|
+
const d = award(rewardPolicy({ usdPeg: 2 }), 15).description;
|
|
82
|
+
assert.equal(d, '$15.00 true cost × 1.20 × 2 scale = 36 drachmae (token reward)');
|
|
83
|
+
assert.doesNotMatch(d, /peg/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('both keys set: costScale wins', () => {
|
|
87
|
+
assert.equal(resolveCostScale({ costScale: 3, usdPeg: 2 }), 3);
|
|
88
|
+
assert.equal(rewardPolicy({ costScale: 3, usdPeg: 2 }).costScale, 3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('an invalid costScale falls back to a working usdPeg, then to 1', () => {
|
|
92
|
+
for (const bad of [0, -1, 'x', null, undefined, NaN, Infinity]) {
|
|
93
|
+
assert.equal(resolveCostScale({ costScale: bad, usdPeg: 2 }), 2, `costScale=${String(bad)} → usdPeg`);
|
|
94
|
+
assert.equal(resolveCostScale({ costScale: bad }), 1, `costScale=${String(bad)} alone → 1`);
|
|
95
|
+
assert.equal(resolveCostScale({ usdPeg: bad }), 1, `usdPeg=${String(bad)} alone → 1 (as before)`);
|
|
96
|
+
}
|
|
97
|
+
assert.equal(resolveCostScale(null), 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('the deprecation note: only for a pack still spelling usdPeg alone', () => {
|
|
101
|
+
assert.equal(rewardPolicyDeprecation({ usdPeg: 1 }), USD_PEG_DEPRECATION);
|
|
102
|
+
assert.equal(rewardPolicyDeprecation({ costScale: 1 }), null);
|
|
103
|
+
assert.equal(rewardPolicyDeprecation({ costScale: 1, usdPeg: 1 }), null, 'costScale wins, so the old key is unused');
|
|
104
|
+
assert.equal(rewardPolicyDeprecation({}), null);
|
|
105
|
+
assert.equal(rewardPolicyDeprecation(null), null);
|
|
106
|
+
assert.equal(USD_PEG_DEPRECATION.split('\n').length, 1, 'one line');
|
|
107
|
+
assert.match(USD_PEG_DEPRECATION, /costScale/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The REAL branding read: rewardConfig() through the module port, with a host
|
|
111
|
+
// pack that deep-merges over neutral. One child per pack, because branding() is
|
|
112
|
+
// memoised for the life of a process.
|
|
113
|
+
function resolveInChild(reward) {
|
|
114
|
+
const file = path.join(os.tmpdir(), `bongos-cost-scale-${process.pid}-${Object.keys(reward).join('-')}.json`);
|
|
115
|
+
fs.writeFileSync(file, JSON.stringify({ reward }));
|
|
116
|
+
const script = `
|
|
117
|
+
const credits = require(${JSON.stringify(path.join(ROOT, 'modules/economy/credits.js'))});
|
|
118
|
+
const a = credits.rewardConfig();
|
|
119
|
+
const b = credits.rewardConfig();
|
|
120
|
+
process.stdout.write('\\nRESULT=' + JSON.stringify({ a, b, award: credits.computeSessionTokenReward(1.65, a.marginPct, a.costScale) }) + '\\n');
|
|
121
|
+
`;
|
|
122
|
+
try {
|
|
123
|
+
const out = execFileSync(process.execPath, ['-e', script], {
|
|
124
|
+
cwd: ROOT,
|
|
125
|
+
env: { ...process.env, BONGOS_BRANDING_FILE: file },
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
});
|
|
129
|
+
// The deprecation note goes through the module logger, which may share
|
|
130
|
+
// stdout, so the result line is marked rather than being the whole output.
|
|
131
|
+
const line = out.split('\n').find((l) => l.startsWith('RESULT='));
|
|
132
|
+
return { result: JSON.parse(line.slice('RESULT='.length)) };
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(file, { force: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stderrOfChild(reward) {
|
|
139
|
+
const file = path.join(os.tmpdir(), `bongos-cost-scale-err-${process.pid}-${Object.keys(reward).join('-')}.json`);
|
|
140
|
+
fs.writeFileSync(file, JSON.stringify({ reward }));
|
|
141
|
+
const errFile = `${file}.err`;
|
|
142
|
+
const script = `
|
|
143
|
+
const credits = require(${JSON.stringify(path.join(ROOT, 'modules/economy/credits.js'))});
|
|
144
|
+
credits.rewardConfig(); credits.rewardConfig(); credits.rewardConfig();
|
|
145
|
+
`;
|
|
146
|
+
const fd = fs.openSync(errFile, 'w');
|
|
147
|
+
try {
|
|
148
|
+
execFileSync(process.execPath, ['-e', script], {
|
|
149
|
+
cwd: ROOT,
|
|
150
|
+
env: { ...process.env, BONGOS_BRANDING_FILE: file },
|
|
151
|
+
// Both streams: the note goes through the module logger, and the test must
|
|
152
|
+
// not depend on which stream the logger writes to.
|
|
153
|
+
stdio: ['ignore', fd, fd],
|
|
154
|
+
});
|
|
155
|
+
return fs.readFileSync(errFile, 'utf8');
|
|
156
|
+
} finally {
|
|
157
|
+
fs.closeSync(fd);
|
|
158
|
+
fs.rmSync(file, { force: true });
|
|
159
|
+
fs.rmSync(errFile, { force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await test('real branding read: a usdPeg-only host pack resolves and awards as before', () => {
|
|
164
|
+
const { result } = resolveInChild({ usdPeg: 2.5 });
|
|
165
|
+
assert.equal(result.a.costScale, 2.5);
|
|
166
|
+
assert.equal(result.award.drachmae, 5, 'the pinned $1.65 award at 2.5');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('real branding read: costScale wins over usdPeg in a host pack', () => {
|
|
170
|
+
const { result } = resolveInChild({ costScale: 3, usdPeg: 2 });
|
|
171
|
+
assert.equal(result.a.costScale, 3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('real branding read: the note prints ONCE for a usdPeg pack, never for a costScale pack', () => {
|
|
175
|
+
const legacy = stderrOfChild({ usdPeg: 2 });
|
|
176
|
+
const hits = legacy.split(USD_PEG_DEPRECATION).length - 1;
|
|
177
|
+
assert.equal(hits, 1, `printed exactly once across three resolutions (stderr: ${legacy})`);
|
|
178
|
+
const moved = stderrOfChild({ costScale: 2 });
|
|
179
|
+
assert.ok(!moved.includes(USD_PEG_DEPRECATION), 'a moved pack is not nagged');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('no migration converts, rescales or rewrites a credit_log row for this rename', () => {
|
|
183
|
+
const dirs = [path.join(ROOT, 'migrations')];
|
|
184
|
+
const modulesDir = path.join(ROOT, 'modules');
|
|
185
|
+
for (const m of fs.readdirSync(modulesDir)) {
|
|
186
|
+
const d = path.join(modulesDir, m, 'migrations');
|
|
187
|
+
if (fs.existsSync(d)) dirs.push(d);
|
|
188
|
+
}
|
|
189
|
+
let scanned = 0;
|
|
190
|
+
for (const d of dirs) {
|
|
191
|
+
for (const f of fs.readdirSync(d).filter((n) => n.endsWith('.sql'))) {
|
|
192
|
+
scanned += 1;
|
|
193
|
+
const sql = fs.readFileSync(path.join(d, f), 'utf8');
|
|
194
|
+
assert.doesNotMatch(sql, /costScale|cost_scale|usdPeg|usd_peg/i, `${f} must not touch the sizing factor`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
assert.ok(scanned > 100, `the scan saw the migration set (${scanned} files), not an empty directory`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
summary();
|
|
@@ -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;
|