{"id":"knowledge-promoter","title":"Knowledge Promoter Agent","description":"Turns curated private notes into public documentation PRs — classify, sanitize to vendor-neutral, adversarial leak-gate, then open one draft PR. Never merges; a human reviews.","category":"ops","version":"1.0.0","source":"agentskit-registry","license":"MIT","tags":["documentation","knowledge","okf","pipeline","leak-prevention","human-in-the-loop"],"packages":["@agentskit/core","@agentskit/runtime","@agentskit/tools"],"requires":{"zod":"^3","zod-to-json-schema":"^3"},"env":[{"name":"ANTHROPIC_API_KEY","description":"Or any provider key your adapter needs.","required":false}],"files":["agent.ts","skills.ts","README.md"],"status":"validated","skill":null,"flow":null,"a2a":{"id":"io.agentskit.registry.knowledge-promoter","name":"Knowledge Promoter Agent","description":"Turns curated private notes into public documentation PRs — classify, sanitize to vendor-neutral, adversarial leak-gate, then open one draft PR. Never merges; a human reviews.","version":"1.0.0","homepage":"https://registry.agentskit.io","skills":[{"name":"knowledge-promoter","description":"Turns curated private notes into public documentation PRs — classify, sanitize to vendor-neutral, adversarial leak-gate, then open one draft PR. Never merges; a human reviews.","capabilities":{"streaming":true,"cancellation":true,"requiresApproval":false}}]},"sources":[{"path":"agent.ts","content":"import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'\nimport { createRuntime } from '@agentskit/runtime'\nimport { defineZodTool } from '@agentskit/tools'\nimport { z } from 'zod'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport type { JSONSchema7 } from 'json-schema'\nimport { classifier, leakAuditor, sanitizer } from './skills'\n\n/**\n * Knowledge Promoter — turns curated private notes into PUBLIC documentation PRs,\n * safely. Pipeline: classify → sanitize → leak-gate → publish (one draft PR). The\n * sanitizer (writer) and leak auditor are separate agents in separate runtimes, so\n * the writer never grades its own homework. The leak gate is DETERMINISTIC: a regex\n * denylist plus the adversarial auditor, both run by orchestration code — never a\n * step the model can decide to skip. It never merges; a human reviews the draft PR.\n *\n * Reusable for any \"private notes → public docs\" flow: inject your site map, house\n * style, denylist, and a `publish` function.\n *\n * ```ts\n * import { anthropic } from '@agentskit/adapters'\n * const agent = createKnowledgePromoterAgent({\n *   adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n *   siteMapUrl: 'https://docs.example.com/llms.txt',\n *   houseStyle: '# Title → ## TL;DR → ## For agents → ### See also',\n *   denylist: [/\\bACME-\\d+\\b/, /\\binternal-[a-z]+\\b/],\n *   publish: async (drafts) => openDraftPrSomehow(drafts),\n * })\n * const report = await agent.run(candidates)\n * ```\n */\n\nexport interface Candidate {\n  /** Stable id for ledger/dedup (caller-owned). */\n  id: string\n  title: string\n  /** Raw lesson text — may contain internal context; the pipeline strips it. */\n  lesson: string\n}\n\nexport interface PreparedDoc {\n  candidate: Candidate\n  category: string | null\n  shape: 'new' | 'enrich'\n  targetDoc: string | null\n  /** Assembled markdown with frontmatter, ready to write. */\n  markdown: string\n  title: string\n  droppedForGenerality: string[]\n}\n\nexport interface PromoteOutcome {\n  candidate: Candidate\n  status: 'promoted' | 'rejected' | 'blocked'\n  /** For rejected/blocked: why. For promoted: the doc that will be opened. */\n  detail: string\n}\n\nexport type PromoteStage = 'classify' | 'sanitize' | 'leak-gate' | 'publish'\n\n/**\n * Progress is emitted through the standard observer channel as `AgentEvent`s of\n * `{ type: 'progress', label, status, detail?, durationMs? }`. Pass any Observer\n * in `observers` (e.g. `createProgressObserver()` from `@agentskit/ink`) to render\n * it live — no bespoke callback; one channel for runtime + domain events.\n */\n\nexport interface KnowledgePromoterConfig {\n  /** Any AgentsKit adapter (anthropic, openai, gemini, …). */\n  adapter: AdapterFactory\n  /** URL of the target docs' machine-readable site map (e.g. /llms.txt), for dedup. */\n  siteMapUrl: string\n  /** House-style spec injected verbatim into the sanitizer (frontmatter + section order). */\n  houseStyle: string\n  /** Regex denylist — the deterministic first leak layer. Hits = hard block. */\n  denylist: RegExp[]\n  /** Opens ONE draft PR with all cleared docs and returns its number/URL. Never merges. */\n  publish: (docs: PreparedDoc[]) => Promise<{ pr: number | string; url?: string }>\n  /** Optional: conversation memory, observers, HITL confirm, step cap. */\n  memory?: ChatMemory\n  observers?: Observer[]\n  /** Per-tool-call confirmation gate (HITL / RBAC) — passed to the runtime. */\n  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>\n  /** Independent leak-audit votes; the gate blocks on a MAJORITY of \"block\". Default 3. */\n  auditVotes?: number\n  maxSteps?: number\n}\n\nconst Classification = z.object({\n  isGeneralizable: z.boolean(),\n  category: z.string().nullable(),\n  shape: z.enum(['new', 'enrich']),\n  targetDoc: z.string().nullable(),\n  alreadyCovered: z.boolean(),\n  reason: z.string(),\n})\nconst Sanitized = z.object({\n  title: z.string(),\n  description: z.string(),\n  type: z.string(),\n  body: z.string(),\n  droppedForGenerality: z.array(z.string()),\n})\nconst LeakVerdict = z.object({\n  verdict: z.enum(['clean', 'block']),\n  identifiers: z.array(z.string()),\n  explanation: z.string(),\n})\n\nconst toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7\n\nexport function createKnowledgePromoterAgent(config: KnowledgePromoterConfig) {\n  const maxSteps = config.maxSteps ?? 4\n\n  // submit_* tools: the agent's only job each stage is to call one of these once.\n  // execute() just acknowledges — the structured payload is read back from toolCalls.\n  const submit = (name: string, schema: z.ZodTypeAny): ToolDefinition =>\n    defineZodTool({\n      name,\n      description: `Submit the ${name.replace('submit_', '').replace('_', ' ')} result. Call exactly once.`,\n      schema,\n      toJsonSchema: toJson,\n      async execute() {\n        return 'recorded'\n      },\n    }) as ToolDefinition\n\n  // One focused runtime per structured stage → reads the validated payload back.\n  async function runStructured<T extends z.ZodTypeAny>(opts: {\n    skill: typeof classifier\n    task: string\n    tool: ToolDefinition\n    schema: T\n  }): Promise<z.infer<T>> {\n    const runtime = createRuntime({\n      adapter: config.adapter,\n      tools: [opts.tool],\n      memory: config.memory,\n      observers: config.observers,\n      onConfirm: config.onConfirm,\n      maxSteps,\n    })\n    const result = await runtime.run(opts.task, { skill: opts.skill })\n    const call = result.toolCalls.find((c) => c.name === opts.tool.name)\n    if (!call) throw new Error(`${opts.skill.name} did not submit a result`)\n    return opts.schema.parse(call.args)\n  }\n\n  async function fetchSiteMap(): Promise<string> {\n    const res = await fetch(config.siteMapUrl)\n    if (!res.ok) throw new Error(`site map fetch failed: ${res.status}`)\n    return res.text()\n  }\n\n  const auditVotes = Math.max(1, config.auditVotes ?? 3)\n\n  async function leakGate(text: string): Promise<{ ok: boolean; hits: string[]; note?: string }> {\n    // Layer 1 — regex denylist (deterministic, no tokens). Any hit hard-blocks.\n    const hits = [...new Set(config.denylist.flatMap((re) => text.match(new RegExp(re, 'g')) ?? []))]\n    if (hits.length) return { ok: false, hits }\n    // Layer 2 — N independent adversarial auditors (separate runtimes, in parallel).\n    // Block on a MAJORITY of \"block\" verdicts; each auditor defaults to \"block\" when unsure.\n    const verdicts = await Promise.all(\n      Array.from({ length: auditVotes }, () =>\n        runStructured({ skill: leakAuditor, task: text, tool: submit('submit_leak_verdict', LeakVerdict), schema: LeakVerdict }),\n      ),\n    )\n    const blocked = verdicts.filter((v) => v.verdict !== 'clean')\n    if (blocked.length * 2 >= auditVotes) {\n      return { ok: false, hits: [...new Set(blocked.flatMap((v) => v.identifiers))], note: blocked[0]?.explanation }\n    }\n    return { ok: true, hits: [] }\n  }\n\n  // Emit a domain-progress AgentEvent through the standard observer channel.\n  const emit = (label: PromoteStage, status: 'start' | 'ok' | 'skip' | 'error', detail?: string, durationMs?: number) => {\n    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail, durationMs })\n  }\n\n  // Times a stage, emitting start → ok progress around it.\n  async function stage<T>(name: PromoteStage, fn: () => Promise<T>, label: (r: T) => string): Promise<T> {\n    emit(name, 'start')\n    const t0 = Date.now()\n    const result = await fn()\n    emit(name, 'ok', label(result), Date.now() - t0)\n    return result\n  }\n\n  async function run(candidates: Candidate[]): Promise<{ outcomes: PromoteOutcome[]; pr?: { pr: number | string; url?: string } }> {\n    if (candidates.length === 0) return { outcomes: [] }\n    const siteMap = await fetchSiteMap()\n    const outcomes: PromoteOutcome[] = []\n    const docs: PreparedDoc[] = []\n\n    for (const c of candidates) {\n      // 1 — classify (always re-decides; dedup against the live site map).\n      const cls = await stage('classify',\n        () => runStructured({ skill: classifier, task: `SITE MAP:\\n${siteMap}\\n\\nCANDIDATE LESSON (title: ${c.title}):\\n${c.lesson}`, tool: submit('submit_classification', Classification), schema: Classification }),\n        (r) => (r.isGeneralizable && !r.alreadyCovered ? `${c.title} → ${r.category} · ${r.shape}` : 'skip'))\n      if (!cls.isGeneralizable || cls.alreadyCovered) {\n        const detail = cls.alreadyCovered ? `duplicate: ${cls.reason}` : `not generalizable: ${cls.reason}`\n        emit('classify', 'skip', `${c.title}: ${detail}`)\n        outcomes.push({ candidate: c, status: 'rejected', detail })\n        continue\n      }\n\n      // 2 — sanitize into the target house style.\n      const sani = await stage('sanitize',\n        () => runStructured({ skill: sanitizer, task: `CATEGORY: ${cls.category}\\nSHAPE: ${cls.shape}${cls.targetDoc ? ` (${cls.targetDoc})` : ''}\\n\\nHOUSE STYLE:\\n${config.houseStyle}\\n\\nLESSON:\\n${c.lesson}`, tool: submit('submit_sanitized', Sanitized), schema: Sanitized }),\n        (r) => `\"${r.title}\"`)\n\n      // 3 — leak gate (regex + N adversarial auditors). BOTH layers must pass.\n      const markdown = `---\\ntype: ${sani.type}\\ntitle: '${sani.title.replace(/'/g, \"''\")}'\\ndescription: '${sani.description.replace(/'/g, \"''\")}'\\n---\\n\\n${sani.body}\\n`\n      emit('leak-gate', 'start')\n      const t0 = Date.now()\n      const gate = await leakGate(`${sani.title}\\n${sani.description}\\n${sani.body}`)\n      if (!gate.ok) {\n        emit('leak-gate', 'error', `blocked: ${gate.hits.join(', ')}`, Date.now() - t0)\n        outcomes.push({ candidate: c, status: 'blocked', detail: `leak-gate: ${gate.hits.join(', ')}${gate.note ? ` — ${gate.note}` : ''}` })\n        continue\n      }\n      emit('leak-gate', 'ok', `clean (${auditVotes} votes)`, Date.now() - t0)\n\n      docs.push({ candidate: c, category: cls.category, shape: cls.shape, targetDoc: cls.targetDoc, markdown, title: sani.title, droppedForGenerality: sani.droppedForGenerality })\n      outcomes.push({ candidate: c, status: 'promoted', detail: `${cls.shape} ${cls.category}/${sani.title}` })\n    }\n\n    // 4 — one batched draft PR (human gate). Never merges.\n    if (docs.length === 0) return { outcomes }\n    emit('publish', 'start', `${docs.length} doc(s)`)\n    const t0 = Date.now()\n    const pr = await config.publish(docs)\n    emit('publish', 'ok', `PR ${pr.pr}`, Date.now() - t0)\n    return { outcomes, pr }\n  }\n\n  return {\n    /** Stable name for orchestration (supervisor / swarm / A2A). */\n    name: 'knowledge-promoter',\n    run,\n    /** AgentHandle: accepts a JSON array of candidates, returns a text summary. */\n    asHandle() {\n      return {\n        name: 'knowledge-promoter',\n        run: async (task: string) => {\n          const candidates = JSON.parse(task) as Candidate[]\n          const { outcomes, pr } = await run(candidates)\n          return JSON.stringify({ outcomes, pr }, null, 2)\n        },\n      }\n    },\n  }\n}\n\n// fetchUrl is re-exported for consumers who prefer the AgentsKit tool over global fetch.\nexport { fetchUrl } from '@agentskit/tools'\n"},{"path":"skills.ts","content":"import type { SkillDefinition } from '@agentskit/core'\n\n/**\n * Three personas for the promote pipeline. The writer (sanitizer) and the auditor\n * are deliberately SEPARATE skills run in SEPARATE runtimes — the writer never\n * grades its own homework. Each ends by calling exactly one `submit_*` tool so\n * the orchestrator can read a validated, structured result from `toolCalls`.\n */\n\nexport const classifier: SkillDefinition = {\n  name: 'knowledge-classifier',\n  description: 'Decides whether a candidate lesson is generalizable, which pillar it belongs to, and whether the target docs already cover it.',\n  systemPrompt: `You triage candidate lessons for a PUBLIC, vendor-neutral documentation set.\nThe docs contain NO project-, company-, or repo-specific detail.\n\nYou are given the docs' site map and one candidate lesson. The lesson may contain\ninternal context — that is expected and will be stripped later by a DIFFERENT agent.\nJudge the underlying, transferable principle, not its current wording.\n\nDecide and call \\`submit_classification\\` EXACTLY ONCE with:\n- isGeneralizable: is there a principle that helps teams on OTHER stacks?\n- category: the best-fit section from the site map (or null).\n- shape: \"new\" (nothing covers it) or \"enrich\" (extend an existing doc — prefer this when close).\n- targetDoc: when shape=\"enrich\", the existing doc path from the site map (else null).\n- alreadyCovered: true if the docs already fully cover it (→ rejected as duplicate).\n- reason: one sentence.\n\nYou always re-decide independently; any hint in the candidate's frontmatter is advisory.\nCall the tool once and stop. Output nothing else.`,\n  tools: ['submit_classification'],\n}\n\nexport const sanitizer: SkillDefinition = {\n  name: 'knowledge-sanitizer',\n  description: 'Rewrites a candidate lesson into a publishable, vendor-neutral doc in the target house style.',\n  systemPrompt: `You rewrite a candidate lesson into a publishable doc for a PUBLIC, vendor-neutral\ndocumentation set. The output MUST be generic enough for any team on any stack — and\nMUST contain ZERO project-, company-, repo-, person-, customer-, or ticket-specific detail.\n\nHard rules:\n- Remove every internal identifier: package names, ADR/RFC/PR numbers, file paths,\n  branch/worktree names, product/customer/tenant names, project-history dates.\n- If a point cannot be made without naming an internal, DROP THE POINT. Never paraphrase\n  an internal into a thin disguise — drop it.\n- Keep only the transferable principle and a generic, concrete illustration.\n\nFollow the HOUSE STYLE block in the task verbatim.\n\nCall \\`submit_sanitized\\` EXACTLY ONCE with: title, description, type, body (markdown,\nno frontmatter), and droppedForGenerality (what you removed to stay generic).\nFor \\`type\\`, use the EXACT document-type vocabulary named in the HOUSE STYLE block —\nnot a freeform word like \"pattern\" or \"doc\". Stop.`,\n  tools: ['submit_sanitized'],\n}\n\nexport const leakAuditor: SkillDefinition = {\n  name: 'knowledge-leak-auditor',\n  description: 'Adversarially audits a draft for any project-, company-, or repo-specific detail before publication.',\n  systemPrompt: `You are an adversarial leak auditor for a PUBLIC repository. You did NOT write the\ntext under review. Your only job: catch anything project-, company-, repo-, person-,\ncustomer-, or ticket-specific that must never be published.\n\nList EVERY token or phrase that could identify a specific project, company, internal\npackage, repository, person, customer, tenant, ticket, or internal decision — including\nthin disguises and oblique references, not just literal names.\n\nCall \\`submit_leak_verdict\\` EXACTLY ONCE with:\n- verdict: \"clean\" ONLY if you are confident there is nothing project-specific; otherwise \"block\".\n- identifiers: every suspect token you found.\n- explanation: one sentence.\n\nThis is a one-way door — a leaked internal in a public repo is irreversible. When in\ndoubt, \"block\". Call the tool once and stop.`,\n  tools: ['submit_leak_verdict'],\n}\n"},{"path":"README.md","content":"# Knowledge Promoter Agent\n\nTurns curated **private notes** into **public documentation PRs** — safely.\n\nPipeline: **classify → sanitize → leak-gate → publish (one draft PR).**\n\n- The **sanitizer** (writer) and the **leak auditor** are separate agents in separate\n  runtimes — the writer never grades its own homework.\n- The **leak gate is deterministic**: a regex denylist *plus* the adversarial auditor,\n  both run by orchestration code — never a step the model can skip.\n- It **never merges**. A human reviews the draft PR.\n\nReusable for any \"private notes → public docs\" flow: inject your site map, house style,\ndenylist, and a `publish` function.\n\n```bash\nnpx agentskit add knowledge-promoter\n```\n\n```ts\nimport { anthropic } from '@agentskit/adapters'\nimport { createKnowledgePromoterAgent } from './agents/knowledge-promoter/agent'\n\nconst agent = createKnowledgePromoterAgent({\n  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n  siteMapUrl: 'https://docs.example.com/llms.txt',\n  houseStyle: '`# Title` → `## TL;DR (human)` → `## For agents` → `### See also`',\n  denylist: [/\\bACME-\\d+\\b/, /\\binternal-[a-z]+\\b/],\n  publish: async (docs) => {\n    // write each doc into a checkout, then `gh pr create --draft`. Return { pr, url }.\n    return { pr: 123, url: 'https://github.com/org/docs/pull/123' }\n  },\n})\n\nconst { outcomes, pr } = await agent.run([\n  { id: 'n1', title: 'Equalize columns by layout, not text', lesson: '…raw lesson, internal context allowed…' },\n])\n```\n\n## Config\n\n| Field | Required | What |\n|---|---|---|\n| `adapter` | ✓ | Any AgentsKit adapter (anthropic/openai/gemini/…) |\n| `siteMapUrl` | ✓ | Target docs' machine-readable map (e.g. `/llms.txt`) — used to dedup |\n| `houseStyle` | ✓ | Injected verbatim into the sanitizer (frontmatter + section order) |\n| `denylist` | ✓ | `RegExp[]` — the deterministic first leak layer; any hit hard-blocks |\n| `publish` | ✓ | `(docs) => { pr, url }` — opens ONE draft PR. **Never merges.** |\n| `onConfirm` | | Per-tool HITL gate |\n| `memory`, `observers`, `maxSteps` | | Standard runtime options |\n\n## `run(candidates)` → `{ outcomes, pr? }`\n\nEach candidate ends as `promoted` (in the PR), `rejected` (not generalizable / duplicate),\nor `blocked` (leak gate). One batched draft PR per run.\n\n## Safety model\n\nTwo independent leak layers, both must pass: regex denylist **and** a separate auditor\nagent that defaults to *block* when uncertain. The `publish` function should use a token\n**without merge permission**, so the human gate is enforced at the token, not just policy.\n\n## Built on\n\n`@agentskit/runtime` (ReAct + structured tool-call capture), `@agentskit/tools`\n(`defineZodTool`), `@agentskit/adapters`. Skills are plain `SkillDefinition`s in `skills.ts`.\n"}],"installable":true}