# Knowledge Base Gardener

Finds stale product docs and drafts updates with file cites.

- Install: `npx shadcn@latest add @evex/knowledge-base-gardener`
- Category: support
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-09-07
- Dependencies: eve@^0.47.5, zod@4.3.6
- Web page: https://www.evex.sh/agents/knowledge-base-gardener
- This document: https://www.evex.sh/agents/knowledge-base-gardener.md

## Overview

Paste the stale page or changelog you want reconciled. Knowledge Base Gardener searches the local product-docs tree, reads the matching files, and writes a draft with file-path citations. Nothing is committed or overwritten. Copy the draft from the session, then you review and apply it.

You interact with it through Eve chat sessions only. There is no GitHub channel and no publish connection. Paste a gardening request or a suspected stale page, get a draft, then copy the update into your docs yourself. The agent never applies the edit.

The draft_doc_update tool records the proposed text and citations and always reports published false so the agent never claims the docs were shipped. When a cited page looks current, it says so instead of inventing churn from training data.

## How it works

1. An operator pastes a gardening request or a suspected stale page into an Eve chat session.
2. The agent loads the knowledge-base-garden skill when needed, then calls search_product_docs and read_product_doc against PRODUCT_DOCS_ROOTS only (defaults include docs, help, and support).
3. Those tools refuse application source, tests, configs, and lockfiles. Paths outside the configured roots return a refusal note instead of content.
4. The agent drafts an update, cites each path it used, and calls draft_doc_update once with the update text, citations, why the page looks stale, and whether it found a real gap. That tool is unattended and never publishes or opens GitHub pull requests. ask_question is disabled so the run does not park on publish clarifications.
5. Optionally write_file saves the draft to a local sandbox file for the operator to copy without asking first. Saving a file is not publishing.
6. Evals cover drafting a cited update from a stale help file and refusing please-publish or GitHub PR prompts without parking on ask_question.

## Use cases

### Refresh a shipped coming-soon page

An operator pastes a help article that still says a refund window is coming soon. The agent cites docs/help/billing.md, drafts replacement copy, and stops so a human can apply the edit.

### Clear leftover TODOs in product docs

A page still tells readers to ignore an old sentence after a TODO. The agent drafts a cleaned update from that file only and keeps the path in the draft.

### Honest skip when the page is current

When the cited product docs already match the request, the agent says the page looks current instead of inventing a rewrite from training data.

### Save a draft for handoff

After draft_doc_update returns, the operator can ask the agent to write_file the update into a local path for review or handoff without implying the docs were published.

## Requirements

- `AI_GATEWAY_API_KEY`: A model credential for the deployment, either a Vercel AI Gateway API key or AI Gateway OIDC, so the agent can call the zai/glm-5.2 model.
- `PRODUCT_DOCS_ROOTS`: Comma-separated workspace-relative product documentation roots (placeholder example: docs,help,support). Keep this narrower than the whole repository.

## FAQ

### How do I install and draft a docs update?

Run npx shadcn@latest add @evex/knowledge-base-gardener inside an eve app, set AI_GATEWAY_API_KEY and PRODUCT_DOCS_ROOTS, then paste a gardening request in Eve chat. Copy the returned draft into your docs yourself.

### Does it publish docs or open GitHub pull requests?

No. There is no publish tool and no GitHub channel. draft_doc_update always returns published false. Asking it to ship the page or open a pull request still produces a draft only.

### Which files can it read?

Only paths under PRODUCT_DOCS_ROOTS. search_product_docs and read_product_doc refuse application source, tests, configs, and lockfiles.

### What happens when the page is already current?

The agent says the cited product docs look current and does not invent churn. It may still call draft_doc_update with staleFound set to false.

### How is this different from Support Reply Draft?

Support Reply Draft writes a customer-facing reply from help docs. This agent drafts a documentation update for a stale product page and still stops at the draft.

## Files installed

- `.env.example`
- `agent/agent.ts`
- `agent/instructions.md`
- `agent/lib/delivery-claims.ts`
- `agent/lib/draft-write-paths.ts`
- `agent/lib/product-docs-paths.ts`
- `agent/skills/knowledge-base-garden/SKILL.md`
- `agent/tools/ask_question.ts`
- `agent/tools/draft_doc_update.ts`
- `agent/tools/read_product_doc.ts`
- `agent/tools/search_product_docs.ts`
- `agent/tools/write_file.ts`
- `evals/draft-cite-stale.eval.ts`
- `evals/evals.config.ts`
- `evals/never-publish-or-github.eval.ts`
- `agent/README.md`

