diff --git a/bundle/.claude/hooks/gitnexus-context-pressure.mjs b/bundle/.claude/hooks/gitnexus-context-pressure.mjs new file mode 100644 index 0000000..7ddb8c6 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-context-pressure.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Claude Code PostToolUse → estimate how full the context window is; when auto-compaction is +// NEAR, nudge the agent to refresh its TASK-CORE (dense AI save-state) BEFORE the summary drops +// detail. PreCompact can't inject context or make the agent act, so this PostToolUse hook is +// where the pre-compaction "migrate the task" trigger lives (additionalContext is valid here). +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} + +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const transcript = input.transcript_path || ""; +if (!transcript) process.exit(0); + +const lib = (rel) => import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); +const { loadHookConfig } = await lib("hook-helpers.mjs"); +const { contextPressure } = await lib("context-pressure.mjs"); +const { emitContext } = await lib("claude-emit.mjs"); +const { taskCoreExists, isPressureNudged, setPressureNudged, bumpScore } = + await lib("session-primer.mjs"); + +const config = loadHookConfig(root); +if (!(config.contextPressureThreshold > 0)) process.exit(0); // feature disabled + +const p = contextPressure(transcript, config); + +if (p.over) { + // Nudge once per pressure zone (flag) — but keep nudging while there's still NO task-core, + // since compacting with no core is straight data loss. Once a core exists, go quiet. + if (!isPressureNudged(root) || !taskCoreExists(root)) { + const pct = Math.round(p.ratio * 100); + const kt = Math.round(p.tokens / 1000); + emitContext( + `⚠ CONTEXT ~${pct}% full (~${kt}k tok) — auto-compaction is NEAR. Refresh your TASK-CORE ` + + "**now**, before the summary drops load-bearing detail: write `.gnkit/.gitnexus-task-core.md` " + + "as a DENSE, AI-facing save-state of THIS TASK — GOAL · CONSTRAINTS · DECISIONS(+why) · " + + "STATE(done/now/NEXT/todo) · ANCHORS(file:line) · GOTCHAS(failed approaches, traps) · " + + "OPEN-Qs · this-task USER-PREFS. Terse, no prose — it's for you, not humans. It is the ONE " + + "thing guaranteed to survive compaction; SessionStart reads it back on recovery. " + + "(Format: the `gitnexus-taskcore` skill.)", + "PostToolUse", + ); + setPressureNudged(root, true); + bumpScore(root, "contextPressureNudges"); + } +} else if (p.ratio < config.contextPressureThreshold - 0.1) { + // Pressure fell well below the line (a compaction shrank the transcript) → re-arm the nudge. + setPressureNudged(root, false); +} +// otherwise: stay silent (hysteresis band, or over-threshold with a fresh core already written) diff --git a/bundle/.claude/hooks/gitnexus-session.mjs b/bundle/.claude/hooks/gitnexus-session.mjs index 8534866..bcdccf0 100644 --- a/bundle/.claude/hooks/gitnexus-session.mjs +++ b/bundle/.claude/hooks/gitnexus-session.mjs @@ -24,6 +24,8 @@ const { isDetectUsed, memoryPath, fallbackGrant, + taskCorePath, + taskCoreExists, } = await lib("session-primer.mjs"); const source = input.source || "startup"; @@ -43,8 +45,13 @@ const staleLine = grant let lines; if (recovering) { const hasMem = existsSync(memoryPath(root)); + const hasCore = taskCoreExists(root); + const tcp = taskCorePath(root); lines = [ `GitNexus: context was ${source === "compact" ? "COMPACTED" : "resumed"} — the task CONTINUES; enforcement and this session's satisfied gates are PRESERVED.`, + hasCore + ? `READ your TASK-CORE FIRST — \`${tcp}\`: a dense save-state of THIS task (goal/constraints/decisions/state/anchors/gotchas/next). Reconstruct from it, verify against reality, then continue — do not re-derive what it already settles.` + : `No TASK-CORE saved — reconstruct THIS task (goal/decisions/state/next) from your memory + the code before acting, and write \`.gnkit/.gitnexus-task-core.md\` next time so compaction can't drift you.`, // Graph-first discipline MUST be re-stated here, not only on fresh start: post-compaction is // exactly where agents drift back to grep/blind-read. "Gates preserved" ≠ "stop using the graph". "Graph-first STILL applies — do NOT fall back to grep or blind Read: orient with gitnexus_query, drill with gitnexus_context, cypher for structure, impact before edits, detect_changes before commit.", diff --git a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc index f68d4db..d2661a4 100644 --- a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc +++ b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc @@ -159,6 +159,18 @@ At a **milestone** — feature done / big-task checkpoint / shared-code refactor Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** +## Task-core (survive compaction without drift) + +Long tasks get **compacted** — the transcript is summarized and dropped, and detail drifts. Keep a **task-core**: a dense, **AI-facing** save-state of the CURRENT TASK at **`.gnkit/.gitnexus-task-core.md`**. When a PostToolUse nudge says context is filling (~90%), or at a milestone / before a risky pivot, **write or refresh it** — terse, for *you* not humans, no prose tax: + +``` +GOAL · CONSTRAINTS · DECISIONS +STATE done<✓+anchor> / now / NEXT / todo · ANCHORS +GOTCHAS · OPEN-Qs · USER-PREFS(this task) +``` + +On recovery **READ the task-core FIRST** and reconstruct from it — it's the one thing guaranteed to survive with full detail. It's distinct from `MEMORY.md` (durable, cross-session, human-shared): the task-core is the *hot working-set for THIS task*, overwritten when the task changes. Full routine: the **`gitnexus-taskcore`** skill. + ## Fallback **Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. diff --git a/bundle/.gnkit/lib/context-pressure.mjs b/bundle/.gnkit/lib/context-pressure.mjs new file mode 100644 index 0000000..ccd352c --- /dev/null +++ b/bundle/.gnkit/lib/context-pressure.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Context-pressure estimation for the TASK-CORE compaction-migration routine. + * + * Claude Code auto-compacts (summarizes + drops the transcript) when the context window + * fills. The PreCompact hook CANNOT make the agent act or inject context, so we can't wait + * for it. Instead a PostToolUse hook estimates how full the window is and, past a threshold, + * nudges the agent to write/refresh its TASK-CORE *before* the summary lands — the only thing + * guaranteed to survive compaction with full detail. + * + * This module is the estimator: it reads the CURRENT context size from the transcript cheaply + * (tail-read, no full-file parse) and accurately (the last assistant message's usage = the + * exact prompt size the model saw), with a byte-size fallback. + */ +import fs from "node:fs"; + +const TAIL_BYTES = 131072; // 128 KB tail — enough to hold the last usage record + +/** + * Estimate the current context size in tokens from a Claude Code transcript (JSONL). + * @param {string} transcriptPath + * @returns {number} estimated context tokens (0 if unknown/unreadable) + */ +export function estimateContextTokens(transcriptPath) { + let size = 0; + try { + size = fs.statSync(transcriptPath).size; + } catch { + return 0; + } + if (!size) return 0; + + let text = ""; + try { + const readBytes = Math.min(size, TAIL_BYTES); + const fd = fs.openSync(transcriptPath, "r"); + try { + const buf = Buffer.alloc(readBytes); + fs.readSync(fd, buf, 0, readBytes, size - readBytes); + text = buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return Math.round(size / 3.5); // couldn't read tail → byte estimate + } + + // Accurate: the LAST assistant message's usage is the prompt size in the window + // (non-cached input + cache read + cache creation = everything sent to the model). + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line || line[0] !== "{") continue; + let obj; + try { + obj = JSON.parse(line); + } catch { + continue; // partial line (tail cut mid-record) or non-JSON — skip + } + const u = obj?.message?.usage || obj?.usage; + if (u && typeof u.input_tokens === "number") { + return ( + (u.input_tokens || 0) + + (u.cache_read_input_tokens || 0) + + (u.cache_creation_input_tokens || 0) + ); + } + } + // No usage record in the tail → rough byte estimate (JSONL overhead ~3.5 bytes/token). + return Math.round(size / 3.5); +} + +/** + * @param {string} transcriptPath + * @param {{ contextWindowTokens?: number, contextPressureThreshold?: number }} config + * @returns {{ tokens: number, window: number, threshold: number, ratio: number, over: boolean }} + */ +export function contextPressure(transcriptPath, config = {}) { + const window = Number(config.contextWindowTokens) > 0 ? Number(config.contextWindowTokens) : 200000; + const threshold = + Number(config.contextPressureThreshold) > 0 ? Number(config.contextPressureThreshold) : 0.9; + const tokens = estimateContextTokens(transcriptPath); + const ratio = window > 0 ? tokens / window : 0; + return { tokens, window, threshold, ratio, over: ratio >= threshold }; +} diff --git a/bundle/.gnkit/lib/hook-helpers.mjs b/bundle/.gnkit/lib/hook-helpers.mjs index 84223a4..55cf18a 100644 --- a/bundle/.gnkit/lib/hook-helpers.mjs +++ b/bundle/.gnkit/lib/hook-helpers.mjs @@ -92,6 +92,11 @@ export function loadHookConfig(root) { // Working-tree drift: after this many uncommitted source edits since the index, // graph query tools require a fast incremental refresh. 0 disables the drift gate. driftRefreshThreshold: 3, + // TASK-CORE compaction migration: nudge the agent to refresh its task-core once context + // reaches contextPressureThreshold of contextWindowTokens. Set contextWindowTokens to your + // model's window (200k default; 1M-context sessions → 1000000). 0 threshold disables. + contextWindowTokens: 200000, + contextPressureThreshold: 0.9, }; const cfgPath = path.join(root, CONFIG_FILE); @@ -106,6 +111,10 @@ export function loadHookConfig(root) { cfg.stalenessCacheTtlMs = file.stalenessCacheTtlMs; if (typeof file.driftRefreshThreshold === "number") cfg.driftRefreshThreshold = file.driftRefreshThreshold; + if (typeof file.contextWindowTokens === "number") + cfg.contextWindowTokens = file.contextWindowTokens; + if (typeof file.contextPressureThreshold === "number") + cfg.contextPressureThreshold = file.contextPressureThreshold; if (Array.isArray(file.sourceGlobs) && file.sourceGlobs.length) { cfg.sourcePathRes = file.sourceGlobs.map((g) => globToRegExp(g)); } diff --git a/bundle/.gnkit/lib/session-primer.mjs b/bundle/.gnkit/lib/session-primer.mjs index 181e7fe..5fb990d 100644 --- a/bundle/.gnkit/lib/session-primer.mjs +++ b/bundle/.gnkit/lib/session-primer.mjs @@ -21,9 +21,60 @@ export function sessionPaths(root) { stalenessCacheFile: path.join(stateDir, '.gitnexus-staleness-cache.json'), scorecardFile: path.join(stateDir, '.gitnexus-scorecard.json'), fallbackFlag: path.join(stateDir, '.gitnexus-fallback.json'), + pressureNudgedFlag: path.join(stateDir, '.gitnexus-pressure-nudged.flag'), }; } +// ── TASK-CORE (compaction-migration save-state) ────────────────────────────── +// A dense, AI-facing save-state of the CURRENT TASK (goal/constraints/decisions/state/ +// anchors/gotchas/next). The context-pressure hook nudges the agent to refresh it before +// auto-compaction; the SessionStart(compact) recovery brief reads it back — so the task +// survives the summary without drift. Lives under .gnkit/ (gitignored, survives compaction +// AND new sessions since a task can span both; the agent overwrites it when the task changes). + +/** @param {string} root */ +export function taskCorePath(root) { + return path.join(root, '.gnkit', '.gitnexus-task-core.md'); +} + +/** @param {string} root @returns {boolean} does a task-core exist + have content? */ +export function taskCoreExists(root) { + try { + return fs.statSync(taskCorePath(root)).size > 0; + } catch { + return false; + } +} + +/** Age of the task-core in ms (Infinity if none) — the pressure hook nudges harder when stale. */ +export function taskCoreAgeMs(root) { + try { + return Date.now() - fs.statSync(taskCorePath(root)).mtimeMs; + } catch { + return Infinity; + } +} + +/** @param {string} root @param {boolean} on — remember we already nudged this pressure zone. */ +export function setPressureNudged(root, on) { + const { stateDir, pressureNudgedFlag } = sessionPaths(root); + try { + if (on) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(pressureNudgedFlag, new Date().toISOString()); + } else { + fs.unlinkSync(pressureNudgedFlag); + } + } catch { + /* best effort */ + } +} + +/** @param {string} root */ +export function isPressureNudged(root) { + return fs.existsSync(sessionPaths(root).pressureNudgedFlag); +} + // ── Classical fallback escape hatch ────────────────────────────────────────── // When GitNexus returns wrong / suspicious / incomplete info while the index is // FRESH, graph-first enforcement would otherwise trap the agent. grantClassicalFallback @@ -362,6 +413,7 @@ export function clearSessionState(root) { stalenessCacheFile, scorecardFile, fallbackFlag, + pressureNudgedFlag, } = sessionPaths(root); fs.mkdirSync(stateDir, { recursive: true }); // Archive the finishing session's tally BEFORE wiping the scorecard. @@ -376,6 +428,7 @@ export function clearSessionState(root) { stalenessCacheFile, scorecardFile, fallbackFlag, + pressureNudgedFlag, ]) { try { fs.unlinkSync(f); diff --git a/bundle/scripts/gitnexus-agent.mjs b/bundle/scripts/gitnexus-agent.mjs index eed312b..25fee26 100644 --- a/bundle/scripts/gitnexus-agent.mjs +++ b/bundle/scripts/gitnexus-agent.mjs @@ -527,6 +527,7 @@ if (cmd === "scorecard") { compactions: "Context compactions", classicalFallbackGranted: "Classical-fallback grants (GN distrusted)", driftRefreshBlocks: "Graph-drift refresh blocks (edited since index)", + contextPressureNudges: "Context-pressure task-core nudges (near compaction)", }; console.log("GitNexus enforcement scorecard (this session)"); console.log( diff --git a/bundle/scripts/pack-gitnexus-teaching.sh b/bundle/scripts/pack-gitnexus-teaching.sh index 9be535d..edcd2db 100755 --- a/bundle/scripts/pack-gitnexus-teaching.sh +++ b/bundle/scripts/pack-gitnexus-teaching.sh @@ -63,6 +63,7 @@ BUNDLE_PATHS=( .gnkit/lib/cursor-emit.mjs .gnkit/lib/claude-emit.mjs .gnkit/lib/session-primer.mjs + .gnkit/lib/context-pressure.mjs .gnkit/lib/first-nudge.mjs .gnkit/lib/clear-session.mjs .gnkit/lib/set-refresh-pending.mjs diff --git a/bundle/scripts/sync-cursor-gitnexus-teaching.sh b/bundle/scripts/sync-cursor-gitnexus-teaching.sh index abf4e88..7890176 100755 --- a/bundle/scripts/sync-cursor-gitnexus-teaching.sh +++ b/bundle/scripts/sync-cursor-gitnexus-teaching.sh @@ -34,6 +34,7 @@ HOOK_LIBS=( ".gnkit/lib/cursor-emit.mjs" ".gnkit/lib/claude-emit.mjs" ".gnkit/lib/session-primer.mjs" + ".gnkit/lib/context-pressure.mjs" ".gnkit/lib/first-nudge.mjs" ".gnkit/lib/clear-session.mjs" ".gnkit/lib/set-refresh-pending.mjs" diff --git a/bundle/skills/gitnexus-taskcore/SKILL.md b/bundle/skills/gitnexus-taskcore/SKILL.md new file mode 100644 index 0000000..369b5f5 --- /dev/null +++ b/bundle/skills/gitnexus-taskcore/SKILL.md @@ -0,0 +1,63 @@ +--- +name: gitnexus-taskcore +description: "Maintain a dense, AI-facing TASK-CORE save-state so a long task survives context COMPACTION without drift. Load it when: a PostToolUse nudge says context is ~90% full, at a milestone / before a risky pivot / when the task shifts, OR on recovery after a compaction (read it back first). The core is for the model, not humans — terse, anchors over prose. Examples: \"context is filling — save state\", \"checkpoint the task before compaction\", \"recover the task after compaction\"." +--- + +# Task-core — a compaction save-state that kills drift + +When a long task runs, Claude Code **compacts**: it summarizes the conversation and drops the transcript. The generic summary keeps the gist but loses load-bearing detail — a constraint the user gave, a decision's *why*, the exact file:line you were mid-edit on, a dead-end you already ruled out. After compaction the agent **drifts**: re-litigates settled calls, repeats failed approaches, forgets requirements. + +The fix: **you** decide what survives. Keep a **task-core** — a dense, machine-facing save-state of the CURRENT TASK — and read it back on recovery. It's the one artifact guaranteed to survive with full fidelity. + +**File:** `.gnkit/.gitnexus-task-core.md` (gitignored; survives compaction *and* new sessions — a task can span both; overwrite it when the task changes). + +## When to write / refresh it + +- **Context-pressure nudge** — a PostToolUse hook estimates the window and, at ~90% (`contextPressureThreshold` × `contextWindowTokens`, tunable in `.gnkit/gitnexus-hooks.json`), tells you compaction is near. **Refresh the core immediately** — this is the last reliable window before the summary lands. +- **Milestones** — a sub-goal done, a decision settled, a pivot. Cheap insurance so a *sudden* auto-compact never catches you with a stale core. +- **Task start / task shift** — seed a fresh core when a new task begins (don't carry the old one). + +You don't need to rewrite it every turn — that wastes tokens. Refresh on the nudge and at real checkpoints. + +## The format (dense, for the model — not humans) + +Terse. No prose transitions, no politeness, no restating the obvious. **Anchors over narrative.** Optimize signal-per-token — the only reader is you, post-compaction. + +``` +# TASK-CORE — (refreshed @ ) +GOAL: +CONSTRAINTS: +DECISIONS: (settled — so you don't re-litigate them) +STATE: + DONE: <✓ fact + file:line anchor> + NOW: + NEXT: + TODO: +ANCHORS: (your map to resume fast) +GOTCHAS: +OPEN-Qs: +USER-PREFS(this task): +``` + +**Include** the things a summary drops: the *why* behind decisions, dead-ends already ruled out, exact anchors, the user's precise wording on constraints, the immediate next action. **Exclude** narrative recap, tool-by-tool history, and anything re-derivable from the code in seconds. + +## On recovery (post-compaction / resume) + +The SessionStart brief points you here. **Read the task-core FIRST**, before acting: +1. Reconstruct the task from it — goal, constraints, decisions, state, next. +2. **Verify against reality** (the core is a point-in-time snapshot; a file may have moved). Don't trust an anchor blindly — confirm it. +3. Continue from `NEXT`. Do **not** re-derive what the core already settles, and do **not** repeat anything in `GOTCHAS`. + +## Task-core vs. MEMORY.md + +- **MEMORY.md** — durable, cross-session, human-shared *project* memory (who/what/why of the project over time). +- **Task-core** — the *hot working-set for THIS task*, machine-optimized, ephemeral, overwritten per task. + +They complement: on recovery, read the **task-core first** (it's the current task), then reconcile with MEMORY.md for durable context. + +## Anti-patterns + +- Writing it human-pretty (headings, prose, hedging) — wastes the token budget it exists to save. +- Only writing it once at the start — refresh at the nudge and milestones or it goes stale. +- Dumping the whole transcript — the point is *distillation*: decisions, state, anchors, gotchas, next. If it reads like a diary, it's wrong. +- Trusting it blindly on recovery — always verify anchors against the live code. diff --git a/bundle/skills/gitnexus-workspace/SKILL.md b/bundle/skills/gitnexus-workspace/SKILL.md index f66c8e4..566e5bb 100644 --- a/bundle/skills/gitnexus-workspace/SKILL.md +++ b/bundle/skills/gitnexus-workspace/SKILL.md @@ -59,6 +59,7 @@ Run `npm run gitnexus:detect-api` to refresh the profile after major server chan | Work across layers (controller→service→repo→model) | `gitnexus-layered-systems` | | Structured task (pre-commit, PR, cross-module) | `gitnexus-scenarios` | | Milestone deep audit (feature done, pre-ship, big refactor) | **`gitnexus-microscope`** — multi-lens, opinionated, verified | +| Context filling / near compaction / recover after compaction | **`gitnexus-taskcore`** — dense AI save-state, survives compaction without drift | | PR or branch review | `gitnexus-pr-review` | | Security / taint / injection review | `gitnexus-security-review` | | Research HTTP API change | See **HTTP API routing** above | diff --git a/bundle/templates/AGENTS.gitnexus.md b/bundle/templates/AGENTS.gitnexus.md index 10fa90b..a873318 100644 --- a/bundle/templates/AGENTS.gitnexus.md +++ b/bundle/templates/AGENTS.gitnexus.md @@ -154,6 +154,18 @@ At a **milestone** — feature done / big-task checkpoint / shared-code refactor Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** +## Task-core (survive compaction without drift) + +Long tasks get **compacted** — the transcript is summarized and dropped, and detail drifts. Keep a **task-core**: a dense, **AI-facing** save-state of the CURRENT TASK at **`.gnkit/.gitnexus-task-core.md`**. When a PostToolUse nudge says context is filling (~90%), or at a milestone / before a risky pivot, **write or refresh it** — terse, for *you* not humans, no prose tax: + +``` +GOAL · CONSTRAINTS · DECISIONS +STATE done<✓+anchor> / now / NEXT / todo · ANCHORS +GOTCHAS · OPEN-Qs · USER-PREFS(this task) +``` + +On recovery **READ the task-core FIRST** and reconstruct from it — it's the one thing guaranteed to survive with full detail. It's distinct from `MEMORY.md` (durable, cross-session, human-shared): the task-core is the *hot working-set for THIS task*, overwritten when the task changes. Full routine: the **`gitnexus-taskcore`** skill. + ## Fallback **Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. diff --git a/bundle/templates/CLAUDE.gitnexus.md b/bundle/templates/CLAUDE.gitnexus.md index b3a0127..77f85a1 100644 --- a/bundle/templates/CLAUDE.gitnexus.md +++ b/bundle/templates/CLAUDE.gitnexus.md @@ -154,6 +154,18 @@ At a **milestone** — feature done / big-task checkpoint / shared-code refactor Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** +## Task-core (survive compaction without drift) + +Long tasks get **compacted** — the transcript is summarized and dropped, and detail drifts. Keep a **task-core**: a dense, **AI-facing** save-state of the CURRENT TASK at **`.gnkit/.gitnexus-task-core.md`**. When a PostToolUse nudge says context is filling (~90%), or at a milestone / before a risky pivot, **write or refresh it** — terse, for *you* not humans, no prose tax: + +``` +GOAL · CONSTRAINTS · DECISIONS +STATE done<✓+anchor> / now / NEXT / todo · ANCHORS +GOTCHAS · OPEN-Qs · USER-PREFS(this task) +``` + +On recovery **READ the task-core FIRST** and reconstruct from it — it's the one thing guaranteed to survive with full detail. It's distinct from `MEMORY.md` (durable, cross-session, human-shared): the task-core is the *hot working-set for THIS task*, overwritten when the task changes. Full routine: the **`gitnexus-taskcore`** skill. + ## Fallback **Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. diff --git a/lib/adapters/claude.mjs b/lib/adapters/claude.mjs index df0d709..a1b7702 100644 --- a/lib/adapters/claude.mjs +++ b/lib/adapters/claude.mjs @@ -30,6 +30,8 @@ const CLAUDE_HOOKS = { ], SessionStart: [[null, "gitnexus-session.mjs"]], PreCompact: [[null, "gitnexus-precompact.mjs"]], + // Estimate context fullness after each tool; near auto-compaction, nudge a TASK-CORE refresh. + PostToolUse: [[null, "gitnexus-context-pressure.mjs"]], }; /** A hook group is "ours" if any of its commands runs a gitnexus-* hook script. */ diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index ed5911e..4fc5bcf 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1810,6 +1810,49 @@ describe("gitnexus-agent-kit", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("context-pressure estimates window fullness; task-core survives session clear", async () => { + const cp = await import( + new URL("../bundle/.gnkit/lib/context-pressure.mjs", import.meta.url).href + ); + const sp = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-pressure-")); + const transcript = path.join(tmp, "t.jsonl"); + + // Estimate = the last assistant message's input + cache usage (the real prompt size). + fs.writeFileSync( + transcript, + '{"type":"user","message":{"role":"user","content":"hi"}}\n' + + '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":5000,"cache_read_input_tokens":175000,"cache_creation_input_tokens":2000,"output_tokens":100}}}\n', + ); + assert.equal(cp.estimateContextTokens(transcript), 182000); + assert.equal( + cp.contextPressure(transcript, { contextWindowTokens: 200000, contextPressureThreshold: 0.9 }).over, + true, + ); + // Same fill but a 1M-token window → NOT near (window is per-model configurable). + assert.equal( + cp.contextPressure(transcript, { contextWindowTokens: 1000000, contextPressureThreshold: 0.9 }).over, + false, + ); + // No usage record → byte-size fallback; missing file → 0. + fs.writeFileSync(transcript, "x".repeat(35000)); + assert.equal(cp.estimateContextTokens(transcript), 10000); + assert.equal(cp.estimateContextTokens(path.join(tmp, "nope")), 0); + + // Task-core survives clearSessionState (a task can span sessions); the nudge flag re-arms. + fs.mkdirSync(path.join(tmp, ".gnkit"), { recursive: true }); + fs.writeFileSync(sp.taskCorePath(tmp), "# TASK-CORE\nGOAL: x\n"); + sp.setPressureNudged(tmp, true); + assert.equal(sp.taskCoreExists(tmp), true); + assert.equal(sp.isPressureNudged(tmp), true); + sp.clearSessionState(tmp); + assert.equal(sp.isPressureNudged(tmp), false, "new session re-arms the nudge"); + assert.equal(sp.taskCoreExists(tmp), true, "task-core survives a session clear"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("session-health-audit builds agent context and user message", async () => { const auditMod = await import( new URL( diff --git a/scripts/contract/enforcement-contract.md b/scripts/contract/enforcement-contract.md index 93cf26e..912859e 100644 --- a/scripts/contract/enforcement-contract.md +++ b/scripts/contract/enforcement-contract.md @@ -150,6 +150,18 @@ At a **milestone** — feature done / big-task checkpoint / shared-code refactor Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** +## Task-core (survive compaction without drift) + +Long tasks get **compacted** — the transcript is summarized and dropped, and detail drifts. Keep a **task-core**: a dense, **AI-facing** save-state of the CURRENT TASK at **`.gnkit/.gitnexus-task-core.md`**. When a PostToolUse nudge says context is filling (~90%), or at a milestone / before a risky pivot, **write or refresh it** — terse, for *you* not humans, no prose tax: + +``` +GOAL · CONSTRAINTS · DECISIONS +STATE done<✓+anchor> / now / NEXT / todo · ANCHORS +GOTCHAS · OPEN-Qs · USER-PREFS(this task) +``` + +On recovery **READ the task-core FIRST** and reconstruct from it — it's the one thing guaranteed to survive with full detail. It's distinct from `MEMORY.md` (durable, cross-session, human-shared): the task-core is the *hot working-set for THIS task*, overwritten when the task changes. Full routine: the **`gitnexus-taskcore`** skill. + ## Fallback **Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why.