Skip to content

Commit 5748411

Browse files
mcp-tool-shopclaude
andcommitted
feat(hook): self-contain via esbuild + new-scoring matcher + 0.5 floor (cutover prep)
Retiring published ai-loadout, so the runtime hook can no longer import it. esbuild bundle (apps/hook/esbuild.config.mjs, like the CLI) inlines the workspace kernel's new recall-aware matcher into apps/hook/dist/loadout-hook.mjs — dependency-free deployable. Floor default 0.3 -> 0.5 (re-derived for the new max(coverage, matched/5) scoring; AI_LOADOUT_MIN_SCORE override kept). ai-loadout moved deps->devDeps in apps/hook (inlined). smoke-test.ps1 drift check now compares the live hook against the freshly-built BUNDLE (default -HookPath bundle). Standalone-verified: self-contained, recall fix live (game-canon prompt scores 0.8-1.0), 0.5 floor silences noise, fail-silent intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c3707df commit 5748411

5 files changed

Lines changed: 136 additions & 25 deletions

File tree

apps/hook/esbuild.config.mjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Bundle the loadout runtime hook into ONE self-contained ESM file.
3+
*
4+
* Why this exists: the LIVE hook (~/.claude/loadout-hook/loadout-hook.mjs) runs
5+
* from a directory with NO node_modules. The SOURCE hook (apps/hook/loadout-hook.mjs)
6+
* does `await import('@mcptoolshop/ai-loadout')` to get the matcher — fine in the
7+
* workspace (it resolves to packages/kernel via the node_modules symlink), but a
8+
* MODULE_NOT_FOUND in the deployed location. Published ai-loadout is being retired,
9+
* so the hook can no longer depend on it being installed.
10+
*
11+
* The fix mirrors packages/cli/esbuild.config.mjs: esbuild INLINES the kernel
12+
* (= @mcptoolshop/ai-loadout, the workspace packages/kernel, carrying the NEW
13+
* recall-aware scoring) into a single dependency-free deployable. Node builtins
14+
* (fs, path, os) stay external — always present at runtime.
15+
*
16+
* SOURCE (apps/hook/loadout-hook.mjs) → keeps the import; resolves to the
17+
* workspace kernel for dev/readability.
18+
* BUNDLE (apps/hook/dist/loadout-hook.mjs) → import inlined; this is what the
19+
* coordinator copies to the live hook.
20+
*
21+
* Format choice: ESM. The kernel is `"type": "module"` ESM and the hook source is
22+
* already ESM (top-level await on the dynamic import). Bundling to ESM preserves
23+
* those semantics with zero shims. The `#!/usr/bin/env node` shebang on line 1 of
24+
* the source is preserved verbatim by esbuild; a post-bundle assertion verifies
25+
* exactly one shebang on line 1 (a malformed shebang silently breaks the hook).
26+
*
27+
* Standards compliance (workflow-standards.md):
28+
* PIN_PER_STEP 2 — the bundle is a deterministic, replayable artifact of the
29+
* pinned source + pinned esbuild version (devDependencies) at a fixed target
30+
* (node20); no network, no nondeterministic input.
31+
* ANDON_AUTHORITY 2 — esbuild fails the build (non-zero) on any unresolved
32+
* import, and the shebang assertion below halts on a malformed artifact; a
33+
* broken bundle never reaches the coordinator's cutover.
34+
* EXTERNAL_VERIFIER 1 — the standalone proof (run the emitted bundle from a
35+
* clean temp dir with no node_modules) is run by the operator/coordinator, not
36+
* baked in here. skip: a build script is not a multi-model pipeline.
37+
* Remaining standards (NAMED_COMPENSATORS / DECOMPOSE_BY_SECRETS /
38+
* UNCERTAINTY_GATED_HUMANS) skip: this step performs no irreversible tool call
39+
* (it only writes dist/loadout-hook.mjs, overwritten on every run) and has no
40+
* human checkpoint. The cutover to the LIVE hook is the coordinator's step.
41+
*/
42+
43+
import { build } from "esbuild";
44+
import { readFileSync } from "node:fs";
45+
import { fileURLToPath } from "node:url";
46+
import { dirname, join } from "node:path";
47+
48+
const here = dirname(fileURLToPath(import.meta.url));
49+
const entry = join(here, "loadout-hook.mjs");
50+
const outfile = join(here, "dist", "loadout-hook.mjs");
51+
52+
await build({
53+
entryPoints: [entry],
54+
outfile,
55+
bundle: true,
56+
platform: "node",
57+
format: "esm",
58+
target: "node20",
59+
// The executable shebang is preserved from the entry (loadout-hook.mjs opens
60+
// with `#!/usr/bin/env node`); esbuild keeps a leading hashbang verbatim. We do
61+
// NOT add a `banner` shebang — that would duplicate it. The assertion below
62+
// verifies exactly one shebang on line 1.
63+
// Inline EVERYTHING (the @mcptoolshop/ai-loadout workspace kernel included).
64+
// Only Node's own builtins stay external — always present at runtime.
65+
packages: undefined,
66+
external: [],
67+
logLevel: "info",
68+
sourcemap: false,
69+
legalComments: "none",
70+
});
71+
72+
// ANDON: a malformed shebang silently breaks the deployed hook (Claude Code runs
73+
// it as an executable). Assert exactly one `#!/usr/bin/env node` and that it is line 1.
74+
const out = readFileSync(outfile, "utf8");
75+
const lines = out.split("\n");
76+
const shebangCount = lines.filter((l) => l.startsWith("#!")).length;
77+
if (lines[0] !== "#!/usr/bin/env node" || shebangCount !== 1) {
78+
console.error(
79+
`bundle shebang check failed: expected exactly one '#!/usr/bin/env node' on line 1, ` +
80+
`got ${shebangCount} hashbang line(s); line 1 = ${JSON.stringify(lines[0])}`,
81+
);
82+
process.exit(1);
83+
}
84+
85+
console.log(`bundled → ${outfile} (${(out.length / 1024).toFixed(1)} kb, shebang OK)`);