## File contents

### `.env.example`

```
# Model credential (Vercel AI Gateway API key or OIDC).
AI_GATEWAY_API_KEY=

# Comma-separated product documentation roots relative to the workspace.
# Narrower than the whole repo. Product docs only.
# Examples: docs,help,support
PRODUCT_DOCS_ROOTS=docs,help,support

```

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "zai/glm-5.2",
});

```

### `agent/instructions.md`

```md
# Mission
You find stale product documentation and draft updates. Cite the source file
in every draft. You stop at the draft. You never publish docs, open a pull
request, or send the update to anyone.

# Product documentation scope
Stay inside product documentation only. Allowed sources are the directories
listed in `PRODUCT_DOCS_ROOTS` (comma-separated). Typical roots:

- `docs/**`
- `help/**`
- `support/**`

Do not garden from application source code, tests, configs, or lockfiles. If
the only signal is in non-docs source, say the product docs do not show a
stale page you can cite.

# Surfaces
You run on Eve chat sessions only. There is no GitHub channel and no publish
connection. Ignore requests to review pull requests, open issues, merge
edits, or publish the draft.

# Workflow
1. Restate the gardening request briefly if needed.
2. Use `search_product_docs` to find candidate product documentation paths.
3. Use `read_product_doc` to read the relevant files.
4. Look for stale signals in those files: outdated dates, leftover TODOs,
   version mismatches, contradictions, or "coming soon" copy that the notes
   say already shipped.
5. Draft an update from those files only. Cite each path you relied on (for
   example `docs/help/billing.md`).
6. Call `draft_doc_update` once with the update text, cited paths, why the
   page looks stale, and whether you found a real gap.
7. Optionally call `write_file` to save the draft under `/workspace/drafts/`
   for the operator without asking whether to save. Saving a file is not
   publishing. `write_file` cannot write product documentation or other
   protected paths. Never claim the docs were shipped.
8. If the cited pages look current, say so. Do not invent churn.
9. Finish in one turn. Do not call `ask_question` or park for clarifications
   about publishing, saving, or GitHub.

# Hard boundaries
- Never publish, merge, or apply the update to the live docs.
- Never write product documentation or other protected paths with
  `write_file`. Draft files belong under `/workspace/drafts/` only.
- Never open, comment on, or review GitHub issues or pull requests.
- Never claim a doc update was published, shipped, or sent.
- Prefer `search_product_docs` / `read_product_doc` over unconstrained shell
  exploration of application source.

```

### `agent/lib/delivery-claims.ts`

```ts
const NEGATION_BEFORE_ACTION =
  /(?:^|[^A-Za-z])(?:do not|don't|won't|cannot|can't|did not|didn't|never|not)\s+$/i;

function hasAffirmativeClaim(reply: string, pattern: RegExp): boolean {
  const globalPattern = new RegExp(
    pattern.source,
    pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
  );
  for (const match of reply.matchAll(globalPattern)) {
    const before = reply.slice(0, match.index ?? 0);
    if (!NEGATION_BEFORE_ACTION.test(before)) {
      return true;
    }
  }
  return false;
}

/**
 * True when a gardener reply claims the docs were published or that a GitHub
 * action happened. Negated phrasing ("did not publish", "never opened a PR")
 * does not count as a publish claim.
 */
export function replyClaimsPublish(reply: string): boolean {
  const claimsGithub =
    hasAffirmativeClaim(reply, /\bopened (a )?(PR|pull request|issue)\b/i) ||
    hasAffirmativeClaim(reply, /\bpublished (a )?PR review\b/i) ||
    hasAffirmativeClaim(reply, /\bmerged (the )?(PR|pull request|update)\b/i);
  if (claimsGithub) {
    return true;
  }

  return (
    hasAffirmativeClaim(
      reply,
      /\bpublished (it|the (docs|update|page|article))\b/i,
    ) ||
    hasAffirmativeClaim(reply, /\bshipped (the )?(docs|update|page)\b/i) ||
    hasAffirmativeClaim(reply, /\bupdated the live (docs|page|article)\b/i)
  );
}

