@bongos/core 1.19.836 → 1.19.838

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.
@@ -0,0 +1,173 @@
1
+ // tests/runner_drift.mjs — the intent-runner drift gate (task 1002722).
2
+ //
3
+ // The incident: for weeks the prod intent runners ran core 1.19.2 from a retired clone while
4
+ // the instance served a current core, so an owner's domain-attach click executed as a
5
+ // TEARDOWN (task 1002716). The hotfix repointed the units with systemd drop-ins. These cases
6
+ // pin the reading of a unit the way systemd reads it (a drop-in overrides the base file) and
7
+ // the version comparison, against a fake filesystem shaped like the live box.
8
+ //
9
+ // Run: node tests/runner_drift.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { readFileSync } from 'node:fs';
13
+ import { effectiveUnit, runnerScriptPath, coreVersionFor, installedCoreVersion, assessRunnerUnit, parseUnitList, runnerBanner } from '../scripts/gds/runner-drift.js';
14
+
15
+ let passed = 0, failed = 0;
16
+ function t(name, fn) {
17
+ try { fn(); passed++; console.log(` PASS ${name}`); }
18
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
19
+ }
20
+
21
+ // The live selfhost unit as `systemctl cat` prints it (2026-09-17): base file + drop-in.
22
+ const LIVE_CAT = `# /etc/systemd/system/selfhost-provision-intent-runner.service
23
+ [Service]
24
+ Type=oneshot
25
+ WorkingDirectory=/home/lars/cloudbongos
26
+ EnvironmentFile=/etc/cloudbongos/box.env
27
+ ExecStart=/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js run-intents --apply
28
+
29
+ # /etc/systemd/system/selfhost-provision-intent-runner.service.d/override.conf
30
+ [Service]
31
+ WorkingDirectory=/home/lars/cloudbongos-instance
32
+ ExecStart=
33
+ ExecStart=/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply
34
+ `;
35
+
36
+ // A fake fs: path → file body. Only readFileSync + existsSync are used.
37
+ function fakeFs(files) {
38
+ return {
39
+ existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p),
40
+ readFileSync: (p) => { if (!(p in files)) { const e = new Error(`ENOENT ${p}`); e.code = 'ENOENT'; throw e; } return files[p]; },
41
+ };
42
+ }
43
+ const pkg = (name, version) => JSON.stringify({ name, version });
44
+ const INSTANCE = '/home/lars/cloudbongos-instance';
45
+ const boxFs = (installed, runnerPkg = installed) => fakeFs({
46
+ [`${INSTANCE}/node_modules/@bongos/core/package.json`]: pkg('@bongos/core', installed),
47
+ [`${INSTANCE}/package.json`]: pkg('cloudbongos-instance', '0.0.1'),
48
+ ...(runnerPkg !== installed ? { '/opt/other/node_modules/@bongos/core/package.json': pkg('@bongos/core', runnerPkg) } : {}),
49
+ '/home/lars/cloudbongos/src/module-api.js': "const CORE_VERSION = '1.19.2'; // old clone\n",
50
+ '/home/lars/cloudbongos/package.json': pkg('cloud-bongos', '0.0.2'),
51
+ });
52
+
53
+ t('effectiveUnit: a drop-in WorkingDirectory wins, and an empty ExecStart= resets the base one', () => {
54
+ assert.deepEqual(effectiveUnit(LIVE_CAT), {
55
+ workingDirectory: INSTANCE,
56
+ execStarts: ['/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply'],
57
+ });
58
+ });
59
+
60
+ t('effectiveUnit: with no drop-in the base file stands (the pre-hotfix shape)', () => {
61
+ const base = LIVE_CAT.split('# /etc/systemd/system/selfhost-provision-intent-runner.service.d')[0];
62
+ const u = effectiveUnit(base);
63
+ assert.equal(u.workingDirectory, '/home/lars/cloudbongos');
64
+ assert.equal(u.execStarts.length, 1);
65
+ assert.match(u.execStarts[0], /infra\/core-node\.sh/);
66
+ });
67
+
68
+ t('assessRunnerUnit: a drop-in that ADDS an ExecStart without clearing leaves the stale base command running — never ok', () => {
69
+ const cat = LIVE_CAT.replace('ExecStart=\nExecStart=/usr/bin/node', 'ExecStart=/usr/bin/node')
70
+ .replace('ExecStart=/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js run-intents --apply', 'ExecStart=/usr/bin/node /home/lars/cloudbongos/scripts/gds/provision.js run-intents --apply');
71
+ assert.equal(effectiveUnit(cat).execStarts.length, 2);
72
+ const r = assessRunnerUnit({ name: 'x', catText: cat, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
73
+ assert.equal(r.status, 'drift', r.reason);
74
+ assert.match(r.reason, /1\.19\.2/);
75
+ });
76
+
77
+ t('runnerScriptPath: node + a relative script resolves against WorkingDirectory; a wrapper resolves to null', () => {
78
+ assert.equal(runnerScriptPath({ workingDirectory: INSTANCE, execStart: '/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply' }),
79
+ `${INSTANCE}/node_modules/@bongos/core/scripts/gds/provision.js`);
80
+ assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env node /abs/core/scripts/gds/box.js run-intents' }), '/abs/core/scripts/gds/box.js');
81
+ assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '-/usr/bin/node scripts/gds/box.js' }), '/w/scripts/gds/box.js', 'systemd exec prefixes are stripped');
82
+ assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env NODE_ENV=production node --max-old-space-size=512 scripts/gds/box.js' }), '/w/scripts/gds/box.js', 'env assignments and node flags are skipped');
83
+ assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js' }), null,
84
+ 'a shell wrapper decides at runtime which core it loads — the gate must not guess');
85
+ });
86
+
87
+ t('coreVersionFor: an installed package reads its package.json; a source checkout reads CORE_VERSION', () => {
88
+ const fs = boxFs('1.19.831');
89
+ assert.equal(coreVersionFor(`${INSTANCE}/node_modules/@bongos/core/scripts/gds/provision.js`, { fsImpl: fs }), '1.19.831');
90
+ assert.equal(coreVersionFor('/home/lars/cloudbongos/scripts/gds/provision.js', { fsImpl: fs }), '1.19.2');
91
+ assert.equal(coreVersionFor('/nowhere/scripts/gds/provision.js', { fsImpl: fs }), null);
92
+ });
93
+
94
+ t('installedCoreVersion: reads <root>/node_modules/@bongos/core; null when absent', () => {
95
+ assert.equal(installedCoreVersion(INSTANCE, { fsImpl: boxFs('1.19.831') }), '1.19.831');
96
+ assert.equal(installedCoreVersion('/empty', { fsImpl: boxFs('1.19.831') }), null);
97
+ });
98
+
99
+ t('assessRunnerUnit: the live drop-in shape is ok', () => {
100
+ const r = assessRunnerUnit({ name: 'selfhost-provision-intent-runner.service', catText: LIVE_CAT, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
101
+ assert.equal(r.status, 'ok', r.reason);
102
+ assert.equal(r.runnerVersion, '1.19.831');
103
+ });
104
+
105
+ t('assessRunnerUnit: a unit running a source clone on another core is DRIFT, naming both versions', () => {
106
+ const cat = LIVE_CAT.replace(/ExecStart=\/usr\/bin\/node node_modules[^\n]*/, 'ExecStart=/usr/bin/node /home/lars/cloudbongos/scripts/gds/provision.js run-intents --apply');
107
+ const r = assessRunnerUnit({ name: 'x.service', catText: cat, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
108
+ assert.equal(r.status, 'drift');
109
+ assert.match(r.reason, /1\.19\.2/);
110
+ assert.match(r.reason, /1\.19\.831/);
111
+ });
112
+
113
+ t('assessRunnerUnit: a wrapper, or a version it cannot read, is UNRESOLVED — never reported as ok', () => {
114
+ const base = LIVE_CAT.split('# /etc/systemd/system/selfhost-provision-intent-runner.service.d')[0];
115
+ assert.equal(assessRunnerUnit({ name: 'x', catText: base, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') }).status, 'unresolved');
116
+ assert.equal(assessRunnerUnit({ name: 'x', catText: LIVE_CAT, instanceRoot: '/empty', fsImpl: boxFs('1.19.831') }).status, 'unresolved');
117
+ assert.equal(assessRunnerUnit({ name: 'x', catText: '', instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') }).status, 'unresolved');
118
+ });
119
+
120
+ t('parseUnitList: plain list-units output → service names only', () => {
121
+ const out = 'cohost-provision-intent-runner.service loaded inactive dead Cloud Bongos — drain\nselfhost-provision-intent-runner.service loaded inactive dead x\n\n';
122
+ assert.deepEqual(parseUnitList(out), ['cohost-provision-intent-runner.service', 'selfhost-provision-intent-runner.service']);
123
+ assert.deepEqual(parseUnitList(''), []);
124
+ });
125
+
126
+ t('runnerBanner: names the runner core + path, and flags a differing instance core', () => {
127
+ assert.equal(runnerBanner({ runnerVersion: '1.19.831', runnerRoot: '/r', instanceVersion: '1.19.831', instanceRoot: '/i' }),
128
+ 'runner core 1.19.831 from /r (instance core 1.19.831 at /i)');
129
+ assert.match(runnerBanner({ runnerVersion: '1.19.2', runnerRoot: '/r', instanceVersion: '1.19.831', instanceRoot: '/i' }), /^⚠ DRIFT: runner core 1\.19\.2 from \/r \(instance core 1\.19\.831 at \/i\)/);
130
+ assert.equal(runnerBanner({ runnerVersion: null, runnerRoot: '/r', instanceVersion: null, instanceRoot: '/i' }), 'runner core ? from /r (no installed core at /i)');
131
+ });
132
+
133
+ // box.js run-intents is the other runner the incident class covers — it opens with the same line.
134
+ const { cmdRunIntents: boxRunIntents } = await import('../scripts/gds/box.js');
135
+ {
136
+ const lines = [];
137
+ const origLog = console.log;
138
+ const origRoot = process.env.BONGOS_INSTANCE_ROOT;
139
+ process.env.BONGOS_INSTANCE_ROOT = '/no/such/instance';
140
+ console.log = (l) => lines.push(String(l));
141
+ try { await boxRunIntents({ query: async () => ({ rows: [] }) }, {}, null, { apply: false }); }
142
+ finally {
143
+ console.log = origLog;
144
+ if (origRoot === undefined) delete process.env.BONGOS_INSTANCE_ROOT; else process.env.BONGOS_INSTANCE_ROOT = origRoot;
145
+ }
146
+ t('box.js run-intents opens with the runner core version line', () => {
147
+ assert.match(lines[0], /^runner core \d+\.\d+\.\d+ from .+ \(no installed core at \/no\/such\/instance\)$/);
148
+ });
149
+ }
150
+
151
+ const { runnerDriftRows } = await import('../scripts/gds/doctor.js');
152
+
153
+ t('doctor maps each verdict to a row: ok → ok, drift → FAIL with the fix, unresolved → warn', () => {
154
+ const rows = runnerDriftRows([
155
+ { name: 'a.service', status: 'ok', reason: 'runs core 1.19.831' },
156
+ { name: 'b.service', status: 'drift', reason: 'runs core 1.19.2 (/x) but the instance installed 1.19.831 at /i' },
157
+ { name: 'c.service', status: 'unresolved', reason: 'ExecStart does not run a .js script directly' },
158
+ ]);
159
+ assert.deepEqual(rows.map((r) => r.status), ['ok', 'fail', 'warn']);
160
+ assert.equal(rows[0].msg, 'a.service: runs core 1.19.831');
161
+ assert.match(rows[1].msg, /^b\.service: runs core 1\.19\.2 .* — repoint the unit's WorkingDirectory \+ ExecStart at the installed core/);
162
+ assert.equal(rows[2].msg, 'c.service: ExecStart does not run a .js script directly');
163
+ assert.deepEqual(runnerDriftRows(null), [], 'no systemd → no rows, so a laptop shows no section');
164
+ assert.deepEqual(runnerDriftRows([]), []);
165
+ });
166
+
167
+ t('doctor runs the runner-drift probe', () => {
168
+ const src = readFileSync(new URL('../scripts/gds/doctor.js', import.meta.url), 'utf8');
169
+ assert.match(src.slice(src.indexOf('require.main === module')), /checkRunnerDrift\(\)/);
170
+ });
171
+
172
+ console.log(`\nrunner_drift: ${passed} passed, ${failed} failed`);
173
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,233 @@
1
+ // tests/stuck_detector.mjs — the stuck detector (task 1003990): a session that hits the
2
+ // same wall three times is told, once per further hit, to stop retrying and diagnose or
3
+ // ask. Three repeat kinds share one strike counter (.claude/hooks/stuck-strikes.js):
4
+ // - the same refused claim → stuck-detector.js, keyed on the [otb-plain] card
5
+ // - the same red check → stuck-detector.js, keyed on the card + the RED line
6
+ // - the same guard block → git-guard.js / worktree-guard.js call the counter
7
+ // The hooks are SPAWNED for the contract that matters to Claude Code (exit code, stdout
8
+ // JSON, stderr); the pure parsing is called in-process.
9
+ //
10
+ // .claude/hooks/ is package-excluded, so a clean core checkout skips this file
11
+ // (tests/host_topology_skips.mjs carries the allowlist entry).
12
+ //
13
+ // Run: node tests/stuck_detector.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { createRequire } from 'node:module';
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 { requireHostModule } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const strikes = requireHostModule(require, '../.claude/hooks/stuck-strikes.js');
27
+ const detector = require('../.claude/hooks/stuck-detector.js');
28
+ const { plainCardLines, CARDS } = require('../scripts/gds/plain-cards.js');
29
+
30
+ let passed = 0;
31
+ let failed = 0;
32
+ function t(name, fn) {
33
+ try { fn(); passed++; console.log(` PASS ${name}`); }
34
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
35
+ }
36
+
37
+ // Every spawned hook counts into a throwaway dir, never the real os.tmpdir().
38
+ const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'stuck-detector-'));
39
+ const ENV = { ...process.env, OTB_STUCK_DIR: DIR, BONGOS_STUCK_DETECTOR_OFF: '', BONGOS_GIT_GUARD_OFF: '', OTB_ALLOW_CROSS_TREE: '' };
40
+ const hookPath = (name) => path.join(ROOT, '.claude', 'hooks', `${name}.js`);
41
+ function run(name, payload, env = {}) {
42
+ const r = spawnSync(process.execPath, [hookPath(name)], { input: JSON.stringify(payload), encoding: 'utf8', timeout: 15000, env: { ...ENV, ...env } });
43
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
44
+ }
45
+ const injected = (r) => (r.stdout.trim() ? JSON.parse(r.stdout).hookSpecificOutput.additionalContext : '');
46
+ const card = (code) => plainCardLines(code, {}).join('\n');
47
+ const bash = (session, command, output, extra = {}) => ({ session_id: session, tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: output, stderr: '' }, ...extra });
48
+
49
+ // ---- pure: reading the card ------------------------------------------------
50
+ t('parsePlainCards reads each card and whether it is a rule', () => {
51
+ const cards = detector.parsePlainCards(`engineer text\n${card('REBASE_REQUIRED')}\nmore\n${card('failing_check')}`);
52
+ assert.equal(cards.length, 2);
53
+ assert.equal(cards[0].happened, CARDS.REBASE_REQUIRED.happened);
54
+ assert.equal(cards[0].byDesign, true);
55
+ assert.equal(cards[1].byDesign, false);
56
+ });
57
+
58
+ t('a waiting card ("Nothing to do…") is not a wall — polling it is what its card says to do', () => {
59
+ const waiting = Object.keys(CARDS).filter((k) => detector.isWaitingCard({ next: CARDS[k].next }));
60
+ assert.deepEqual(waiting.sort(), ['blocked_required_review_or_pending_check', 'land_pending', 'mergeability_computing', 'non_required_check_pending', 'request_pending']);
61
+ });
62
+
63
+ t('the task id is the one the CLI was handed, not any number on the line', () => {
64
+ const id = (cmd) => detector.wallsIn(cmd, card('TASK_NOT_READY')).taskId;
65
+ assert.equal(id('node scripts/gds/claim.js 1003990 --worktree task-1003990'), '1003990');
66
+ assert.equal(id('node scripts/gds/claim.js --worktree task-1003990 1003990'), '1003990');
67
+ assert.equal(id('cd "C:/x y/task-77" && node "C:/x y/scripts/gds/ship.js" --regrade 1003848 --notes "3 of 404 pages"'), '1003848');
68
+ assert.equal(id('bongos claim 7'), '7', 'a fresh instance numbers its tasks from 1');
69
+ assert.equal(id('timeout 120 node scripts/gds/start.js'), '');
70
+ });
71
+
72
+ t('only a builder CLI run can hit a wall — reading a file that quotes a card cannot', () => {
73
+ for (const cmd of ['cat docs/session-logs/x.md', 'git diff', 'grep -A5 otb-plain tests/stuck_detector.mjs', 'cat scripts/gds/claim.js | node -e ""']) {
74
+ assert.deepEqual(detector.wallsIn(cmd, card('REBASE_REQUIRED')).walls, [], cmd);
75
+ }
76
+ assert.equal(detector.wallsIn('node node_modules/@cloudbongos/core/scripts/gds/claim.js 5', card('REBASE_REQUIRED')).walls.length, 1);
77
+ });
78
+
79
+ t('parallel hook processes cannot wipe each other: the counter is append-only', () => {
80
+ const src = fs.readFileSync(hookPath('stuck-strikes'), 'utf8');
81
+ assert.match(src, /appendFileSync/);
82
+ assert.ok(!/writeFileSync/.test(src), 'a read-modify-write would let one hook reset another\'s counts');
83
+ });
84
+
85
+ t('recordStrike counts per session and per signature, and never throws', () => {
86
+ assert.equal(strikes.recordStrike('s-unit', 'a', DIR), 1);
87
+ assert.equal(strikes.recordStrike('s-unit', 'a', DIR), 2);
88
+ assert.equal(strikes.recordStrike('s-unit', 'b', DIR), 1);
89
+ assert.equal(strikes.recordStrike('s-other', 'a', DIR), 1);
90
+ assert.equal(strikes.recordStrike('', 'a', DIR), 0, 'no session id → cannot count → quiet');
91
+ assert.equal(strikes.recordStrike('s-unit', 'a', path.join(DIR, 'missing', 'dir')), 0, 'unwritable dir → quiet');
92
+ });
93
+
94
+ // ---- kind 1: the same refused claim ----------------------------------------
95
+ t('the same refused claim: silent twice, one instruction on the third', () => {
96
+ const out = `Claim refused: 409 REBASE_REQUIRED\n${card('REBASE_REQUIRED')}`;
97
+ const p = bash('s-claim', 'node scripts/gds/claim.js 1003848 --worktree task-1003848', out);
98
+ for (let i = 0; i < 2; i++) {
99
+ const r = run('stuck-detector', p);
100
+ assert.equal(r.status, 0);
101
+ assert.equal(r.stdout, '', `strike ${i + 1} must be silent`);
102
+ }
103
+ const third = run('stuck-detector', p);
104
+ assert.equal(third.status, 0, 'never blocks: exit 0 even when it speaks');
105
+ const ctx = injected(third);
106
+ assert.match(ctx, /\[otb-stuck\]/);
107
+ assert.match(ctx, /3 times/);
108
+ assert.match(ctx, /rule, not a fault/);
109
+ assert.match(ctx, /\/ask-for-help/);
110
+ assert.ok(!ctx.includes(CARDS.REBASE_REQUIRED.happened), 'static text only — tool output is never echoed back');
111
+ });
112
+
113
+ t('a different task id is a different wall', () => {
114
+ const out = card('ALREADY_CLAIMED');
115
+ for (const id of ['2001', '2002', '2003']) {
116
+ assert.equal(run('stuck-detector', bash('s-ids', `node scripts/gds/claim.js ${id}`, out)).stdout, '');
117
+ }
118
+ });
119
+
120
+ // ---- kind 2: the same red check on the same branch -------------------------
121
+ t('the same red check: third identical RED names the diagnostic path', () => {
122
+ const red = ' ci-land: the PR is RED — tests/security.mjs failed. Reproduce with `node tests/<name>.mjs`, fix it';
123
+ const out = `${red}\n${card('failing_check')}\n${card('land_pending')}`;
124
+ const p = bash('s-red', 'node scripts/gds/ship.js 1003848 --notes x --summary y', out);
125
+ run('stuck-detector', p); run('stuck-detector', p);
126
+ const ctx = injected(run('stuck-detector', p));
127
+ assert.match(ctx, /same failure 3 times/);
128
+ assert.match(ctx, /publish-status/);
129
+ assert.match(ctx, /1003848/);
130
+ });
131
+
132
+ t('a red check that names DIFFERENT failing tests each time is progress, not a wall', () => {
133
+ for (const name of ['a', 'b', 'c']) {
134
+ const out = ` ci-land: the PR is RED — tests/${name}.mjs failed.\n${card('failing_check')}`;
135
+ assert.equal(run('stuck-detector', bash('s-progress', 'node scripts/gds/ship.js 77001', out)).stdout, '');
136
+ }
137
+ });
138
+
139
+ t('resultText reads the output from whichever field the harness used', () => {
140
+ assert.equal(detector.resultText({ tool_response: { stdout: 'a', stderr: 'b' } }), 'a\nb');
141
+ assert.equal(detector.resultText({ tool_output: { output: 'c', exit_code: 1 } }), 'c');
142
+ assert.equal(detector.resultText({ tool_response: 'd', error_message: 'e', error: 'f' }), 'd\nf\ne');
143
+ assert.equal(detector.resultText({ tool_response: 42, error: null }), '');
144
+ });
145
+
146
+ // A refusal exits non-zero; whether that routes to PostToolUse or PostToolUseFailure is
147
+ // the harness's business, so the hook is wired on both and answers as whichever fired.
148
+ t('a failed tool call (PostToolUseFailure: error_message, no tool_response) still counts', () => {
149
+ const p = { session_id: 's-fail', hook_event_name: 'PostToolUseFailure', tool_name: 'PowerShell', tool_input: { command: 'node scripts/gds/claim.js 555' }, error_message: `exit 1\n${card('DEPS_NOT_SHIPPED')}` };
150
+ run('stuck-detector', p); run('stuck-detector', p);
151
+ const r = run('stuck-detector', p);
152
+ assert.match(JSON.parse(r.stdout).hookSpecificOutput.hookEventName, /^PostToolUseFailure$/);
153
+ assert.match(injected(r), /\[otb-stuck\]/);
154
+ });
155
+
156
+ // ---- kind 3: the same command blocked by a guard ---------------------------
157
+ t('git-guard: the third block of the same rule appends the instruction; the block itself is unchanged', () => {
158
+ const p = { session_id: 's-git', tool_name: 'Bash', tool_input: { command: 'git stash' } };
159
+ for (let i = 0; i < 2; i++) {
160
+ const r = run('git-guard', p);
161
+ assert.equal(r.status, 2);
162
+ assert.ok(!r.stderr.includes('[otb-stuck]'), `block ${i + 1} carries no nudge`);
163
+ }
164
+ const third = run('git-guard', { ...p, tool_input: { command: 'git stash push -m again' } });
165
+ assert.equal(third.status, 2);
166
+ assert.match(third.stderr, /git-guard: blocked/);
167
+ assert.match(third.stderr, /\[otb-stuck\]/);
168
+ assert.match(third.stderr, /remedy/i);
169
+ });
170
+
171
+ t('worktree-guard: the same wrong-tree file three times appends the instruction to the deny reason', () => {
172
+ // The guard resolves its roots from git, so the spawned case needs a real linked
173
+ // worktree; from the main checkout (CI) the in-process case below carries it.
174
+ const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim();
175
+ const mainRoot = path.dirname(common);
176
+ const guard = require('../.claude/hooks/worktree-guard.js');
177
+ const deny = { deny: true, reason: 'Wrong git checkout.' };
178
+ assert.equal(guard.withStuckNudge(deny, 's-wt-pure', '/repo/server.js', ENV).reason, deny.reason);
179
+ assert.equal(guard.withStuckNudge(deny, 's-wt-pure', '/repo/server.js', ENV).reason, deny.reason);
180
+ assert.match(guard.withStuckNudge(deny, 's-wt-pure', '/repo/server.js', ENV).reason, /^Wrong git checkout\.\n\[otb-stuck\]/);
181
+ if (!common || path.resolve(mainRoot) === path.resolve(ROOT)) return;
182
+ const p ={ session_id: 's-wt', cwd: ROOT, tool_name: 'Edit', tool_input: { file_path: path.join(mainRoot, 'server.js') } };
183
+ const reason = (r) => JSON.parse(r.stdout).hookSpecificOutput.permissionDecisionReason;
184
+ assert.ok(!reason(run('worktree-guard', p)).includes('[otb-stuck]'));
185
+ assert.ok(!reason(run('worktree-guard', p)).includes('[otb-stuck]'));
186
+ const third = run('worktree-guard', p);
187
+ assert.equal(third.status, 0);
188
+ assert.match(reason(third), /Wrong git checkout/);
189
+ assert.match(reason(third), /\[otb-stuck\]/);
190
+ });
191
+
192
+ // ---- the quiet path --------------------------------------------------------
193
+ t('quiet on a clean run: no card → exit 0, no stdout, no stderr, nothing written', () => {
194
+ const before = fs.readdirSync(DIR).length;
195
+ for (const p of [
196
+ bash('s-quiet', 'node scripts/gds/claim.js 1003990', 'Claimed task 1003990\n[otb-confirm] claimed'),
197
+ bash('s-quiet', 'npm test', 'FAIL tests/x.mjs'),
198
+ { session_id: 's-quiet', tool_name: 'Read', tool_input: { file_path: 'x' } },
199
+ {},
200
+ ]) {
201
+ const r = run('stuck-detector', p);
202
+ assert.deepEqual([r.status, r.stdout, r.stderr], [0, '', '']);
203
+ }
204
+ assert.equal(fs.readdirSync(DIR).length, before);
205
+ });
206
+
207
+ t('malformed stdin fails open and silent', () => {
208
+ for (const raw of ['', 'not json', '{"tool_name":"Bash",']) {
209
+ const r = spawnSync(process.execPath, [hookPath('stuck-detector')], { input: raw, encoding: 'utf8', env: ENV });
210
+ assert.deepEqual([r.status, r.stdout, r.stderr], [0, '', '']);
211
+ }
212
+ });
213
+
214
+ t('kill-switch: BONGOS_STUCK_DETECTOR_OFF=1 silences the detector and the guard nudges', () => {
215
+ const p = bash('s-off', 'node scripts/gds/claim.js 9', card('TASK_NOT_READY'));
216
+ for (let i = 0; i < 4; i++) assert.equal(run('stuck-detector', p, { BONGOS_STUCK_DETECTOR_OFF: '1' }).stdout, '');
217
+ const g = { session_id: 's-off-git', tool_name: 'Bash', tool_input: { command: 'git branch -D x' } };
218
+ for (let i = 0; i < 4; i++) assert.ok(!run('git-guard', g, { BONGOS_STUCK_DETECTOR_OFF: '1' }).stderr.includes('[otb-stuck]'));
219
+ });
220
+
221
+ t('wired in .claude/settings.json through run-node.sh, on both tool-result events', () => {
222
+ const settings = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
223
+ for (const event of ['PostToolUse', 'PostToolUseFailure']) {
224
+ const cmds = (settings.hooks[event] || []).flatMap((g) => g.hooks.map((h) => h.command));
225
+ const mine = cmds.filter((c) => c.includes('stuck-detector.js'));
226
+ assert.equal(mine.length, 1, `${event}: expected exactly one stuck-detector entry`);
227
+ assert.match(mine[0], /run-node\.sh/);
228
+ }
229
+ });
230
+
231
+ fs.rmSync(DIR, { recursive: true, force: true });
232
+ console.log(`\nstuck_detector: ${passed} passed, ${failed} failed`);
233
+ process.exitCode = failed ? 1 : 0;