diff --git a/.changeset/tidy-wikis-rest.md b/.changeset/tidy-wikis-rest.md new file mode 100644 index 00000000..4f559c94 --- /dev/null +++ b/.changeset/tidy-wikis-rest.md @@ -0,0 +1,5 @@ +--- +"openwiki": minor +--- + +feat: add configurable `manage` and `preserve` policies for code-mode root agent files diff --git a/README.md b/README.md index 9520a702..2bfb931c 100644 --- a/README.md +++ b/README.md @@ -142,11 +142,21 @@ Locally the setup wizard saves this to `~/.openwiki/.env`. In CI, set it as a re Everything OpenWiki writes is plain Markdown you own and version alongside your code. -- **Agents read it as memory.** On each `code` run, OpenWiki maintains an `AGENTS.md` and `CLAUDE.md` at the repo root that point your coding agent at the wiki. It only rewrites its own `` block and leaves the rest of each file untouched. +- **Agents read it as memory.** On each `code` run, OpenWiki maintains an `AGENTS.md` and `CLAUDE.md` at the repo root that point your coding agent at the wiki. It only rewrites its own `` block and leaves the rest of each file untouched. Repositories that own those files themselves can select the `preserve` policy below. - **You set the brief.** Repository-specific instructions live in `openwiki/INSTRUCTIONS.md`, a user-authored file OpenWiki reads for scope and priorities but never rewrites during normal runs. - **No-op runs are free.** After a run, OpenWiki snapshots the `openwiki/` directory and only records new metadata when something actually changed, so scheduled workflows never churn. - **Local, private config.** Provider choice, keys, and optional LangSmith tracing are saved to `~/.openwiki/.env` on your machine. +Agent-file ownership is committed in `openwiki.config.yaml`. The default policy is `manage`; set `preserve` to leave existing root files byte-for-byte unchanged and keep missing files absent during init, update, chat, and scheduled runs: + +```yaml +codeMode: + agentFiles: + policy: preserve +``` + +Use `--agent-files-policy ` to override that policy for one code-mode run without rewriting the config. Resolution order is CLI override, committed config, then the `manage` default. The `agentFiles` mapping leaves room for future path configuration without changing the meaning of the policy setting. A newly generated workflow reads the committed config on later runs and omits `AGENTS.md` and `CLAUDE.md` from its pull request paths when the committed policy is `preserve`. + ## Open Knowledge Format OpenWiki emits [Google Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles in both modes, so your wiki is portable to any OKF-aware tool. @@ -359,6 +369,7 @@ openwiki "generate docs" # start with an initial request openwiki -p "what can you do?" # one-shot, print, and exit openwiki --init # initialize code docs (personal: openwiki personal --init) openwiki --update # update code docs (personal: openwiki personal --update) +openwiki code --update --agent-files-policy preserve # preserve root agent files for one run openwiki visualize # interactive graph + live reader openwiki auth # authenticate a connector (slack, gmail, x, notion) openwiki ingest # run connector ingestion (all, or a connector/instance) diff --git a/src/cli/app/app.tsx b/src/cli/app/app.tsx index a1f4c77c..0a153ea5 100644 --- a/src/cli/app/app.tsx +++ b/src/cli/app/app.tsx @@ -52,6 +52,7 @@ import { getDisplayModelId, isExitMessage } from "../format.js"; import { appendRunLogEvent } from "../run-log/reducer.js"; import type { RunLogItem } from "../run-log/types.js"; import { + getCodeModeRepoSetupOptions, getRunModeCwd, getRunModeOutputMode, shouldAutoExitStartupRun, @@ -431,9 +432,13 @@ export function App({ command }: AppProps) { telemetryContext, async () => { if (runMode === "code") { - await ensureCodeModeRepoSetup(runtimeCwd, { - createWorkflow: resolvedCommand === "init", - }); + await ensureCodeModeRepoSetup( + runtimeCwd, + getCodeModeRepoSetupOptions({ + ...command, + command: resolvedCommand, + }), + ); } await scheduler.yield(); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 8ed7a0c7..d6e345ad 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,4 +1,8 @@ import { isValidModelId, normalizeModelId } from "../config/constants.js"; +import { + isCodeModeAgentFilesPolicy, + type CodeModeAgentFilesPolicy, +} from "../config/code-mode.js"; import type { OpenWikiCommand } from "../agent/types.js"; import { resolveLanguage } from "../platform/language.js"; import { isAuthProviderId } from "../auth/providers.js"; @@ -71,6 +75,7 @@ export type CliCommand = dryRun: boolean; language: string | null; languageWarning: string | null; + agentFilesPolicy: CodeModeAgentFilesPolicy | null; mode: OpenWikiRunMode; modeSource: OpenWikiRunModeSource; modelId: string | null; @@ -408,6 +413,7 @@ function parseRunCommand( initialMode: OpenWikiRunMode, initialModeSource: OpenWikiRunModeSource, ): CliCommand { + let agentFilesPolicy: CodeModeAgentFilesPolicy | null = null; let dryRun = false; let language: string | null = null; let mode = initialMode; @@ -444,6 +450,43 @@ function parseRunCommand( continue; } + if (arg === "--agent-files-policy") { + const nextArg = argv[index + 1]; + + if (!nextArg || nextArg.startsWith("-")) { + return { + kind: "error", + exitCode: 1, + message: "--agent-files-policy requires manage or preserve.", + }; + } + if (!isCodeModeAgentFilesPolicy(nextArg)) { + return { + kind: "error", + exitCode: 1, + message: `Invalid --agent-files-policy value: ${nextArg}. Expected manage or preserve.`, + }; + } + + agentFilesPolicy = nextArg; + index += 1; + continue; + } + + if (arg.startsWith("--agent-files-policy=")) { + const [, value = ""] = arg.split("=", 2); + if (!isCodeModeAgentFilesPolicy(value)) { + return { + kind: "error", + exitCode: 1, + message: `Invalid --agent-files-policy value: ${value}. Expected manage or preserve.`, + }; + } + + agentFilesPolicy = value; + continue; + } + if (arg === "--debug") { // isDebugMode() reads OPENWIKI_DEBUG; setting it at parse time is the // least-invasive way to opt into full credential/error diagnostics. @@ -643,6 +686,14 @@ function parseRunCommand( mode = "code"; } + if (agentFilesPolicy !== null && mode !== "code") { + return { + kind: "error", + exitCode: 1, + message: "--agent-files-policy is only available in code mode.", + }; + } + if (print && !shouldStart) { return { kind: "error", @@ -654,6 +705,7 @@ function parseRunCommand( return { kind: "run", exitCode: 0, + agentFilesPolicy, command, dryRun, language: resolvedLanguage.language ?? null, @@ -736,6 +788,7 @@ export const helpContent: HelpContent = { usage: [ "openwiki [--init|--update] [message]", "openwiki code [--init|--update] [message]", + "openwiki code --agent-files-policy [--init|--update] [message]", "openwiki personal [--init|--update] [message]", "openwiki --mode [--init|--update] [message]", "openwiki [--language ] [--init|--update] [message]", @@ -843,6 +896,11 @@ export const helpContent: HelpContent = { label: "-p, --print", description: "Run once and print the final assistant output.", }, + { + label: "--agent-files-policy ", + description: + "Override root agent-file handling for this code-mode run (default: config, then manage).", + }, { label: "--debug", description: @@ -884,6 +942,7 @@ export const helpContent: HelpContent = { "openwiki personal --init", "openwiki code --init", "openwiki --update", + "openwiki code --update --agent-files-policy preserve", "openwiki --update --mode personal", 'openwiki "What can you do?"', 'openwiki -p "Summarize what OpenWiki can do"', diff --git a/src/cli/run-mode.ts b/src/cli/run-mode.ts index 4b0330be..a4b9923f 100644 --- a/src/cli/run-mode.ts +++ b/src/cli/run-mode.ts @@ -1,5 +1,6 @@ import type { OpenWikiOutputMode } from "../agent/types.js"; import { openWikiLocalWikiDir } from "../config/openwiki-home.js"; +import type { CodeModeRepoSetupOptions } from "../ingestion/code-mode.js"; import type { CliCommand, OpenWikiRunMode } from "./commands.js"; /** @@ -54,6 +55,16 @@ export function getRunModeOutputMode( return mode === "code" ? "repository" : "local-wiki"; } +/** Maps a parsed code-mode command to repository setup behavior. */ +export function getCodeModeRepoSetupOptions( + command: Extract, +): CodeModeRepoSetupOptions { + return { + agentFilesPolicy: command.agentFilesPolicy, + createWorkflow: command.command === "init", + }; +} + /** * Reports whether a startup run should auto-exit when finished: a real * (non-dry-run, non-print) init or update run that was asked to start. diff --git a/src/cli/runners.ts b/src/cli/runners.ts index b26e5c7f..f1102e54 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -35,7 +35,11 @@ import type { CliCommand } from "./commands.js"; import { isDebugMode } from "./debug.js"; import { getAuthFix, getAuthFixSteps } from "./diagnostics/auth-fix.js"; import { getErrorDiagnostics } from "./diagnostics/error-diagnostics.js"; -import { getRunModeCwd, getRunModeOutputMode } from "./run-mode.js"; +import { + getCodeModeRepoSetupOptions, + getRunModeCwd, + getRunModeOutputMode, +} from "./run-mode.js"; import { formatPowerScheduleStatus, formatScheduleHeader, @@ -283,9 +287,10 @@ export async function runPrintCommand( telemetryContext, async () => { if (command.mode === "code") { - await ensureCodeModeRepoSetup(runtimeCwd, { - createWorkflow: command.command === "init", - }); + await ensureCodeModeRepoSetup( + runtimeCwd, + getCodeModeRepoSetupOptions(command), + ); } // Code-mode connectors (e.g. langsmith) pull their evidence and augment diff --git a/src/config/code-mode.ts b/src/config/code-mode.ts new file mode 100644 index 00000000..d523f3e4 --- /dev/null +++ b/src/config/code-mode.ts @@ -0,0 +1,124 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "yaml"; +import { isFileNotFoundError } from "../platform/fs-errors.js"; + +export const CODE_MODE_CONFIG_FILENAME = "openwiki.config.yaml"; +export const DEFAULT_CODE_MODE_AGENT_FILES_POLICY = "manage"; + +export type CodeModeAgentFilesPolicy = "manage" | "preserve"; +export type CodeModeAgentFilesPolicySource = "cli" | "config" | "default"; + +export interface ResolvedCodeModeAgentFilesPolicy { + /** Policy applied to the current run. */ + policy: CodeModeAgentFilesPolicy; + /** Where the current run's policy came from. */ + source: CodeModeAgentFilesPolicySource; + /** Committed policy used by later runs, or null when the default applies. */ + configuredPolicy: CodeModeAgentFilesPolicy | null; +} + +export function isCodeModeAgentFilesPolicy( + value: string, +): value is CodeModeAgentFilesPolicy { + return value === "manage" || value === "preserve"; +} + +/** + * Resolve the current code-mode agent-file policy without mutating repository + * configuration. CLI overrides win over committed config, which wins over the + * backward-compatible `manage` default. + */ +export async function resolveCodeModeAgentFilesPolicy( + cwd: string, + cliOverride: CodeModeAgentFilesPolicy | null = null, +): Promise { + const configuredPolicy = await readConfiguredAgentFilesPolicy(cwd); + + if (cliOverride !== null) { + return { configuredPolicy, policy: cliOverride, source: "cli" }; + } + + if (configuredPolicy !== null) { + return { + configuredPolicy, + policy: configuredPolicy, + source: "config", + }; + } + + return { + configuredPolicy: null, + policy: DEFAULT_CODE_MODE_AGENT_FILES_POLICY, + source: "default", + }; +} + +async function readConfiguredAgentFilesPolicy( + cwd: string, +): Promise { + let text: string; + + try { + text = await readFile(path.join(cwd, CODE_MODE_CONFIG_FILENAME), "utf8"); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + throw error; + } + + let config: unknown; + try { + config = parse(text) as unknown; + } catch (error) { + throw invalidConfig(errorMessage(error)); + } + + if (config === null || config === undefined) { + return null; + } + if (!isRecord(config)) { + throw invalidConfig("the document root must be a mapping."); + } + + const codeMode = config.codeMode; + if (codeMode === undefined) { + return null; + } + if (!isRecord(codeMode)) { + throw invalidConfig("codeMode must be a mapping."); + } + + const agentFiles = codeMode.agentFiles; + if (agentFiles === undefined) { + return null; + } + if (!isRecord(agentFiles)) { + throw invalidConfig("codeMode.agentFiles must be a mapping."); + } + + const policy = agentFiles.policy; + if (policy === undefined) { + return null; + } + if (typeof policy !== "string" || !isCodeModeAgentFilesPolicy(policy)) { + throw invalidConfig( + "codeMode.agentFiles.policy must be manage or preserve.", + ); + } + + return policy; +} + +function invalidConfig(message: string): Error { + return new Error(`Invalid ${CODE_MODE_CONFIG_FILENAME}: ${message}`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/ingestion/code-mode.ts b/src/ingestion/code-mode.ts index 33ea5f8c..d80b1f9c 100644 --- a/src/ingestion/code-mode.ts +++ b/src/ingestion/code-mode.ts @@ -4,6 +4,11 @@ import { OPENWIKI_VERSION } from "../config/constants.js"; import { isFileNotFoundError } from "../platform/fs-errors.js"; import { createConnectorRegistry } from "../connectors/registry.js"; import { UPDATE_METADATA_PATH } from "../config/constants.js"; +import { + DEFAULT_CODE_MODE_AGENT_FILES_POLICY, + resolveCodeModeAgentFilesPolicy, + type CodeModeAgentFilesPolicy, +} from "../config/code-mode.js"; import { createConnectorSynthesisGuidance } from "./ingestion.js"; import type { OpenWikiRunEvent } from "../agent/types.js"; @@ -26,24 +31,36 @@ export interface CodeModeRepoSetupOptions { createWorkflow?: boolean; /** Cron expression for a freshly created workflow. Defaults to {@link DEFAULT_CODE_MODE_CRON}. */ cronExpression?: string; + /** One-run override for the committed code-mode agent-file policy. */ + agentFilesPolicy?: CodeModeAgentFilesPolicy | null; } /** - * Ensure the repo is set up for code mode: refresh the managed agent-instruction - * snippets, and, when `options.createWorkflow` is set, create the scheduled-update - * workflow if it does not already exist. + * Ensure the repo is set up for code mode: optionally refresh the managed + * agent-instruction snippets and, when `options.createWorkflow` is set, create + * the scheduled-update workflow if it does not already exist. */ export async function ensureCodeModeRepoSetup( cwd: string, options: CodeModeRepoSetupOptions = {}, ): Promise { + const agentFiles = await resolveCodeModeAgentFilesPolicy( + cwd, + options.agentFilesPolicy, + ); + const scheduledAgentFilesPolicy = + agentFiles.configuredPolicy ?? DEFAULT_CODE_MODE_AGENT_FILES_POLICY; + if (options.createWorkflow) { await ensureCodeModeWorkflow( cwd, options.cronExpression ?? DEFAULT_CODE_MODE_CRON, + scheduledAgentFilesPolicy, ); } - await writeCodeModeAgentSnippets(cwd); + if (agentFiles.policy === "manage") { + await writeCodeModeAgentSnippets(cwd); + } } /** @@ -54,6 +71,7 @@ export async function ensureCodeModeRepoSetup( async function ensureCodeModeWorkflow( cwd: string, cronExpression: string, + agentFilesPolicy: CodeModeAgentFilesPolicy, ): Promise { const workflowPath = path.join( cwd, @@ -72,7 +90,11 @@ async function ensureCodeModeWorkflow( } await mkdir(path.dirname(workflowPath), { recursive: true }); - await writeFile(workflowPath, createCodeModeWorkflow(cronExpression), "utf8"); + await writeFile( + workflowPath, + createCodeModeWorkflow(cronExpression, agentFilesPolicy), + "utf8", + ); } /** @@ -228,7 +250,15 @@ async function prepareCodeModeAgentSnippet( }; } -function createCodeModeWorkflow(cronExpression: string): string { +function createCodeModeWorkflow( + cronExpression: string, + agentFilesPolicy: CodeModeAgentFilesPolicy, +): string { + const agentFilePaths = + agentFilesPolicy === "manage" + ? " AGENTS.md\n CLAUDE.md\n" + : ""; + return `name: OpenWiki Update on: @@ -281,9 +311,7 @@ jobs: with: add-paths: | openwiki - AGENTS.md - CLAUDE.md - .github/workflows/openwiki-update.yml +${agentFilePaths} .github/workflows/openwiki-update.yml branch: openwiki/update commit-message: "docs: update OpenWiki" title: "docs: update OpenWiki" diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 9292a754..5a2a5ed7 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -70,6 +70,10 @@ describe("parseCommand — help", () => { test("help documents the output language option", () => { expect(getHelpText()).toContain("-l, --language "); }); + + test("help documents the root agent-file opt-out", () => { + expect(getHelpText()).toContain("--agent-files-policy "); + }); }); describe("parseCommand — chat default", () => { @@ -85,6 +89,7 @@ describe("parseCommand — chat default", () => { userMessage: null, print: false, dryRun: false, + agentFilesPolicy: null, modelId: null, }); }); @@ -289,6 +294,67 @@ describe("parseCommand — init/update", () => { test("repeating the same command flag is allowed", () => { expect(parseCommand(["personal", "--init", "--init"]).kind).toBe("run"); }); + + for (const [name, argv, command] of [ + ["chat", ["code", "--agent-files-policy", "preserve"], "chat"], + ["init", ["code", "--init", "--agent-files-policy", "preserve"], "init"], + [ + "update", + ["code", "--update", "--agent-files-policy", "preserve"], + "update", + ], + ] as const) { + test(`--agent-files-policy applies to the ${name} command`, () => { + expect(parseCommand([...argv])).toMatchObject({ + agentFilesPolicy: "preserve", + command, + kind: "run", + mode: "code", + }); + }); + } + + test("--agent-files-policy accepts the equals form and explicit manage policy", () => { + expect( + parseCommand(["--update", "--agent-files-policy=manage"]), + ).toMatchObject({ + agentFilesPolicy: "manage", + command: "update", + kind: "run", + mode: "code", + }); + }); + + test("--agent-files-policy requires a policy", () => { + expect(parseCommand(["--update", "--agent-files-policy"])).toMatchObject({ + kind: "error", + message: "--agent-files-policy requires manage or preserve.", + }); + }); + + test("--agent-files-policy rejects an unknown policy", () => { + expect( + parseCommand(["--update", "--agent-files-policy", "ignore"]), + ).toMatchObject({ + kind: "error", + message: + "Invalid --agent-files-policy value: ignore. Expected manage or preserve.", + }); + }); + + test("--agent-files-policy is rejected in personal mode", () => { + expect( + parseCommand([ + "personal", + "--update", + "--agent-files-policy", + "preserve", + ]), + ).toMatchObject({ + kind: "error", + message: "--agent-files-policy is only available in code mode.", + }); + }); }); describe("parseCommand — print", () => { diff --git a/test/cli/run-mode.test.ts b/test/cli/run-mode.test.ts index 9180919c..0c74d5c1 100644 --- a/test/cli/run-mode.test.ts +++ b/test/cli/run-mode.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { argvRequestsPrint, + getCodeModeRepoSetupOptions, getRunModeCwd, getRunModeOutputMode, shouldAutoExitStartupRun, @@ -17,6 +18,7 @@ function runCommand( ): CliCommand { return { kind: "run", + agentFilesPolicy: null, command: "init", dryRun: false, exitCode: 0, @@ -127,6 +129,28 @@ describe("getRunModeOutputMode", () => { }); }); +describe("getCodeModeRepoSetupOptions", () => { + for (const [command, createWorkflow] of [ + ["chat", false], + ["init", true], + ["update", false], + ] as const) { + test(`forwards agent-file policy for ${command}`, () => { + expect( + getCodeModeRepoSetupOptions( + runCommand({ agentFilesPolicy: "preserve", command }) as Extract< + CliCommand, + { kind: "run" } + >, + ), + ).toEqual({ + agentFilesPolicy: "preserve", + createWorkflow, + }); + }); + } +}); + describe("shouldAutoExitStartupRun", () => { test("auto-exits a real init/update run that was asked to start", () => { expect(shouldAutoExitStartupRun(runCommand({ command: "init" }))).toBe( diff --git a/test/cli/runners.test.ts b/test/cli/runners.test.ts index 52c7d11c..1cf4edd8 100644 --- a/test/cli/runners.test.ts +++ b/test/cli/runners.test.ts @@ -497,6 +497,7 @@ describe("runPrintCommand", () => { await runPrintCommand( makeCommand("run", { + agentFilesPolicy: null, command: "update", dryRun: false, language: null, @@ -521,6 +522,7 @@ describe("runPrintCommand", () => { await runPrintCommand( makeCommand("run", { + agentFilesPolicy: null, command: "init", dryRun: false, language: null, @@ -536,6 +538,7 @@ describe("runPrintCommand", () => { ); expect(ensureCodeModeRepoSetup).toHaveBeenCalledWith(expect.any(String), { + agentFilesPolicy: null, createWorkflow: true, }); expect(runCodeModeConnectors).toHaveBeenCalled(); @@ -555,6 +558,7 @@ describe("runPrintCommand", () => { await runPrintCommand( makeCommand("run", { + agentFilesPolicy: null, command: "update", dryRun: false, language: null, diff --git a/test/config/code-mode.test.ts b/test/config/code-mode.test.ts new file mode 100644 index 00000000..9210ff9c --- /dev/null +++ b/test/config/code-mode.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + CODE_MODE_CONFIG_FILENAME, + resolveCodeModeAgentFilesPolicy, +} from "../../src/config/code-mode.ts"; +import { ensureCodeModeRepoSetup } from "../../src/ingestion/code-mode.ts"; + +const tempRepos: string[] = []; + +async function createTempRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-code-config-")); + tempRepos.push(repo); + return repo; +} + +async function writeConfig(repo: string, contents: string): Promise { + await writeFile(path.join(repo, CODE_MODE_CONFIG_FILENAME), contents, "utf8"); +} + +async function readIfPresent(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +afterEach(async () => { + await Promise.all( + tempRepos + .splice(0) + .map((repo) => rm(repo, { force: true, recursive: true })), + ); +}); + +describe("resolveCodeModeAgentFilesPolicy", () => { + test("defaults to manage when no config or CLI override exists", async () => { + const repo = await createTempRepo(); + + await expect(resolveCodeModeAgentFilesPolicy(repo)).resolves.toEqual({ + configuredPolicy: null, + policy: "manage", + source: "default", + }); + }); + + for (const policy of ["manage", "preserve"] as const) { + test(`reads ${policy} from committed config`, async () => { + const repo = await createTempRepo(); + await writeConfig( + repo, + `codeMode:\n agentFiles:\n policy: ${policy}\n`, + ); + + await expect(resolveCodeModeAgentFilesPolicy(repo)).resolves.toEqual({ + configuredPolicy: policy, + policy, + source: "config", + }); + }); + } + + for (const [configuredPolicy, cliPolicy] of [ + ["manage", "preserve"], + ["preserve", "manage"], + ] as const) { + test(`CLI ${cliPolicy} overrides config ${configuredPolicy}`, async () => { + const repo = await createTempRepo(); + await writeConfig( + repo, + `codeMode:\n agentFiles:\n policy: ${configuredPolicy}\n`, + ); + + await expect( + resolveCodeModeAgentFilesPolicy(repo, cliPolicy), + ).resolves.toEqual({ + configuredPolicy, + policy: cliPolicy, + source: "cli", + }); + }); + } + + test("a CLI override neither rewrites nor creates config", async () => { + const configuredRepo = await createTempRepo(); + const original = + "# Repository policy\ncodeMode:\n agentFiles:\n policy: manage\n"; + await writeConfig(configuredRepo, original); + + await ensureCodeModeRepoSetup(configuredRepo, { + agentFilesPolicy: "preserve", + }); + + expect( + await readFile( + path.join(configuredRepo, CODE_MODE_CONFIG_FILENAME), + "utf8", + ), + ).toBe(original); + + const unconfiguredRepo = await createTempRepo(); + await ensureCodeModeRepoSetup(unconfiguredRepo, { + agentFilesPolicy: "preserve", + }); + expect( + await readIfPresent( + path.join(unconfiguredRepo, CODE_MODE_CONFIG_FILENAME), + ), + ).toBeNull(); + }); + + test("rejects an unknown configured policy", async () => { + const repo = await createTempRepo(); + await writeConfig(repo, "codeMode:\n agentFiles:\n policy: ignore\n"); + + await expect(resolveCodeModeAgentFilesPolicy(repo)).rejects.toThrow( + "Invalid openwiki.config.yaml: codeMode.agentFiles.policy must be manage or preserve.", + ); + }); + + test("rejects a scalar agentFiles value", async () => { + const repo = await createTempRepo(); + await writeConfig(repo, "codeMode:\n agentFiles: preserve\n"); + + await expect(resolveCodeModeAgentFilesPolicy(repo)).rejects.toThrow( + "Invalid openwiki.config.yaml: codeMode.agentFiles must be a mapping.", + ); + }); + + test("rejects malformed YAML before applying a CLI override", async () => { + const repo = await createTempRepo(); + await writeConfig(repo, "codeMode: [\n"); + + await expect( + resolveCodeModeAgentFilesPolicy(repo, "preserve"), + ).rejects.toThrow("Invalid openwiki.config.yaml:"); + }); +}); diff --git a/test/ingestion/code-mode.test.ts b/test/ingestion/code-mode.test.ts index 89df9991..66719923 100644 --- a/test/ingestion/code-mode.test.ts +++ b/test/ingestion/code-mode.test.ts @@ -7,6 +7,7 @@ import { runCodeModeConnectors, } from "../../src/ingestion/code-mode.ts"; import type { OpenWikiRunEvent } from "../../src/agent/types.ts"; +import { CODE_MODE_CONFIG_FILENAME } from "../../src/config/code-mode.ts"; const SNIPPET_START = ""; const SNIPPET_END = ""; @@ -27,6 +28,17 @@ async function readIfPresent(filePath: string): Promise { } } +async function writeAgentFilesPolicy( + repo: string, + policy: "manage" | "preserve", +): Promise { + await writeFile( + path.join(repo, CODE_MODE_CONFIG_FILENAME), + `codeMode:\n agentFiles:\n policy: ${policy}\n`, + "utf8", + ); +} + afterEach(async () => { await Promise.all( tempRepos @@ -161,6 +173,66 @@ ${SNIPPET_END} expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toBeNull(); }); } + + test("preserve config leaves existing agent files byte-for-byte and wiki content untouched", async () => { + const repo = await createTempRepo(); + const agentsPath = path.join(repo, "AGENTS.md"); + const claudePath = path.join(repo, "CLAUDE.md"); + const wikiPath = path.join(repo, "openwiki", "index.md"); + const existingAgents = "# Existing AGENTS\n\nKeep this byte-for-byte.\n"; + const existingClaude = "# Existing CLAUDE\r\n\r\nKeep this too.\r\n"; + const existingWiki = "# Existing wiki\n"; + await writeAgentFilesPolicy(repo, "preserve"); + await mkdir(path.dirname(wikiPath), { recursive: true }); + await Promise.all([ + writeFile(agentsPath, existingAgents, "utf8"), + writeFile(claudePath, existingClaude, "utf8"), + writeFile(wikiPath, existingWiki, "utf8"), + ]); + + await ensureCodeModeRepoSetup(repo); + + expect(await readIfPresent(agentsPath)).toBe(existingAgents); + expect(await readIfPresent(claudePath)).toBe(existingClaude); + expect(await readIfPresent(wikiPath)).toBe(existingWiki); + }); + + test("preserve config leaves missing agent files missing", async () => { + const repo = await createTempRepo(); + await writeAgentFilesPolicy(repo, "preserve"); + + await ensureCodeModeRepoSetup(repo); + + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toBeNull(); + expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toBeNull(); + }); + + test("CLI preserve overrides a committed manage policy", async () => { + const repo = await createTempRepo(); + const agentsPath = path.join(repo, "AGENTS.md"); + const existing = "# Keep me\n"; + await writeAgentFilesPolicy(repo, "manage"); + await writeFile(agentsPath, existing, "utf8"); + + await ensureCodeModeRepoSetup(repo, { agentFilesPolicy: "preserve" }); + + expect(await readIfPresent(agentsPath)).toBe(existing); + expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toBeNull(); + }); + + test("CLI manage overrides a committed preserve policy", async () => { + const repo = await createTempRepo(); + await writeAgentFilesPolicy(repo, "preserve"); + + await ensureCodeModeRepoSetup(repo, { agentFilesPolicy: "manage" }); + + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toContain( + SNIPPET_START, + ); + expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toContain( + SNIPPET_START, + ); + }); }); describe("ensureCodeModeRepoSetup workflow", () => { @@ -184,6 +256,53 @@ describe("ensureCodeModeRepoSetup workflow", () => { } }); + test("generated workflow reads committed preserve config on later runs", async () => { + const repo = await createTempRepo(); + await writeAgentFilesPolicy(repo, "preserve"); + + await ensureCodeModeRepoSetup(repo, { + createWorkflow: true, + }); + + const workflow = await readIfPresent( + path.join(repo, ".github", "workflows", "openwiki-update.yml"), + ); + expect(workflow).toContain("run: openwiki code --update --print"); + expect(workflow).not.toContain("--agent-files-policy"); + expect(workflow).not.toContain(" AGENTS.md"); + expect(workflow).not.toContain(" CLAUDE.md"); + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toBeNull(); + expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toBeNull(); + + // A later scheduled invocation has no CLI override and resolves the same + // committed policy instead of recreating either root file. + await ensureCodeModeRepoSetup(repo); + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toBeNull(); + expect(await readIfPresent(path.join(repo, "CLAUDE.md"))).toBeNull(); + }); + + test("a one-run CLI override does not become scheduled policy", async () => { + const repo = await createTempRepo(); + + await ensureCodeModeRepoSetup(repo, { + agentFilesPolicy: "preserve", + createWorkflow: true, + }); + + const workflow = await readIfPresent( + path.join(repo, ".github", "workflows", "openwiki-update.yml"), + ); + expect(workflow).not.toContain("--agent-files-policy"); + expect(workflow).toContain(" AGENTS.md"); + expect(workflow).toContain(" CLAUDE.md"); + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toBeNull(); + + await ensureCodeModeRepoSetup(repo); + expect(await readIfPresent(path.join(repo, "AGENTS.md"))).toContain( + SNIPPET_START, + ); + }); + test("wires the LangSmith connector read key into the workflow env", async () => { const repo = await createTempRepo();