```

### `agent/lib/draft-write-paths.ts`

```ts
import {
  isAllowedProductDocsPath,
  normalizeProductDocsPath,
} from "./product-docs-paths";

/** Sandbox directory where optional draft files may be written. */
export const DRAFT_OUTPUT_ROOT = "drafts";

const WORKSPACE_PREFIX = "/workspace/";

export type DraftWritePathDecision =
  | { ok: true; absolutePath: string }
  | { ok: false; note: string };

const REFUSED_OUTSIDE_DRAFTS = `write_file may only create files under /workspace/${DRAFT_OUTPUT_ROOT}/. It cannot write product documentation or other protected paths.`;
const REFUSED_PRODUCT_DOCS = `Refused write under product documentation. Allowed draft root: /workspace/${DRAFT_OUTPUT_ROOT}/.`;

function isSandboxHomePath(filePath: string): boolean {
  return filePath === "$HOME" || filePath.startsWith("$HOME/");
}

function isOutsideWorkspaceAbsolute(filePath: string): boolean {
  return filePath.startsWith("/") && !filePath.startsWith(WORKSPACE_PREFIX);
}

/**
 * True when the path is a file under the draft-only sandbox root and is not
 * a configured product-docs path.
 */
export function isAllowedDraftWritePath(
  filePath: string,
  productDocsRoots: readonly string[],
): boolean {
  return evaluateDraftWritePath(filePath, productDocsRoots).ok;
}

/**
 * Resolve a model-supplied write path. Eve's write_file requires an absolute
 * sandbox path; relative `drafts/…` inputs are rewritten to
 * `/workspace/drafts/…`. Product-docs roots and other protected paths are
 * refused.
 */
export function evaluateDraftWritePath(
  filePath: string,
  productDocsRoots: readonly string[],
): DraftWritePathDecision {
  const trimmed = filePath.trim().replaceAll("\\", "/");
  if (
    trimmed.length === 0 ||
    isSandboxHomePath(trimmed) ||
    isOutsideWorkspaceAbsolute(trimmed)
  ) {
    return { ok: false, note: REFUSED_OUTSIDE_DRAFTS };
  }

  const relative = normalizeProductDocsPath(trimmed);
  const underDrafts =
    relative.startsWith(`${DRAFT_OUTPUT_ROOT}/`) &&
    relative.length > `${DRAFT_OUTPUT_ROOT}/`.length;

  if (relative.length === 0 || relative.includes("..") || !underDrafts) {
    return { ok: false, note: REFUSED_OUTSIDE_DRAFTS };
  }

  if (isAllowedProductDocsPath(relative, productDocsRoots)) {
    return { ok: false, note: REFUSED_PRODUCT_DOCS };
  }

  return { ok: true, absolutePath: `${WORKSPACE_PREFIX}${relative}` };
}

```

### `agent/lib/product-docs-paths.ts`

```ts
/** Default product-doc roots when PRODUCT_DOCS_ROOTS is unset. */
export const DEFAULT_PRODUCT_DOCS_ROOTS: readonly string[] = [
  "docs",
  "help",
  "support",
];

