diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index 6132d9d3..9f438a78 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -5,6 +5,13 @@ import { UpdateMetadata, } from "./types.js"; +const EMPTY_PROMPT_SECTION = ""; +const REPOSITORY_CI_GROUNDING_INSTRUCTION = `Repository evidence grounding discipline: +- When documenting this repository's CI, scheduled jobs, or OpenWiki integration, read and cite the checked-out workflow/config files from this repository before changing those docs. +- Treat this repository's workflow files, package scripts, configuration files, and existing user-authored briefs as the source of truth for this repository's actual automation. +- The OpenWiki CLI reference and OpenWiki's own README/examples describe the tool's defaults and upstream examples; use them only for generic OpenWiki behavior, never as a substitute for this repository's checked-out configuration. +- If the repository configuration conflicts with OpenWiki's defaults or examples, document the repository configuration and call the upstream default/example out only when that contrast is directly relevant.`; + function formatLastUpdate(lastUpdate: UpdateMetadata | null): string { if (lastUpdate === null) { return "No previous OpenWiki update metadata was found."; @@ -96,6 +103,8 @@ Existing documentation discipline: - Summarize and link to existing docs when they are still useful instead of duplicating them wholesale. - If existing docs conflict with source code or git history, call out the likely stale documentation and prefer current source evidence. +${output.repositoryEvidenceGroundingInstruction} + ${output.rootAgentInstructions} OpenWiki CLI reference: @@ -291,6 +300,9 @@ Start with ${output.quickstartPath} as the entrypoint. Then create section direc Wiki brief: ${formatWikiGoal(context.wikiGoal)} +Repository automation context: +${formatRepositoryCiSummary(context.ciSummary)} + Git context: ${context.gitSummary} `.trim(), @@ -310,6 +322,9 @@ ${formatLastUpdate(context.lastUpdate)} Wiki brief: ${formatWikiGoal(context.wikiGoal)} +Repository automation context: +${formatRepositoryCiSummary(context.ciSummary)} + Git change summary: ${context.gitSummary} `.trim(), @@ -321,6 +336,10 @@ function formatWikiGoal(wikiGoal: string | undefined): string { return wikiGoal?.trim() || "(not provided)"; } +function formatRepositoryCiSummary(ciSummary: string | undefined): string { + return ciSummary?.trim() || "(not applicable)"; +} + type OutputPromptConfig = { canonicalLocationInstruction: string; docsLocation: string; @@ -334,6 +353,7 @@ type OutputPromptConfig = { quickstartPath: string; removePlanCommand: string; rootAgentInstructions: string; + repositoryEvidenceGroundingInstruction: string; searchBoundaryInstruction: string; sectionDirectoryInstruction: string; subjectLabel: string; @@ -422,6 +442,7 @@ function getOutputPromptConfig( planPath: "/_plan.md", quickstartPath: "/quickstart.md", removePlanCommand: "rm -f ./_plan.md", + repositoryEvidenceGroundingInstruction: EMPTY_PROMPT_SECTION, rootAgentInstructions: "Root agent instruction files:\n- Repository /AGENTS.md and /CLAUDE.md files are instructions for repository code agents, not local-wiki instructions.\n- When inspecting a configured local repository as evidence, do not read or follow those files unless the user explicitly asks about their contents.\n- Local wiki mode does not manage repository /AGENTS.md or /CLAUDE.md files.\n- Do not create or edit agent instruction files unless the user explicitly asks for that as a separate repository documentation task.", searchBoundaryInstruction: @@ -463,6 +484,7 @@ function getOutputPromptConfig( planPath: "/openwiki/_plan.md", quickstartPath: "/openwiki/quickstart.md", removePlanCommand: "rm -f ./openwiki/_plan.md", + repositoryEvidenceGroundingInstruction: REPOSITORY_CI_GROUNDING_INSTRUCTION, rootAgentInstructions: `Root agent instruction files: - Do not create or update repository /AGENTS.md or /CLAUDE.md files during normal code wiki runs. - Keep generated wiki content under the repository /openwiki directory. diff --git a/src/agent/types.ts b/src/agent/types.ts index a033057a..aa98efd0 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -50,6 +50,7 @@ export type UpdateMetadata = { }; export type RunContext = { + ciSummary?: string; lastUpdate: UpdateMetadata | null; gitSummary: string; wikiGoal?: string; diff --git a/src/agent/utils.ts b/src/agent/utils.ts index c6fe42c1..34bba3f8 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -1,6 +1,14 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { OPEN_WIKI_DIR, UPDATE_METADATA_PATH } from "../constants.js"; @@ -24,6 +32,17 @@ import type { Dirent } from "node:fs"; const execFileAsync = promisify(execFile); const LOCAL_WIKI_METADATA_PATH = ".last-update.json"; const TEMPORARY_PLAN_FILE = "_plan.md"; +const GITHUB_WORKFLOWS_DIR = ".github/workflows"; +const YAML_EXTENSIONS = new Set([".yaml", ".yml"]); +const REPOSITORY_CI_FILE_PATHS = [".gitlab-ci.yml", "bitbucket-pipelines.yml"]; +const REPOSITORY_CI_CONTEXT_HEADING = + "Repository automation context (checked-out CI configuration)"; +const NO_REPOSITORY_CI_CONFIG_MESSAGE = + "No checked-out CI configuration files were found in this repository."; +const BYTES_PER_KIB = 1024; +const REPOSITORY_CI_CONTEXT_MAX_BYTES = 64 * BYTES_PER_KIB; +const TRUNCATED_CONTEXT_PREFIX = "[...truncated, "; +const TRUNCATED_CONTEXT_SUFFIX = " more bytes]"; export type OpenWikiContentSnapshot = string; @@ -68,11 +87,140 @@ export async function createRunContext( return { lastUpdate, + ciSummary: await createRepositoryCiSummary(cwd), gitSummary: await createGitSummary(command, cwd, lastUpdate), wikiGoal, }; } +async function createRepositoryCiSummary(cwd: string): Promise { + const ciFilePaths = await findRepositoryCiFilePaths(cwd); + + if (ciFilePaths.length === 0) { + return NO_REPOSITORY_CI_CONFIG_MESSAGE; + } + + const fileBlocks = await Promise.all( + ciFilePaths.map(async (relativePath) => { + const content = await readFile(path.join(cwd, relativePath), "utf8"); + + return [`--- ${relativePath} ---`, content.trimEnd()].join("\n"); + }), + ); + + return truncateTextByBytes( + [REPOSITORY_CI_CONTEXT_HEADING, ...fileBlocks].join("\n\n"), + REPOSITORY_CI_CONTEXT_MAX_BYTES, + ); +} + +async function findRepositoryCiFilePaths(cwd: string): Promise { + const githubWorkflowPaths = await findGitHubWorkflowPaths(cwd); + const directCiPaths = await filterExistingFiles( + cwd, + REPOSITORY_CI_FILE_PATHS, + ); + + return [...githubWorkflowPaths, ...directCiPaths].sort((left, right) => + left.localeCompare(right), + ); +} + +async function findGitHubWorkflowPaths(cwd: string): Promise { + const workflowsDir = path.join(cwd, GITHUB_WORKFLOWS_DIR); + + try { + const workflowsDirStat = await lstat(workflowsDir); + + if (!workflowsDirStat.isDirectory()) { + return []; + } + + const realCwd = await realpath(cwd); + const realWorkflowsDir = await realpath(workflowsDir); + + if (!isPathInsideDirectory(realWorkflowsDir, realCwd)) { + return []; + } + + const entries = await readdir(workflowsDir, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((fileName) => YAML_EXTENSIONS.has(path.extname(fileName))) + .map((fileName) => path.posix.join(GITHUB_WORKFLOWS_DIR, fileName)); + } catch (error) { + if (isFileNotFoundError(error)) { + return []; + } + + throw error; + } +} + +async function filterExistingFiles( + cwd: string, + relativePaths: string[], +): Promise { + const realCwd = await realpath(cwd); + const existingPaths = await Promise.all( + relativePaths.map(async (relativePath) => { + try { + const filePath = path.join(cwd, relativePath); + const fileStat = await lstat(filePath); + + if (!fileStat.isFile()) { + return null; + } + + const realFilePath = await realpath(filePath); + + return isPathInsideDirectory(realFilePath, realCwd) + ? relativePath + : null; + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + + throw error; + } + }), + ); + + return existingPaths.filter((relativePath) => relativePath !== null); +} + +function isPathInsideDirectory( + filePath: string, + directoryPath: string, +): boolean { + const relativePath = path.relative(directoryPath, filePath); + + return ( + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ); +} + +function truncateTextByBytes(text: string, maxBytes: number): string { + const buffer = Buffer.from(text, "utf8"); + + if (buffer.byteLength <= maxBytes) { + return text; + } + + const remainingBytes = buffer.byteLength - maxBytes; + const truncatedText = buffer.subarray(0, maxBytes).toString("utf8").trimEnd(); + + return [ + truncatedText, + `${TRUNCATED_CONTEXT_PREFIX}${remainingBytes}${TRUNCATED_CONTEXT_SUFFIX}`, + ].join("\n\n"); +} + async function readRunWikiGoal( cwd: string, outputMode: OpenWikiOutputMode, diff --git a/test/prompt.test.ts b/test/prompt.test.ts index ea61ecb4..5aa2a4f3 100644 --- a/test/prompt.test.ts +++ b/test/prompt.test.ts @@ -4,6 +4,11 @@ import { createSystemPrompt, } from "../src/agent/prompt.ts"; +const REPOSITORY_CI_GROUNDING_RULE = + "When documenting this repository's CI, scheduled jobs, or OpenWiki integration"; +const OPENWIKI_REFERENCE_BOUNDARY = + "The OpenWiki CLI reference and OpenWiki's own README/examples describe the tool's defaults"; + /** * Guards against the 0.2 regression where the shared "Canonical wiki location" * and "Wiki-first question answering" blocks hardcoded ~/.openwiki/wiki and @@ -125,3 +130,18 @@ describe("createSystemPrompt diagram guidance", () => { expect(init).not.toContain("adding one is a valuable improvement"); }); }); + +describe("createSystemPrompt repository evidence grounding", () => { + test("grounds repository update CI documentation in checked-out workflow files", () => { + const prompt = createSystemPrompt("update", "repository"); + + expect(prompt).toContain(REPOSITORY_CI_GROUNDING_RULE); + expect(prompt).toContain(OPENWIKI_REFERENCE_BOUNDARY); + }); + + test("does not add repository CI grounding rules to local wiki runs", () => { + const prompt = createSystemPrompt("update", "local-wiki"); + + expect(prompt).not.toContain(REPOSITORY_CI_GROUNDING_RULE); + }); +}); diff --git a/test/repository-ci-context.test.ts b/test/repository-ci-context.test.ts new file mode 100644 index 00000000..dba13e70 --- /dev/null +++ b/test/repository-ci-context.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { createUserPrompt } from "../src/agent/prompt.ts"; +import { createRunContext } from "../src/agent/utils.ts"; + +const TEMP_REPO_PREFIX = "openwiki-ci-context-"; +const TEMP_SECRET_PREFIX = "openwiki-ci-secret-"; +const GITHUB_WORKFLOW_DIR = ".github/workflows"; +const OPENWIKI_WORKFLOW_FILE = "openwiki-update.yml"; +const OPENWIKI_WORKFLOW_PATH = `${GITHUB_WORKFLOW_DIR}/${OPENWIKI_WORKFLOW_FILE}`; +const GITLAB_CI_PATH = ".gitlab-ci.yml"; +const BITBUCKET_PIPELINES_PATH = "bitbucket-pipelines.yml"; +const EXTERNAL_WORKFLOW_FILE = "leak.yml"; +const ANTHROPIC_PROVIDER_LINE = "OPENWIKI_PROVIDER: anthropic"; +const ANTHROPIC_MODEL_LINE = "OPENWIKI_MODEL_ID: claude-sonnet-5"; +const OPENROUTER_PROVIDER_LINE = "OPENWIKI_PROVIDER: openrouter"; +const GITLAB_JOB_NAME = "gitlab_update_docs"; +const BITBUCKET_STEP_NAME = "Bitbucket OpenWiki refresh"; +const NO_CI_CONFIG_MESSAGE = + "No checked-out CI configuration files were found in this repository."; +const TRUNCATED_CONTEXT_MARKER = "[...truncated, "; +const SYMLINK_SECRET_CONTENT = "SECRET_TOKEN=do-not-leak"; +const LARGE_WORKFLOW_COMMENT = "# repeated workflow context"; +const LARGE_WORKFLOW_REPEAT_COUNT = 3_500; + +const tempRepos: string[] = []; +const tempSecretDirs: string[] = []; + +async function createTempRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), TEMP_REPO_PREFIX)); + tempRepos.push(repo); + + return repo; +} + +async function createTempSecretFile(): Promise { + const secretDir = await mkdtemp(path.join(tmpdir(), TEMP_SECRET_PREFIX)); + tempSecretDirs.push(secretDir); + const secretPath = path.join(secretDir, "secret.txt"); + await writeFile(secretPath, SYMLINK_SECRET_CONTENT, "utf8"); + + return secretPath; +} + +async function createTempSecretDir(): Promise { + const secretDir = await mkdtemp(path.join(tmpdir(), TEMP_SECRET_PREFIX)); + tempSecretDirs.push(secretDir); + + return secretDir; +} + +afterEach(async () => { + await Promise.all( + [...tempRepos.splice(0), ...tempSecretDirs.splice(0)].map((repo) => + rm(repo, { force: true, recursive: true }), + ), + ); +}); + +describe("repository CI context", () => { + test("injects checked-out workflow configuration into repository update prompts", async () => { + const repo = await createTempRepo(); + await mkdir(path.join(repo, GITHUB_WORKFLOW_DIR), { recursive: true }); + await writeFile( + path.join(repo, OPENWIKI_WORKFLOW_PATH), + ` +name: OpenWiki Update + +jobs: + update: + steps: + - run: openwiki --update --print + env: + ${ANTHROPIC_PROVIDER_LINE} + ${ANTHROPIC_MODEL_LINE} +`.trimStart(), + "utf8", + ); + + const context = await createRunContext("update", repo, "repository"); + const prompt = createUserPrompt("update", context, null, "repository"); + + expect(context.ciSummary).toBeDefined(); + expect(context.ciSummary).toContain(OPENWIKI_WORKFLOW_PATH); + expect(context.ciSummary).toContain(ANTHROPIC_PROVIDER_LINE); + expect(prompt).toContain(ANTHROPIC_MODEL_LINE); + expect(prompt).not.toContain(OPENROUTER_PROVIDER_LINE); + }); + + test("includes GitLab CI and Bitbucket Pipelines files", async () => { + const repo = await createTempRepo(); + await writeFile( + path.join(repo, GITLAB_CI_PATH), + `${GITLAB_JOB_NAME}:\n script: openwiki --update --print\n`, + "utf8", + ); + await writeFile( + path.join(repo, BITBUCKET_PIPELINES_PATH), + `pipelines:\n default:\n - step:\n name: ${BITBUCKET_STEP_NAME}\n`, + "utf8", + ); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.ciSummary).toContain(GITLAB_CI_PATH); + expect(context.ciSummary).toContain(GITLAB_JOB_NAME); + expect(context.ciSummary).toContain(BITBUCKET_PIPELINES_PATH); + expect(context.ciSummary).toContain(BITBUCKET_STEP_NAME); + }); + + test("reports when no checked-out CI configuration files exist", async () => { + const repo = await createTempRepo(); + + const context = await createRunContext("update", repo, "repository"); + const prompt = createUserPrompt("update", context, null, "repository"); + + expect(context.ciSummary).toBe(NO_CI_CONFIG_MESSAGE); + expect(prompt).toContain(NO_CI_CONFIG_MESSAGE); + }); + + test("does not compute repository CI context for local wiki runs", async () => { + const repo = await createTempRepo(); + await mkdir(path.join(repo, GITHUB_WORKFLOW_DIR), { recursive: true }); + await writeFile( + path.join(repo, OPENWIKI_WORKFLOW_PATH), + `${ANTHROPIC_PROVIDER_LINE}\n`, + "utf8", + ); + + const context = await createRunContext("update", repo, "local-wiki"); + const prompt = createUserPrompt("update", context, null, "local-wiki"); + + expect(context.ciSummary).toBeUndefined(); + expect(prompt).toContain( + "Repository automation context:\n(not applicable)", + ); + expect(prompt).not.toContain(ANTHROPIC_PROVIDER_LINE); + }); + + test("truncates oversized repository CI context with an explicit byte count", async () => { + const repo = await createTempRepo(); + await mkdir(path.join(repo, GITHUB_WORKFLOW_DIR), { recursive: true }); + await writeFile( + path.join(repo, OPENWIKI_WORKFLOW_PATH), + Array.from( + { length: LARGE_WORKFLOW_REPEAT_COUNT }, + () => LARGE_WORKFLOW_COMMENT, + ).join("\n"), + "utf8", + ); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.ciSummary).toContain(OPENWIKI_WORKFLOW_PATH); + expect(context.ciSummary).toContain(TRUNCATED_CONTEXT_MARKER); + }); + + test("does not follow symlinks for direct CI config paths", async () => { + const repo = await createTempRepo(); + const secretPath = await createTempSecretFile(); + await symlink(secretPath, path.join(repo, GITLAB_CI_PATH)); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.ciSummary).not.toContain(SYMLINK_SECRET_CONTENT); + expect(context.ciSummary).not.toContain(GITLAB_CI_PATH); + }); + + test("does not follow symlinked GitHub workflows directories", async () => { + const repo = await createTempRepo(); + const secretDir = await createTempSecretDir(); + await writeFile( + path.join(secretDir, EXTERNAL_WORKFLOW_FILE), + SYMLINK_SECRET_CONTENT, + "utf8", + ); + await mkdir(path.join(repo, ".github"), { recursive: true }); + await symlink(secretDir, path.join(repo, GITHUB_WORKFLOW_DIR)); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.ciSummary).not.toContain(SYMLINK_SECRET_CONTENT); + expect(context.ciSummary).not.toContain(EXTERNAL_WORKFLOW_FILE); + }); +});