From 09a8f79d4b59b780179b9f02cb0ffcf521b91622 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Fri, 17 Jul 2026 01:43:34 +0300 Subject: [PATCH] Per-machine hook-config override: .gnkit/gitnexus-hooks.local.json (gitignored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the GITNEXUS_CONTEXT_WINDOW env override — same goal (scope a model-specific setting like the 1M context window per-machine, not committed to a shared repo) but as a first-class, runtime-agnostic config file rather than a shell/CI env var. loadHookConfig now layers config by precedence: defaults < gitnexus-hooks.json (team-shared) < gitnexus-hooks.local.json (gitignored, per-machine) < GITNEXUS_CONTEXT_WINDOW env. Extracted the file-merge into applyHookConfigFile() and call it for both files (a missing/invalid file is a no-op, so layering is safe). The local file is added to the managed .gitignore block so it never gets committed. Any dev can now override any knob (window, mode, thresholds) locally without touching the shared config or a shell profile — works for every runtime + GUI-launched IDEs. 62/62 tests. Co-Authored-By: Claude Opus 4.8 --- bundle/.gnkit/gitnexus-hooks.json | 2 +- bundle/.gnkit/lib/hook-helpers.mjs | 41 ++++++++++++++++-------- lib/kit.mjs | 8 +++-- lib/kit.test.mjs | 50 ++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/bundle/.gnkit/gitnexus-hooks.json b/bundle/.gnkit/gitnexus-hooks.json index 72f9626..4ea3da3 100644 --- a/bundle/.gnkit/gitnexus-hooks.json +++ b/bundle/.gnkit/gitnexus-hooks.json @@ -1,5 +1,5 @@ { - "comment": "Optional hook tuning. mode: enforce (block) | guide (nudge only). sourceGlobs: gitignore-style roots for graph-first paths. stalenessCacheTtlMs: reuse staleness within N ms to cut per-tool-call latency. sourceExts is intentionally omitted — the built-in polyglot default (see hook-helpers.mjs DEFAULT_SOURCE_EXT_RE) is the single source of truth; add a \"sourceExts\": [\"js\",\"py\", …] array here only to narrow/override it.", + "comment": "Optional hook tuning, TEAM-SHARED (committed). mode: enforce (block) | guide (nudge only). sourceGlobs: gitignore-style roots for graph-first paths. stalenessCacheTtlMs: reuse staleness within N ms to cut per-tool-call latency. sourceExts is intentionally omitted — the built-in polyglot default (see hook-helpers.mjs DEFAULT_SOURCE_EXT_RE) is the single source of truth; add a \"sourceExts\": [\"js\",\"py\", …] array here only to narrow/override it. For PER-MACHINE overrides that shouldn't be committed (e.g. \"contextWindowTokens\": 1000000 for a 1M-context session), create a gitignored .gnkit/gitnexus-hooks.local.json with the same shape — it wins over this file (GITNEXUS_CONTEXT_WINDOW env wins over both).", "mode": "enforce", "readLineThreshold": 60, "stalenessCacheTtlMs": 2500, diff --git a/bundle/.gnkit/lib/hook-helpers.mjs b/bundle/.gnkit/lib/hook-helpers.mjs index 9230b12..a6eca8d 100644 --- a/bundle/.gnkit/lib/hook-helpers.mjs +++ b/bundle/.gnkit/lib/hook-helpers.mjs @@ -42,6 +42,10 @@ export { } from "./rename-helpers.mjs"; export const CONFIG_FILE = ".gnkit/gitnexus-hooks.json"; +// Gitignored per-machine override — same shape as CONFIG_FILE, wins over it. Lets one dev tune the +// window / mode / thresholds (e.g. contextWindowTokens for a 1M session) without editing the +// team-shared file. Precedence: defaults < CONFIG_FILE < LOCAL_CONFIG_FILE < env. +export const LOCAL_CONFIG_FILE = ".gnkit/gitnexus-hooks.local.json"; /** @typedef {'enforce' | 'guide'} HookMode */ /** @typedef {'none' | 'light' | 'medium' | 'full'} EditSensitivity */ @@ -94,16 +98,35 @@ export function loadHookConfig(root) { driftRefreshThreshold: 3, // TASK-CORE compaction migration: nudge the agent to refresh its task-core once context // reaches contextPressureThreshold of contextWindowTokens. The window is model-specific, so - // prefer the per-machine env var GITNEXUS_CONTEXT_WINDOW=1000000 (wins over this file) rather - // than committing 1000000 to a shared repo where teammates run a 200k model. 0 threshold disables. + // scope 1000000 per-machine — a gitignored .gnkit/gitnexus-hooks.local.json (repo-scoped) or the + // GITNEXUS_CONTEXT_WINDOW env — instead of committing it to a repo where teammates run a 200k + // model. Precedence: this default < gitnexus-hooks.json < .local.json < env. 0 threshold disables. contextWindowTokens: 200000, contextPressureThreshold: 0.9, }; - const cfgPath = path.join(root, CONFIG_FILE); + // Shared team config first, then the gitignored per-machine override (each present file wins over + // the prior). A missing file is a no-op — readFileSync throws inside the helper and is swallowed. + applyHookConfigFile(cfg, path.join(root, CONFIG_FILE)); + applyHookConfigFile(cfg, path.join(root, LOCAL_CONFIG_FILE)); + // Per-machine env override wins over both files (handy for CI / ad-hoc). The context window is + // model-specific — a 1M-context session and a teammate's 200k model can't share one committed + // value — so scope it via GITNEXUS_CONTEXT_WINDOW or gitnexus-hooks.local.json, not the shared file. + const envWindow = Number(process.env.GITNEXUS_CONTEXT_WINDOW); + if (Number.isFinite(envWindow) && envWindow > 0) cfg.contextWindowTokens = envWindow; + + return cfg; +} + +/** + * Merge one hook-config JSON file into cfg (mutates). Missing/invalid file → no-op (keeps prior + * values), so it's safe to layer several files by precedence. Shared by CONFIG_FILE + LOCAL_CONFIG_FILE. + * @param {Record} cfg + * @param {string} cfgPath + */ +function applyHookConfigFile(cfg, cfgPath) { try { - // Missing file → readFileSync throws → caught below → defaults kept, then env override runs. const file = JSON.parse(fs.readFileSync(cfgPath, "utf8")); if (file.mode) cfg.mode = file.mode === "guide" ? "guide" : "enforce"; if (typeof file.readLineThreshold === "number") @@ -123,16 +146,8 @@ export function loadHookConfig(root) { cfg.sourceExtRe = buildExtRe(file.sourceExts); } } catch { - /* keep defaults */ + /* missing or invalid → keep prior values */ } - - // Per-machine env override wins over the (team-shared) config file. The context window is - // model-specific — a 1M-context session and a teammate's 200k model can't share one committed - // value — so set GITNEXUS_CONTEXT_WINDOW=1000000 in your shell rather than in gitnexus-hooks.json. - const envWindow = Number(process.env.GITNEXUS_CONTEXT_WINDOW); - if (Number.isFinite(envWindow) && envWindow > 0) cfg.contextWindowTokens = envWindow; - - return cfg; } function hookModeFromEnv() { diff --git a/lib/kit.mjs b/lib/kit.mjs index 6fa7ec1..0b78b4d 100644 --- a/lib/kit.mjs +++ b/lib/kit.mjs @@ -67,10 +67,14 @@ const GITIGNORE_BASE = [ ".gitnexus/", ".tmp-agent/", // .gnkit/ holds the TRACKED kit payload (hook lib, policy config, skill store — - // teammates get these via git). Only per-session runtime state + the derived - // API profile are ignored; the IDE skill symlink dirs are ignored + regenerated. + // teammates get these via git). Only per-session runtime state, the derived API + // profile, and the per-machine config override are ignored; the IDE skill symlink + // dirs are ignored + regenerated. ".gnkit/.gitnexus-*", ".gnkit/gitnexus-api-profile.json", + // Per-machine hook-config override (e.g. contextWindowTokens for a 1M session) — wins + // over the team-shared gitnexus-hooks.json; kept local so it doesn't affect teammates. + ".gnkit/gitnexus-hooks.local.json", // Derived architecture doc — regenerated from live graph stats on every refresh // (machine-specific), so it churns git for every teammate. Kept local. "docs/ARCHITECTURE.gitnexus.md", diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index cd632f4..2ba71a2 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1074,6 +1074,56 @@ describe("gitnexus-agent-kit", () => { } }); + it("gitnexus-hooks.local.json overrides the shared file per-machine (defaults < shared < local < env)", async () => { + const helpers = await import( + new URL("../bundle/.gnkit/lib/hook-helpers.mjs", import.meta.url).href + ); + const prev = process.env.GITNEXUS_CONTEXT_WINDOW; + try { + delete process.env.GITNEXUS_CONTEXT_WINDOW; + const r = fs.mkdtempSync(path.join(os.tmpdir(), "gn-local-")); + fs.mkdirSync(path.join(r, ".gnkit"), { recursive: true }); + const shared = path.join(r, ".gnkit/gitnexus-hooks.json"); + const local = path.join(r, ".gnkit/gitnexus-hooks.local.json"); + + // Shared team config sets three keys. + fs.writeFileSync( + shared, + JSON.stringify({ mode: "guide", contextWindowTokens: 200000, driftRefreshThreshold: 5 }), + ); + // Local override touches two of them; the untouched one falls through to shared. + fs.writeFileSync( + local, + JSON.stringify({ contextWindowTokens: 1000000, driftRefreshThreshold: 9 }), + ); + let c = helpers.loadHookConfig(r); + assert.equal(c.contextWindowTokens, 1000000, "local wins over shared"); + assert.equal(c.driftRefreshThreshold, 9, "local wins over shared"); + assert.equal(c.mode, "guide", "shared value stands where local is silent"); + + // Env still beats the local file. + process.env.GITNEXUS_CONTEXT_WINDOW = "500000"; + assert.equal(helpers.loadHookConfig(r).contextWindowTokens, 500000, "env beats local"); + delete process.env.GITNEXUS_CONTEXT_WINDOW; + + // Local-only (no shared file) still layers over the built-in defaults. + fs.rmSync(shared); + c = helpers.loadHookConfig(r); + assert.equal(c.contextWindowTokens, 1000000); + assert.equal(c.mode, "enforce", "default mode when no shared file"); + + // Invalid local JSON is ignored (shared/default stands) — never throws. + fs.writeFileSync(shared, JSON.stringify({ contextWindowTokens: 200000 })); + fs.writeFileSync(local, "{ not valid json"); + assert.equal(helpers.loadHookConfig(r).contextWindowTokens, 200000, "invalid local ignored"); + + fs.rmSync(r, { recursive: true, force: true }); + } finally { + if (prev === undefined) delete process.env.GITNEXUS_CONTEXT_WINDOW; + else process.env.GITNEXUS_CONTEXT_WINDOW = prev; + } + }); + it("cypher field access matches Methods (untyped source node)", async () => { const cypher = await import( new URL("../bundle/.gnkit/lib/cypher-helpers.mjs", import.meta.url)