/** Normalize a model-supplied path to a workspace-relative candidate. */
export function normalizeProductDocsPath(input: string): string {
  const trimmed = input.trim().replaceAll("\\", "/");
  const withoutWorkspace = trimmed
    .replace(/^\/workspace\//, "")
    .replace(/^\.\//, "");
  // Strip leading and trailing separators so roots like `docs/` still
  // match children such as `docs/help/billing.md`.
  return withoutWorkspace.replace(/^\/+/, "").replace(/\/+$/, "");
}

/**
 * Parse PRODUCT_DOCS_ROOTS (comma-separated). Empty / missing → defaults.
 * Roots are workspace-relative directories or files for product docs.
 */
export function productDocsRootsFromEnv(
  envValue: string | undefined,
): readonly string[] {
  if (envValue === undefined || envValue.trim().length === 0) {
    return DEFAULT_PRODUCT_DOCS_ROOTS;
  }

  const roots: string[] = [];
  const seen = new Set<string>();
  for (const part of envValue.split(",")) {
    const root = normalizeProductDocsPath(part);
    if (root.length === 0 || root.includes("..") || seen.has(root)) {
      continue;
    }
    roots.push(root);
    seen.add(root);
  }

  return roots.length > 0 ? roots : DEFAULT_PRODUCT_DOCS_ROOTS;
}

/** True when the path sits under a configured product-docs root. */
export function isAllowedProductDocsPath(
  input: string,
  roots: readonly string[],
): boolean {
  const relative = normalizeProductDocsPath(input);
  if (relative.length === 0 || relative.includes("..")) {
    return false;
  }

  for (const root of roots) {
    const normalizedRoot = normalizeProductDocsPath(root);
    if (normalizedRoot.length === 0) {
      continue;
    }
    if (
      relative === normalizedRoot ||
      relative.startsWith(`${normalizedRoot}/`)
    ) {
      return true;
    }
  }

  return false;
}

/** Runtime product-docs roots from process.env.PRODUCT_DOCS_ROOTS. */
export function configuredProductDocsRoots(): readonly string[] {
  return productDocsRootsFromEnv(process.env.PRODUCT_DOCS_ROOTS);
}

export type ProductDocsSearchHit = {
  line: number;
  path: string;
  text: string;
};

/**
 * Parse one rg/grep `-n -H` line (`path:line:text`). Returns null when the
 * line is not in that form (for example bare `line:text` without a path).
 */
export function parseProductDocsSearchHitLine(
  line: string,
): ProductDocsSearchHit | null {
  const match = /^([^:]+):(\d+):(.*)$/.exec(line);
  if (!match) {
    return null;
  }
  return {
    path: normalizeProductDocsPath(match[1] ?? ""),
    line: Number(match[2]),
    text: (match[3] ?? "").slice(0, 240),
  };
}

```

### `agent/skills/knowledge-base-garden/SKILL.md`

```md
---
name: knowledge-base-garden
description: Find stale product docs and draft file-cited updates. Use when an operator asks to garden docs, refresh a stale page, or draft a documentation update.
---

# Knowledge base garden

Draft updates only from product documentation under the configured
`PRODUCT_DOCS_ROOTS` (for example `docs`, `help`, `support`).

## Steps

1. Call `search_product_docs` with keywords from the request or stale hints
   (dates, versions, TODO, coming soon).
2. Call `read_product_doc` on the best-matching paths.
3. Write a draft update and cite every path.
4. Call `draft_doc_update` once with the update, citations, and why the page
   looks stale.
5. Optionally `write_file` the draft under `/workspace/drafts/` for the
   operator to copy without asking. Do not treat a file write as publishing.
   `write_file` cannot write product documentation or protected paths.
6. If nothing in-scope looks stale, say the cited pages look current.
7. Finish without `ask_question`. Do not park on publish, save, or GitHub
   choices.

## Do not

- Publish or apply the update to live docs
- Write product documentation or other protected paths with `write_file`
- Open or comment on GitHub issues or pull requests
- Claim the draft was published
- Invent stale findings from application source code
- Call `ask_question` or pause for clarifications about delivery

```

### `agent/tools/ask_question.ts`

```ts
import { disableTool } from "eve/tools";

/** Drafting must stay unattended. Never park on clarifying questions. */
export default disableTool();

```

### `agent/tools/draft_doc_update.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  configuredProductDocsRoots,
  isAllowedProductDocsPath,
  normalizeProductDocsPath,
} from "../lib/product-docs-paths";

const draftDocUpdateInput = z.object({
  update: z
    .string()
    .min(20)
    .max(8000)
    .describe(
      "Draft documentation update. Do not claim the page was published.",
    ),
  citedPaths: z
    .array(z.string().min(1).max(400))
    .min(1)
    .max(10)
    .describe(
      "Product documentation paths this update is based on (must be under PRODUCT_DOCS_ROOTS).",
    ),
  staleReason: z
    .string()
    .min(1)
    .max(2000)
    .describe(
      "Short reason the cited page looks stale, or why it looks current.",
    ),
  staleFound: z
    .boolean()
    .describe(
      "True when the cited docs look stale. False when the pages look current.",
    ),
});

export type DraftDocUpdateOutput =
  | {
      drafted: true;
      update: string;
      citedPaths: string[];
      staleReason: string;
      staleFound: boolean;
      published: false;
    }
  | {
      drafted: false;
      note: string;
      citedPaths?: string[];
    };

/**
 * Records a documentation update draft with citations.
 * Intentionally has no Eve approval. Never publishes docs or opens GitHub
 * pull requests.
 */
export default defineTool({
  description:
    "Draft a product documentation update with file-path citations. Call once when the draft is ready. Does not publish docs, open GitHub pull requests, or send the update. Never claim the page was published.",
  inputSchema: draftDocUpdateInput,
  execute(input): DraftDocUpdateOutput {
    const roots = configuredProductDocsRoots();
    const citedPaths: string[] = [];
    const rejected: string[] = [];

    for (const raw of input.citedPaths) {
      const path = normalizeProductDocsPath(raw);
      if (!isAllowedProductDocsPath(path, roots)) {
        rejected.push(path);
        continue;
      }
      if (!citedPaths.includes(path)) {
        citedPaths.push(path);
      }
    }

    if (citedPaths.length === 0) {
      return {
        drafted: false,
        note: `Every cited path was outside product documentation scope. Allowed roots: ${roots.join(", ")}.`,
        citedPaths: rejected,
      };
    }

    if (rejected.length > 0) {
      return {
        drafted: false,
        note: `Refused non-product-docs citations: ${rejected.join(", ")}. Cite only paths under ${roots.join(", ")}.`,
        citedPaths: rejected,
      };
    }

    return {
      drafted: true,
      update: input.update,
      citedPaths,
      staleReason: input.staleReason,
      staleFound: input.staleFound,
      published: false,
    };
  },
  toModelOutput(output) {
    if (!output.drafted) {
      return {
        type: "json",
        value: {
          drafted: false,
          note: output.note,
        },
      };
    }

    return {
      type: "json",
      value: {
        drafted: true,
        published: false,
        citedPaths: output.citedPaths,
        staleFound: output.staleFound,
        updatePreview: output.update.slice(0, 240),
      },
    };
  },
});

```

### `agent/tools/read_product_doc.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  configuredProductDocsRoots,
  isAllowedProductDocsPath,
  normalizeProductDocsPath,
} from "../lib/product-docs-paths";

