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)