apps/hook/loadout-hook.mjs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// • AI_LOADOUT_HOOK=debug → print why-silent + top near-misses to STDERR only
1515
// (never stdout; the suppressOutput / fail-silent
1616
// contract and the ≤200-token budget are untouched)
17-
// • AI_LOADOUT_MIN_SCORE=<n> → override the score floor (default 0.3) for
17+
// • AI_LOADOUT_MIN_SCORE=<n> → override the score floor (default 0.5) for
1818
// calibration, without an edit + mirror→live cutover
1919
// Latency budget: < 500 ms cold. Never blocks.
2020

@@ -32,14 +32,17 @@ const USAGE_PATH = resolve(HOME, '.ai-loadout', 'usage.jsonl');
3232
const MAX_ENTRIES = 5;
3333
const MAX_LINE_CHARS = 180;
3434
// Minimum match score for a DOMAIN entry to be injected. Core entries (score 1.0)
35-
// always pass. Calibrated 2026-06-16 against the live 336-entry index: observed
36-
// incidental single-keyword noise tops out at ~0.25, genuine topical matches begin
37-
// ~0.33+, so 0.3 is the "confident match" floor that delivers the design's
38-
// "below-threshold → emit nothing". Override per-run with AI_LOADOUT_MIN_SCORE for
39-
// calibration without an edit + cutover. Recall on keyword-rich entries (e.g. game
40-
// canon) is a separate Phase-2 keyword-curation concern, not this floor's job.
35+
// always pass. Re-calibrated 2026-06-16 for the NEW recall-aware scoring in the
36+
// inlined kernel matcher: score = max(coverage, matched/ABSOLUTE_K=5) + patternBonus.
37+
// Under this blend a genuine multi-keyword match lands at ~0.6+ (3 matched keywords
38+
// → absolute 3/5 = 0.6, plus the 0.2 patternBonus if a pattern also fires), while
39+
// incidental / generic single- or double-keyword noise tops out around ~0.4
40+
// (2/5 = 0.4). So 0.5 is the new "confident match" floor that delivers the design's
41+
// "below-threshold → emit nothing" (was 0.3 under the OLD matched/total coverage
42+
// scoring, where genuine matches began ~0.33). Override per-run with
43+
// AI_LOADOUT_MIN_SCORE for calibration without an edit + cutover.
4144
const _envMin = Number(process.env.AI_LOADOUT_MIN_SCORE);
42-
const HOOK_MIN_SCORE = Number.isFinite(_envMin) ? _envMin : 0.3;
45+
const HOOK_MIN_SCORE = Number.isFinite(_envMin) ? _envMin : 0.5;
4346

4447
// Debug diagnostics → STDERR only (never stdout, so suppressOutput + fail-silent
4548
// are preserved). Answers "why was the hook silent on a prompt I expected a hit for".

apps/hook/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
"main": "loadout-hook.mjs",
88
"scripts": {
99
"test": "node --check loadout-hook.mjs",
10+
"bundle": "node esbuild.config.mjs",
1011
"smoke": "pwsh -NoProfile -File smoke-test.ps1"
1112
},
1213
"engines": {
1314
"node": ">=20"
1415
},
15-
"dependencies": {
16-
"@mcptoolshop/ai-loadout": "^1.4.3"
16+
"devDependencies": {
17+
"@mcptoolshop/ai-loadout": "^1.4.3",
18+
"esbuild": "^0.28.1"
1719
}
1820
}