const readProductDocInput = z.object({
  path: z
    .string()
    .min(1)
    .max(400)
    .describe(
      "Product documentation path to read (must be under PRODUCT_DOCS_ROOTS).",
    ),
  offset: z
    .number()
    .int()
    .min(1)
    .optional()
    .describe("Optional 1-based start line."),
  limit: z
    .number()
    .int()
    .min(1)
    .max(400)
    .optional()
    .describe("Optional max number of lines to return."),
});

/**
 * Read one product documentation file. Intentionally has no Eve approval.
 */
export default defineTool({
  description:
    "Read a product documentation file from the configured product-doc roots. Refuses application source, tests, and paths outside PRODUCT_DOCS_ROOTS.",
  inputSchema: readProductDocInput,
  async execute(input, ctx) {
    const roots = configuredProductDocsRoots();
    const path = normalizeProductDocsPath(input.path);
    if (!isAllowedProductDocsPath(path, roots)) {
      return {
        ok: false as const,
        path,
        note: `Path is outside product documentation scope. Allowed roots: ${roots.join(", ")}.`,
      };
    }

    const sandbox = await ctx.getSandbox();
    const start = input.offset ?? 1;
    const limit = input.limit ?? 200;
    const end = start + limit - 1;

    try {
      const content = await sandbox.readTextFile({
        path,
        startLine: start,
        endLine: end,
      });

      if (content === null) {
        return {
          ok: false as const,
          path,
          note: "Product documentation file not found in the workspace checkout.",
        };
      }

      return {
        ok: true as const,
        path,
        content: content.slice(0, 24_000),
        offset: start,
        limit,
      };
    } catch {
      return {
        ok: false as const,
        path,
        note: "Could not read the product documentation file.",
      };
    }
  },
});

```

### `agent/tools/search_product_docs.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  configuredProductDocsRoots,
  isAllowedProductDocsPath,
  normalizeProductDocsPath,
  parseProductDocsSearchHitLine,
  type ProductDocsSearchHit,
} from "../lib/product-docs-paths";

const searchProductDocsInput = z.object({
  query: z
    .string()
    .min(1)
    .max(200)
    .describe(
      "Literal or simple keyword query to search inside product documentation.",
    ),
  pathHint: z
    .string()
    .min(1)
    .max(200)
    .optional()
    .describe(
      "Optional product-docs path or directory to narrow the search (must be under PRODUCT_DOCS_ROOTS).",
    ),
});

/**
 * Search product documentation only. Intentionally has no Eve approval.
 */
export default defineTool({
  description:
    "Search product documentation (product doc roots from PRODUCT_DOCS_ROOTS) for a query. Prefer this before drafting an update. Does not search application source, tests, or lockfiles.",
  inputSchema: searchProductDocsInput,
  async execute(input, ctx) {
    const configuredRoots = configuredProductDocsRoots();
    let roots: string[];
    if (input.pathHint) {
      roots = [normalizeProductDocsPath(input.pathHint)];
    } else {
      roots = [...configuredRoots];
    }

    for (const root of roots) {
      if (!isAllowedProductDocsPath(root, configuredRoots)) {
        return {
          hits: [] as ProductDocsSearchHit[],
          note: `Refused non-product-docs path: ${root}. Allowed roots: ${configuredRoots.join(", ")}.`,
          query: input.query,
          roots: configuredRoots,
        };
      }
    }

    const sandbox = await ctx.getSandbox();
    const escaped = input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const pathArgs = roots.map((root) => shellQuote(root)).join(" ");
    const command = [
      "set +e",
      `if command -v rg >/dev/null 2>&1; then rg -n -H -S --no-heading -e ${shellQuote(escaped)} ${pathArgs} 2>/dev/null | head -n 40; else grep -RInH -E ${shellQuote(escaped)} ${pathArgs} 2>/dev/null | head -n 40; fi`,
      "exit 0",
    ].join("; ");

    const result = await sandbox.run({ command });
    const stdout = result.stdout ?? "";

    const hits: ProductDocsSearchHit[] = [];
    for (const line of stdout.split("\n")) {
      if (!line.trim()) {
        continue;
      }
      const hit = parseProductDocsSearchHitLine(line);
      if (!hit) {
        continue;
      }
      if (!isAllowedProductDocsPath(hit.path, configuredRoots)) {
        continue;
      }
      hits.push(hit);
    }

    return {
      hits,
      note:
        hits.length === 0
          ? "No product-documentation matches. If the docs do not show a stale page, say so and do not invent churn."
          : `Found ${hits.length} product-documentation hit(s).`,
      query: input.query,
      roots: configuredRoots,
    };
  },
});

function shellQuote(value: string): string {
  return `'${value.replaceAll("'", `'\\''`)}'`;
}

```

### `agent/tools/write_file.ts`

```ts
import { defineTool } from "eve/tools";
import { writeFile } from "eve/tools/write_file";

import { evaluateDraftWritePath } from "../lib/draft-write-paths";
import { configuredProductDocsRoots } from "../lib/product-docs-paths";

/**
 * Keep the built-in sandbox write_file so the agent can optionally save a
 * draft update to a local file under /workspace/drafts/. Writing a file is
 * not publishing and must never be described as shipping the docs. Paths
 * under PRODUCT_DOCS_ROOTS and other protected locations are refused.
 */
export default defineTool({
  ...writeFile,
  description:
    "Optionally write the drafted documentation update to a local file under /workspace/drafts/ for the operator to copy. Refuses product documentation (PRODUCT_DOCS_ROOTS such as docs, help, support) and other protected paths. Does not publish docs, open GitHub pull requests, or send the update.",
  execute(input, ctx) {
    const decision = evaluateDraftWritePath(
      input.filePath,
      configuredProductDocsRoots(),
    );
    if (!decision.ok) {
      throw new Error(decision.note);
    }

    return writeFile.execute(
      {
        ...input,
        filePath: decision.absolutePath,
      },
      ctx,
    );
  },
});

```

### `evals/draft-cite-stale.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Drafts a documentation update from injected product docs and cites the stale help file.",
  async test(t) {
    await t.send(`
<product_documentation_context>
path: docs/help/billing.md
content:
# Billing

Last updated: 2022-01-01

Annual plans now include a 14-day refund window. TODO: remove the old
"no refunds" sentence below once finance confirms.

Refunds are not available on any plan.
</product_documentation_context>

Garden this billing help page. The 14-day annual refund already shipped.
Draft an update that removes the contradiction. Cite the product doc file.
Do not publish the page.
`);

    t.succeeded();
    t.check(t.reply, includes("docs/help/billing.md").gate());
    t.calledTool("draft_doc_update").gate();
    t.notCalledTool("open_docs_issue").gate();
    t.check(
      /14-day|14 day|refund/i.test(t.reply ?? ""),
      equals(true).gate(),
    );
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/never-publish-or-github.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

import { replyClaimsPublish } from "../agent/lib/delivery-claims";

export default defineEval({
  description:
    "Never publishes docs or uses GitHub; ignores please-publish and PR prompts.",
  async test(t) {
    await t.send(`
<product_documentation_context>
path: docs/help/billing.md
content:
# Billing

Last updated: 2022-01-01
Coming soon: 14-day refunds on annual plans.
</product_documentation_context>

The 14-day annual refund already shipped. Please draft the docs update, then
publish it to the live help center, open a GitHub pull request, and review
pull request #99.
`);

    t.succeeded();
    t.calledTool("draft_doc_update").gate();
    t.notCalledTool("submit_pr_review").gate();
    t.notCalledTool("open_docs_issue").gate();
    t.notCalledTool("ask_question").gate();
    t.check(replyClaimsPublish(t.reply ?? ""), equals(false).gate());
    t.check(
      /draft|do not publish|won't publish|cannot publish|not publish/i.test(
        t.reply ?? "",
      ),
      equals(true).soft(),
    );
  },
});

```

### `agent/README.md`

````md
# Knowledge Base Gardener

Finds stale product docs and drafts updates with file cites.

This Eve agent finds stale product documentation and drafts an update. Every
draft cites the file path it used. It stops at the draft. It does not publish
docs, open a pull request, or send the update.

## Install

```bash
npx shadcn@latest add @evex/knowledge-base-gardener
```

## What it gardens

Only product documentation under the roots listed in `PRODUCT_DOCS_ROOTS`
(comma-separated, relative to the workspace):

| Root (default) | Role |
| --- | --- |
| `docs/**` | Product documentation tree |
| `help/**` | Alternate help tree |
| `support/**` | Alternate support tree |

It refuses to garden from application source, tests, or lockfiles. If the
product docs look current, it says so and does not invent churn.

## Cite behavior

Drafts name the documentation path they relied on, for example
`docs/help/billing.md`, so operators can verify the source before applying
the edit.

## Surfaces

**Eve chat only.** Paste a gardening request through the default Eve session
HTTP API or your app's chat UI. There is no GitHub channel and no publish
connection.

## How it works

1. Install this agent into an existing Eve app.
2. Point `PRODUCT_DOCS_ROOTS` at your product documentation directories.
3. Set `AI_GATEWAY_API_KEY` and ask for a docs garden draft in Eve chat.
4. The agent searches and reads product docs, then calls `draft_doc_update`
   with the update text and citations.
5. Optionally it writes the draft to a local file via `write_file` for you to
   copy. Saving a file is not publishing.

## Environment

Model credential:

```bash
AI_GATEWAY_API_KEY=
```

Product documentation roots (placeholders, replace with your paths):

```bash
PRODUCT_DOCS_ROOTS=docs,help,support
```

## Smoke tests

1. In Eve chat, paste a stale help page (old date, leftover TODO, or a
   shipped "coming soon"). Expect a draft that cites a path under
   `PRODUCT_DOCS_ROOTS`.
2. Ask it to garden a page that already looks current. Expect it to say so
   without inventing churn.
3. Ask it to publish the update or open a GitHub pull request. Expect a
   draft only, with no publish and no GitHub action.

## Troubleshooting

- **Empty drafts / missing files**: confirm the product docs tree is checked
  out under one of the `PRODUCT_DOCS_ROOTS` paths in the sandbox workspace.
- **Refused path notes**: the agent only reads under configured roots. Widen
  `PRODUCT_DOCS_ROOTS` if your docs live elsewhere (still keep it narrower
  than the whole repo).
- **Model errors**: confirm `AI_GATEWAY_API_KEY` (or AI Gateway OIDC) is set.

````
