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
31 changes: 30 additions & 1 deletion src/runtime/inject-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,37 @@ export function claudeMdKey(prompt: string, ctx: string): string {
return `claude-md:${hashText(prompt)}:${hashText(ctx)}`;
}

/**
* Extract the APEX preamble portion of a `buildClaudeMdContext` result, i.e.
* everything BEFORE the `# <docName>\n` root-doc block it prepends on dev
* prompts. Returns "" when `ctx` has no preamble (plain prompt: `ctx` starts
* directly with the root-doc heading).
* @param ctx - The full `buildClaudeMdContext` return value.
* @param docName - Root doc file name (e.g. "AGENTS.md").
* @returns The preamble text, or "" when absent.
*/
function extractApexPreamble(ctx: string, docName: string): string {
const heading = `# ${docName}\n`;
if (ctx.startsWith(heading)) return "";
const sep = `\n\n${heading}`;
const idx = ctx.indexOf(sep);
return idx === -1 ? "" : ctx.slice(0, idx);
}

/**
* UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
* preamble as a Claude `additionalContext` response, or "" when nothing to emit.
* Guarded by {@link oncePerWindow} via {@link claudeMdKey}: only a
* near-simultaneous double-fire of the SAME turn (identical prompt AND identical
* block, within {@link DEDUP_WINDOW_MS}) is suppressed. The invariant "CLAUDE.md
* is emitted on EVERY message" is thus preserved.
*
* On kimi specifically, the root doc body is dropped from the emitted text:
* Kimi loads `<kimiHome>/AGENTS.md` natively at session start (`apex-target.ts`),
* so re-injecting its full body on every prompt is redundant terminal noise
* (kimi has no model-only hook channel — `runtime/inform.ts`). The APEX
* preamble, which is NOT part of `AGENTS.md`, is still emitted in full when the
* prompt is dev-shaped; a plain prompt leaves only the notice.
* @param prompt - The raw user prompt.
* @param cwd - Project root (for project-type detection).
* @param id - Harness target id (defaults to "claude-code" — zero-regression default).
Expand All @@ -38,7 +62,12 @@ export function promptSubmitContext(prompt: string, cwd: string, id: string = "c
const ctx = buildClaudeMdContext(prompt, cwd, id);
if (!ctx) return "";
if (!oncePerWindow(claudeMdKey(prompt, ctx), DEDUP_WINDOW_MS)) return "";
return renderInform(id, "UserPromptSubmit", ctx, `${apexDocName(id)} injected`);
const notice = `${apexDocName(id)} injected`;
if (id === "kimi") {
const preamble = extractApexPreamble(ctx, apexDocName(id));
return preamble ? renderInform(id, "UserPromptSubmit", preamble, notice) : notice;
}
return renderInform(id, "UserPromptSubmit", ctx, notice);
}

/**
Expand Down
12 changes: 11 additions & 1 deletion src/runtime/lifecycle/inject-rules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { renderInform } from "../inform";
import { rulesInAgentsMd } from "./kimi-rules-native";

/** Read & concatenate all `*.md` files (sorted) under `rulesDir`. */
export function readRules(rulesDir: string): string {
Expand Down Expand Up @@ -29,12 +30,21 @@ export function readRules(rulesDir: string): string {
* with the *actual* `hookEventName` — the spec requires it to match the firing
* event (a hardcoded "SessionStart" is non-conforming and may be dropped on
* UserPromptSubmit/SubagentStart).
*
* On kimi's `UserPromptSubmit` specifically, when the corpus is already fenced
* inside `<kimiHome>/AGENTS.md` (native load, {@link rulesInAgentsMd}), only the
* notice is emitted — the re-injected corpus would otherwise dump into the
* user's terminal on every single prompt for no model-context benefit.
* `SessionStart`/`SubagentStart` always keep the full corpus (session bootstrap
* / sub-agent contexts need it regardless of the native AGENTS.md load).
* @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin.
* @param event - The firing hook event name (e.g. "SessionStart").
* @param id - Harness target id (defaults to "claude-code" — zero-regression default).
* @returns The native hook stdout (possibly empty).
*/
export function injectRules(pluginRoot: string, event: string, id: string = "claude-code"): string {
const content = readRules(join(pluginRoot, "rules"));
return content ? renderInform(id, event, content, "rules 00-08 injected") : "";
if (!content) return "";
if (id === "kimi" && event === "UserPromptSubmit" && rulesInAgentsMd()) return "rules 00-08 injected";
return renderInform(id, event, content, "rules 00-08 injected");
}
44 changes: 44 additions & 0 deletions src/runtime/lifecycle/kimi-rules-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Guard for the kimi notice-only rules injection. Kimi Code CLI has no
* model-only hook channel (`src/runtime/inform.ts`) — every `UserPromptSubmit`
* stdout is BOTH appended to the model's context AND rendered raw in the
* user's terminal. `injectRules` (`inject-rules.ts`) normally re-injects the
* full ~18 Ko rules corpus on every prompt; when the installer has already
* merged that same corpus into `<kimiHome>/AGENTS.md` — which Kimi loads
* natively at session start via its documented `${agents_md}` mechanism,
* independent of hooks — the re-injection is pure noise the user has to
* scroll past. Confirmed present, fenced, in the local install:
* `~/.kimi-code/AGENTS.md:87` / `:335`.
*
* Fail-safe semantics: fences absent, file absent, or any read error ⟹
* `false` — the caller then falls back to the full corpus. A dump is
* verbose but never wrong; a false `true` would silently drop the rules.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { harnessHome } from "../../config/home-dir";

/** Opening fence the fusengine installer writes around the merged corpus. */
const FENCE_START = "<!-- fusengine:kimi-rules:start -->";

/** Closing fence the fusengine installer writes around the merged corpus. */
const FENCE_END = "<!-- fusengine:kimi-rules:end -->";

/**
* Check whether the kimi rules corpus is already present, fenced, inside
* `<kimiHome>/AGENTS.md`.
* @param home - Kimi home dir override (defaults to `harnessHome("kimi")`,
* which honors `KIMI_CODE_HOME`).
* @returns `true` only when both fences are found in a readable file.
*/
export function rulesInAgentsMd(home?: string): boolean {
try {
const kimiHome = home ?? harnessHome("kimi");
const agentsMd = join(kimiHome, "AGENTS.md");
if (!existsSync(agentsMd)) return false;
const content = readFileSync(agentsMd, "utf-8");
return content.includes(FENCE_START) && content.includes(FENCE_END);
} catch {
return false;
}
}
34 changes: 34 additions & 0 deletions test/fixtures/inform-matrix.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"injectRules|claude-code|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|claude-code|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|claude-code|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|codex|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|codex|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|codex|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|gemini-cli|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|gemini-cli|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|gemini-cli|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}",
"injectRules|kimi|SessionStart|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected",
"injectRules|kimi|SubagentStart|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected",
"injectRules|kimi|UserPromptSubmit|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected",
"lessons|claude-code|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|claude-code|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|claude-code|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|codex|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|codex|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|codex|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|gemini-cli|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|gemini-cli|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|gemini-cli|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}",
"lessons|kimi|SessionStart|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected",
"lessons|kimi|SubagentStart|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected",
"lessons|kimi|UserPromptSubmit|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected",
"promptSubmit|claude-code|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.claude/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use TaskCreate to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .claude/apex/task.json to check documentation status before writing code.\\n\\n# CLAUDE.md\\n# fixture CLAUDE.md for .claude\\nBODY .claude\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}",
"promptSubmit|claude-code|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# CLAUDE.md\\n# fixture CLAUDE.md for .claude\\nBODY .claude\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}",
"promptSubmit|codex|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.codex/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use update_plan to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .codex/apex/task.json to check documentation status before writing code.\\n\\n# AGENTS.md\\n# fixture AGENTS.md for .codex\\nBODY .codex\\n\"},\"systemMessage\":\"AGENTS.md injected\"}",
"promptSubmit|codex|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# AGENTS.md\\n# fixture AGENTS.md for .codex\\nBODY .codex\\n\"},\"systemMessage\":\"AGENTS.md injected\"}",
"promptSubmit|gemini-cli|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.gemini/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use TaskCreate to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .gemini/apex/task.json to check documentation status before writing code.\\n\\n# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}",
"promptSubmit|gemini-cli|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}",
"promptSubmit|kimi|UserPromptSubmit|dev": "INSTRUCTION: This is a development task. Use APEX methodology:\n\n**TRACKING FILE**: [project]/.kimi-code/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\n\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\n - explore-codebase + research-expert + general-purpose (framework expertise)\n - Project type detected: generic\n\n2. **PLAN**: Use TodoList to break down tasks (<100 lines per file)\n\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\n\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\n\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\n\n6. **eXAMINE**: Run sniper agent after ANY modification\n\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\n\n**IMPORTANT**: Read .kimi-code/apex/task.json to check documentation status before writing code.\n\nAGENTS.md injected",
"promptSubmit|kimi|UserPromptSubmit|plain": "AGENTS.md injected"
}
23 changes: 23 additions & 0 deletions test/helpers/capture-inform-golden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @module test/helpers/capture-inform-golden
* Manual, one-shot script: run `bun run test/helpers/capture-inform-golden.ts`
* to (re)write `test/fixtures/inform-matrix.golden.json` from the current
* `src/` behavior.
*
* NEVER re-run this in the same commit as a behavior change to `renderInform`
* (or anything it calls transitively). The golden is the WITNESS that the
* pre-change code respected the zero-regression theorem's hypotheses — it
* must be captured and committed BEFORE the change, as its own commit.
* Regenerating it alongside the change makes the proof and the change
* indistinguishable on review: a diff that "updates the golden" could just as
* easily be hiding a real regression as reflecting an intentional new branch.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runMatrix } from "./inform-matrix-run";

const outDir = join(import.meta.dir, "..", "fixtures");
const outFile = join(outDir, "inform-matrix.golden.json");
mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, `${JSON.stringify(runMatrix(), null, 2)}\n`);
console.log(`wrote ${outFile}`);
Loading