apps/hook/smoke-test.ps1

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,61 @@
22
<#
33
Smoke test for the loadout hook.
44
5-
- Drift check (HOK-05): mirror (apps/hook) vs live (~/.claude/loadout-hook) must be byte-identical.
6-
- Threshold check (HOK-01): drives the hook with representative prompts and shows what it injects.
7-
A min-score floor means weak/incidental matches and off-topic prompts go silent.
5+
- Drift check (HOK-05): the LIVE hook (~/.claude/loadout-hook) must be byte-identical
6+
to the BUNDLE (apps/hook/dist/loadout-hook.mjs) — the dependency-free deployable
7+
that the coordinator copies to the live location. (The source loadout-hook.mjs is
8+
NOT the deployable: it `import`s @mcptoolshop/ai-loadout, which the live dir cannot
9+
resolve. The bundle inlines the kernel matcher.) The bundle is (re)built here first.
10+
- Threshold check (HOK-01): drives the hook with representative prompts and shows what
11+
it injects. The min-score floor (default 0.5 under the recall-aware scoring) means
12+
weak/incidental matches and off-topic prompts go silent.
813
914
Isolation: runs the hook under a SCRATCH HOME (a copy of the live ~/.ai-loadout/index.json),
1015
so testing never appends to the live usage.jsonl.
1116
1217
Usage:
13-
./smoke-test.ps1 # tests the MIRROR copy (this repo) by default
14-
./smoke-test.ps1 -HookPath live # tests the LIVE copy (~/.claude/loadout-hook)
18+
./smoke-test.ps1 # tests the BUNDLE (apps/hook/dist) by default
19+
./smoke-test.ps1 -HookPath source # tests the SOURCE copy (needs the workspace node_modules)
20+
./smoke-test.ps1 -HookPath live # tests the LIVE copy (~/.claude/loadout-hook)
1521
#>
1622
param(
17-
[string]$HookPath = 'mirror'
23+
[ValidateSet('bundle', 'source', 'live')]
24+
[string]$HookPath = 'bundle'
1825
)
1926
$ErrorActionPreference = 'Continue'
2027

21-
$mirror = Join-Path $PSScriptRoot 'loadout-hook.mjs'
28+
$source = Join-Path $PSScriptRoot 'loadout-hook.mjs'
29+
$bundle = Join-Path $PSScriptRoot 'dist/loadout-hook.mjs'
2230
$live = Join-Path $HOME '.claude/loadout-hook/loadout-hook.mjs'
2331

24-
# ── Drift check (HOK-05) ────────────────────────────────────────
32+
# ── (Re)build the bundle so the drift check compares against a fresh artifact ──
2533
Write-Output ('=' * 78)
26-
Write-Output 'DRIFT CHECK — mirror vs live (must be byte-identical)'
34+
Write-Output 'BUILD — esbuild bundle (apps/hook/dist/loadout-hook.mjs)'
2735
Write-Output ('=' * 78)
28-
$mirrorHash = (Get-FileHash $mirror -Algorithm SHA256).Hash
36+
& node (Join-Path $PSScriptRoot 'esbuild.config.mjs')
37+
if ($LASTEXITCODE -ne 0) { Write-Output "BUILD FAILED (esbuild exit $LASTEXITCODE)"; exit 1 }
38+
Write-Output ''
39+
40+
# ── Drift check (HOK-05): live must equal the BUNDLE ────────────
41+
Write-Output ('=' * 78)
42+
Write-Output 'DRIFT CHECK — live vs bundle (must be byte-identical)'
43+
Write-Output ('=' * 78)
44+
$bundleHash = (Get-FileHash $bundle -Algorithm SHA256).Hash
2945
if (Test-Path $live) {
3046
$liveHash = (Get-FileHash $live -Algorithm SHA256).Hash
31-
if ($mirrorHash -eq $liveHash) { Write-Output "OK — identical ($mirrorHash)" }
32-
else { Write-Output "DRIFT — mirror=$mirrorHash live=$liveHash" }
47+
if ($bundleHash -eq $liveHash) { Write-Output "OK — identical ($bundleHash)" }
48+
else { Write-Output "DRIFT — bundle=$bundleHash live=$liveHash" }
3349
} else {
3450
Write-Output "live copy not found at $live"
3551
}
3652
Write-Output ''
3753

3854
# ── Resolve which hook to drive ─────────────────────────────────
39-
$hook = if ($HookPath -eq 'live') { $live } else { $mirror }
55+
$hook = switch ($HookPath) {
56+
'live' { $live }
57+
'source' { $source }
58+
default { $bundle }
59+
}
4060
Write-Output "Driving: $hook"
4161
Write-Output ''
4262

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)