Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bundle/.gnkit/gitnexus-hooks.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
41 changes: 28 additions & 13 deletions bundle/.gnkit/lib/hook-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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<string, any>} 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")
Expand All @@ -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() {
Expand Down
8 changes: 6 additions & 2 deletions lib/kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions lib/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading