diff --git a/src/runtime/inject-context.ts b/src/runtime/inject-context.ts index f78d0d6..4709579 100644 --- a/src/runtime/inject-context.ts +++ b/src/runtime/inject-context.ts @@ -22,6 +22,23 @@ 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 `# \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. @@ -29,6 +46,13 @@ export function claudeMdKey(prompt: string, ctx: string): string { * 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 `/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). @@ -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); } /** diff --git a/src/runtime/lifecycle/inject-rules.ts b/src/runtime/lifecycle/inject-rules.ts index da6af27..9f3d882 100644 --- a/src/runtime/lifecycle/inject-rules.ts +++ b/src/runtime/lifecycle/inject-rules.ts @@ -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 { @@ -29,6 +30,13 @@ 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 `/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). @@ -36,5 +44,7 @@ export function readRules(rulesDir: string): string { */ 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"); } diff --git a/src/runtime/lifecycle/kimi-rules-native.ts b/src/runtime/lifecycle/kimi-rules-native.ts new file mode 100644 index 0000000..7844717 --- /dev/null +++ b/src/runtime/lifecycle/kimi-rules-native.ts @@ -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 `/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 = ""; + +/** Closing fence the fusengine installer writes around the merged corpus. */ +const FENCE_END = ""; + +/** + * Check whether the kimi rules corpus is already present, fenced, inside + * `/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; + } +} diff --git a/test/fixtures/inform-matrix.golden.json b/test/fixtures/inform-matrix.golden.json new file mode 100644 index 0000000..82d3bda --- /dev/null +++ b/test/fixtures/inform-matrix.golden.json @@ -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" +} diff --git a/test/helpers/capture-inform-golden.ts b/test/helpers/capture-inform-golden.ts new file mode 100644 index 0000000..2bfb17e --- /dev/null +++ b/test/helpers/capture-inform-golden.ts @@ -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}`); diff --git a/test/helpers/capture-run.ts b/test/helpers/capture-run.ts new file mode 100644 index 0000000..98b0776 --- /dev/null +++ b/test/helpers/capture-run.ts @@ -0,0 +1,19 @@ +/** + * @module test/helpers/capture-run + * Entry point of the CAPTURE CHILD PROCESS. Runs isolated from the parent + * because Bun snapshots `$HOME` at process start — mutating + * `process.env.HOME` in the parent process would NOT change what + * `os.homedir()` returns there (measured on Bun 1.3.14). Spawning a fresh + * process with `HOME` set in its own env is the only way to pin it. + * Reads the fixture root from `MATRIX_ROOT` (never `process.argv`, since the + * parent passes it via the child's env allowlist — see `childEnv`). + */ +import { pathsFor } from "./inform-matrix-env"; +import { captureMatrix } from "./inform-matrix"; + +const root = process.env.MATRIX_ROOT; +if (!root) { + console.error("capture-run: MATRIX_ROOT env var is required"); + process.exit(2); +} +process.stdout.write(JSON.stringify(captureMatrix(pathsFor(root)))); diff --git a/test/helpers/inform-matrix-env.ts b/test/helpers/inform-matrix-env.ts new file mode 100644 index 0000000..f70f03e --- /dev/null +++ b/test/helpers/inform-matrix-env.ts @@ -0,0 +1,110 @@ +/** + * @module test/helpers/inform-matrix-env + * Hermetic fixture tree for the zero-regression golden capture. + * + * The emitters under capture read AMBIENT state: `$HOME` (root instructions doc, + * the marketplace scan, the dedup sidecar), the project dir (`MEMORY/LESSON.md`, + * project-type detection), `process.env` (harness detection, SOLID ceiling), and + * a clock. Left ambient, the golden would encode THIS machine on THIS day and + * prove nothing. This module builds the fixtures; the capture itself runs in a + * CHILD process (see `inform-matrix-run`) because Bun snapshots `$HOME` at + * process start — `os.homedir()` ignores a later `process.env.HOME` mutation + * (measured on Bun 1.3.14), so in-process pinning silently reads the real home. + */ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Pinned clock for every emitter that takes one (lessons curation). */ +export const NOW = 1_700_000_000_000; + +/** + * Home segment → root instructions doc, mirroring `harnessHomeSegment` / + * `apexDocName`. Bodies differ per segment on purpose: `claude-code` and + * `gemini-cli` both read `CLAUDE.md`, and identical bodies would collide on the + * `oncePerWindow` content hash — blanking one cell of the golden in silence. + */ +const HARNESS_DOCS: ReadonlyArray = [ + [".claude", "CLAUDE.md"], + [".codex", "AGENTS.md"], + [".gemini", "CLAUDE.md"], + [".kimi-code", "AGENTS.md"], +]; + +/** Fixture lesson: one dated bullet, stable under curation at {@link NOW}. */ +const LESSON = "- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\n"; + +/** + * `package.json` marker. NOT cosmetic: `projectRoot()` walks up for `.git` then + * `package.json` and falls back to `process.cwd()` — without this marker the + * lessons emitter would read AND REWRITE this repo's own `MEMORY/LESSON.md`. + * Its content must match no framework probe, so detection stays "generic". + */ +const PKG = '{"name":"golden-fixture","version":"0.0.0"}\n'; + +/** Absolute paths of one fixture tree, shared by the parent and the capture child. */ +export interface MatrixPaths { + /** Temp root holding `home/`, `plugin/` and the project dirs. */ + root: string; + /** `$HOME` of the capture child. */ + home: string; + /** Plugin root passed to `injectRules` (holds `rules/*.md`). */ + pluginRoot: string; + /** Stable project dir, for the emitters that only READ it. */ + project: string; +} + +/** Derive every fixture path from the root, so parent and child never disagree. */ +export function pathsFor(root: string): MatrixPaths { + return { + root, + home: join(root, "home"), + pluginRoot: join(root, "plugin"), + project: join(root, "project-stable"), + }; +} + +/** + * Create a project dir carrying the markers `projectRoot` + detection need. + * Lessons curation REWRITES `LESSON.md`, so each lessons cell gets its OWN dir — + * a shared one would make cell N depend on cell N-1. + * @param root - Fixture root. + * @param tag - Unique suffix for this project dir. + * @returns The created project dir. + */ +export function makeProject(root: string, tag: string): string { + const dir = join(root, `project-${tag}`); + mkdirSync(join(dir, "MEMORY"), { recursive: true }); + writeFileSync(join(dir, "package.json"), PKG); + writeFileSync(join(dir, "MEMORY", "LESSON.md"), LESSON); + return dir; +} + +/** + * Build the fixture tree under a fresh temp root. + * @returns The fixture paths. + */ +export function buildFixtures(): MatrixPaths { + const paths = pathsFor(mkdtempSync(join(tmpdir(), "inform-golden-"))); + for (const [seg, doc] of HARNESS_DOCS) { + mkdirSync(join(paths.home, seg), { recursive: true }); + writeFileSync(join(paths.home, seg, doc), `# fixture ${doc} for ${seg}\nBODY ${seg}\n`); + } + mkdirSync(join(paths.pluginRoot, "rules"), { recursive: true }); + writeFileSync(join(paths.pluginRoot, "rules", "00-alpha.md"), "# 00 alpha\nalpha body\n"); + writeFileSync(join(paths.pluginRoot, "rules", "01-beta.md"), "# 01 beta\nbeta body\n"); + makeProject(paths.root, "stable"); + return paths; +} + +/** + * Environment of the capture child — an ALLOWLIST, never an inherit. Inheriting + * would leak `CLAUDECODE` (or any of the 15 other harness markers), making + * `detectHarness()` answer "claude-code" here and "unknown" in CI, plus every + * ambient `FUSE_*` that moves the SOLID ceiling. + * @param paths - The fixture tree. + * @returns The exact env handed to the child. + */ +export function childEnv(paths: MatrixPaths): Record { + return { PATH: process.env.PATH ?? "", HOME: paths.home, MATRIX_ROOT: paths.root }; +} diff --git a/test/helpers/inform-matrix-run.ts b/test/helpers/inform-matrix-run.ts new file mode 100644 index 0000000..f4fb0ed --- /dev/null +++ b/test/helpers/inform-matrix-run.ts @@ -0,0 +1,33 @@ +/** + * @module test/helpers/inform-matrix-run + * Parent-side driver: builds the fixtures, spawns the capture child + * (`capture-run.ts`) with an allowlisted env, parses its stdout, validates + * portability, and cleans up. Never mutates the PARENT process's own + * `process.env` — `bun test` runs every file sequentially in one shared + * process, so a parent-side mutation would leak into the other ~180 test + * files in the suite. + */ +import { spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { buildFixtures, childEnv } from "./inform-matrix-env"; +import { assertPortable } from "./inform-matrix"; + +/** + * Run the full capture in an isolated child process and return the golden. + * @returns The 32-cell golden map. + */ +export function runMatrix(): Record { + const paths = buildFixtures(); + const r = spawnSync("bun", ["run", join(import.meta.dir, "capture-run.ts")], { + env: childEnv(paths), + encoding: "utf8", + }); + if (r.status !== 0) { + throw new Error(`capture-run.ts exited ${String(r.status)}: ${r.stderr}`); + } + const cells = JSON.parse(r.stdout) as Record; + assertPortable(cells, paths.root); + rmSync(paths.root, { recursive: true, force: true }); + return cells; +} diff --git a/test/helpers/inform-matrix.ts b/test/helpers/inform-matrix.ts new file mode 100644 index 0000000..698fca3 --- /dev/null +++ b/test/helpers/inform-matrix.ts @@ -0,0 +1,70 @@ +/** + * @module test/helpers/inform-matrix + * Builds the full 32-cell golden matrix: (id × event × emitter) triples, + * captured DETERMINISTICALLY inside the hermetic fixture tree from + * `inform-matrix-env`. Runs inside the capture child (see `capture-run`) so + * every ambient read resolves against the fixtures, never the real machine. + */ +import { injectRules } from "../../src/runtime/lifecycle/inject-rules"; +import { promptSubmitContext } from "../../src/runtime/inject-context"; +import { dispatchLessons } from "../../src/runtime/lifecycle/lessons/dispatch"; +import { NOW, makeProject } from "./inform-matrix-env"; +import type { MatrixPaths } from "./inform-matrix-env"; + +/** Harness targets covered by the matrix. */ +export const IDS = ["claude-code", "codex", "gemini-cli", "kimi"] as const; + +/** Hook events covered by the matrix. */ +export const EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"] as const; + +/** + * Prompts for `promptSubmitContext`: "dev" matches both `DEV_VERBS` + * (`src/policy/claude-md-context.ts`) and `DEV_KEYWORDS` + * (`src/policy/detect-project.ts`), so it exercises the APEX preamble path. + * "plain" matches NEITHER regex (verified against both patterns before + * writing this fixture) — it exercises the plain CLAUDE.md-only path. + */ +const PROMPTS = [ + ["dev", "implement the parser"], + ["plain", "quelle heure est-il"], +] as const; + +/** + * Capture every (id × event) cell for the three emitters under test, plus the + * two prompt-kind variants of `promptSubmitContext`. Iteration is purely + * synchronous — order comes from the loop structure, never from scheduling — + * and the final map is key-sorted so the golden JSON is diffable line by line + * regardless of iteration order. + * @param paths - The hermetic fixture tree (see {@link buildFixtures}). + * @returns The 32-cell golden map, keyed `emitter|id|event|extra`. + */ +export function captureMatrix(paths: MatrixPaths): Record { + const cells: Array<[string, string]> = []; + for (const id of IDS) { + for (const event of EVENTS) { + cells.push([`injectRules|${id}|${event}|-`, injectRules(paths.pluginRoot, event, id)]); + const lessonsDir = makeProject(paths.root, `${id}-${event}`); + cells.push([`lessons|${id}|${event}|-`, dispatchLessons(event, {}, lessonsDir, NOW, id)]); + if (event === "UserPromptSubmit") { + for (const [kind, prompt] of PROMPTS) { + cells.push([`promptSubmit|${id}|UserPromptSubmit|${kind}`, promptSubmitContext(prompt, paths.project, id)]); + } + } + } + } + cells.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return Object.fromEntries(cells); +} + +/** + * Guard against a non-portable golden: a captured value that embeds the + * fixture's own temp root would tie the golden to this run's random path and + * break on every future capture. + * @param cells - The captured golden map. + * @param root - The fixture root whose path must never leak into a value. + */ +export function assertPortable(cells: Record, root: string): void { + for (const [key, value] of Object.entries(cells)) { + if (value.includes(root)) throw new Error(`non-portable golden cell "${key}" embeds fixture root ${root}`); + } +} diff --git a/test/inform-matrix.characterization.test.ts b/test/inform-matrix.characterization.test.ts new file mode 100644 index 0000000..ed00d8e --- /dev/null +++ b/test/inform-matrix.characterization.test.ts @@ -0,0 +1,25 @@ +/** + * @module test/inform-matrix.characterization + * Zero-regression golden for `renderInform` and its three call sites + * (`injectRules`, `dispatchLessons`, `promptSubmitContext`) across every + * (harness id × hook event) combination. + * + * A diff on ANY cell other than `kimi × UserPromptSubmit` IS the regression: + * the theorem this golden witnesses is `f'(x) = g(x) if P(x), f(x) otherwise` + * ⟹ `∀x. ¬P(x) ⟹ f'(x) = f(x)` — every non-kimi/non-UserPromptSubmit cell is + * an `x` where `¬P(x)` holds, so it MUST stay byte-identical. The golden is + * never updated to make new code pass; see `capture-inform-golden.ts` for why. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { runMatrix } from "./helpers/inform-matrix-run"; + +const goldenPath = join(import.meta.dir, "fixtures", "inform-matrix.golden.json"); + +describe("renderInform zero-regression matrix", () => { + test("every (id x event x emitter) cell matches the captured golden", () => { + const golden = JSON.parse(readFileSync(goldenPath, "utf8")) as Record; + expect(runMatrix()).toEqual(golden); + }); +}); diff --git a/test/kimi-notice-only.test.ts b/test/kimi-notice-only.test.ts new file mode 100644 index 0000000..aee4802 --- /dev/null +++ b/test/kimi-notice-only.test.ts @@ -0,0 +1,92 @@ +/** + * @module test/kimi-notice-only + * Dedicated unit tests for the kimi notice-only injection (theorem + * `f'(x) = g(x) if P(x), f(x) otherwise`, `P(x) ≡ id="kimi" ∧ + * event="UserPromptSubmit"`). The golden matrix + * (`inform-matrix.characterization.test.ts`) proves the fail-safe default + * (fences absent in its fixture); these tests exercise the fenced-present + * branch directly, plus the SessionStart/SubagentStart/claude-code + * non-regression cases. + */ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rulesInAgentsMd } from "../src/runtime/lifecycle/kimi-rules-native"; +import { injectRules } from "../src/runtime/lifecycle/inject-rules"; + +const FENCED = "\nbody\n\n"; + +/** Build a fixture kimi home dir, optionally with a fenced/unfenced AGENTS.md. */ +function kimiHome(root: string, agentsMd?: string): string { + const home = mkdtempSync(join(root, "kimi-home-")); + if (agentsMd !== undefined) writeFileSync(join(home, "AGENTS.md"), agentsMd); + return home; +} + +/** Fixture plugin root carrying one rules file, for `injectRules`. */ +function pluginRoot(root: string): string { + const dir = mkdtempSync(join(root, "plugin-")); + mkdirSync(join(dir, "rules"), { recursive: true }); + writeFileSync(join(dir, "rules", "00.md"), "# rules\nbody\n"); + return dir; +} + +describe("rulesInAgentsMd", () => { + const root = mkdtempSync(join(tmpdir(), "kimi-notice-only-")); + + test("fences present -> true", () => { + expect(rulesInAgentsMd(kimiHome(root, FENCED))).toBe(true); + }); + + test("fences absent -> false", () => { + expect(rulesInAgentsMd(kimiHome(root, "# AGENTS.md\nplain body\n"))).toBe(false); + }); + + test("AGENTS.md absent -> false", () => { + expect(rulesInAgentsMd(kimiHome(root))).toBe(false); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); +}); + +describe("injectRules: kimi notice-only gate", () => { + const root = mkdtempSync(join(tmpdir(), "kimi-notice-only-inject-")); + const prevKimiHome = process.env.KIMI_CODE_HOME; + + afterEach(() => { + if (prevKimiHome === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = prevKimiHome; + }); + + test("kimi UserPromptSubmit, fences present -> notice only", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "UserPromptSubmit", "kimi")).toBe("rules 00-08 injected"); + }); + + test("kimi UserPromptSubmit, fences absent -> full corpus", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, "# AGENTS.md\nplain\n"); + const out = injectRules(pluginRoot(root), "UserPromptSubmit", "kimi"); + expect(out).toContain("# rules\nbody"); + expect(out).toContain("rules 00-08 injected"); + }); + + test("kimi SessionStart, fences present -> full corpus (never gated)", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "SessionStart", "kimi")).toContain("# rules\nbody"); + }); + + test("kimi SubagentStart, fences present -> full corpus (never gated)", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "SubagentStart", "kimi")).toContain("# rules\nbody"); + }); + + test("claude-code UserPromptSubmit is unaffected by the kimi gate", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + const out = injectRules(pluginRoot(root), "UserPromptSubmit", "claude-code"); + expect(out).toContain("additionalContext"); + expect(out).toContain("# rules\\nbody"); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); +});