diff --git a/README.md b/README.md index 0f9e99d..f90807a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ This workspace now includes the first MVP slices: - Safe-corpus gating before semantic closure, indexes, and grounding - CLI commands for project init, validation, ID listing, reference checks, grounding, indexing, and demo +- Package-manager-neutral Agent bootstrap with managed Codex Skills, + repository instructions, updates, and diagnostics - OpenSpec `affects_domain` grounding - Explicit `required`, `not_required`, and `unclassified` grounding decisions - Advisory and enforced Grounding Assurance for Codex and CI @@ -64,10 +66,27 @@ Install the CLI from npm: ```bash npm install -g @echopath-labs/opendomain -opendomain init +opendomain init --tools codex +opendomain doctor opendomain validate ``` +The global npm installation is a CLI distribution channel. OpenDomain does not +create or modify the host project's `package.json`, lockfile, dependency list, +or npm scripts. `init --tools codex` adds the canonical `opendomain/` workspace, +generated `.codex/skills/opendomain-*` adapters, and one managed OpenDomain +block in `AGENTS.md`. Existing instructions outside that block remain owned by +the project. + +After initialization, users can ask Codex to explore or model the domain, +review a Candidate, or implement a change. The generated Skills and managed +instructions select the appropriate CLI operations; direct commands remain +available for CI and diagnostics. + +If workspace configuration later deselects an adapter, `doctor` reports any +remaining generated Skills and `update` removes only files that still carry +OpenDomain generation ownership metadata. + Or try it from a source checkout: Common commands: @@ -75,6 +94,9 @@ Common commands: ```bash npm run opendomain -- help npm run opendomain -- init +npm run opendomain -- init --tools codex +npm run opendomain -- update +npm run opendomain -- doctor npm run opendomain -- validate npm run prepare:demo (cd examples/erp && node ../../bin/opendomain.mjs assure openspec/changes/order-cancellation/spec.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 4ce47d9..0611c9b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -100,7 +100,9 @@ OpenDomain 适合: - 安全 parser 和 Draft 2020-12 Runtime Schema 校验; - 在 Semantic Closure、index 和 grounding 前执行安全语料门禁; - CLI 命令:init、validate、ids list、refs check、prepare、assure、integrations、 - index、demo; + index、update、doctor、demo; +- 不要求宿主 `package.json` 的 Agent bootstrap,以及受管 Codex Skills 与 + `AGENTS.md` 指令区块; - OpenSpec `affects_domain` grounding; - 显式 `required` / `not_required` / `unclassified` Grounding Request; - 面向 Codex 与 CI 的 advisory / enforced Grounding Assurance; @@ -136,10 +138,23 @@ OpenDomain 的 npm 包名是 `@echopath-labs/opendomain`,CLI 命令是 `opendo ```bash npm install -g @echopath-labs/opendomain -opendomain init +opendomain init --tools codex +opendomain doctor opendomain validate ``` +npm 在这里仅是 CLI 的全局分发渠道。OpenDomain 不会在宿主项目中创建或修改 +`package.json`、lockfile、依赖声明或 npm scripts。`init --tools codex` 会创建 +canonical `opendomain/`、生成 `.codex/skills/opendomain-*`,并在 `AGENTS.md` +中维护一个有明确边界的 OpenDomain 区块;区块之外的项目指令保持原样。 + +初始化后,用户可以直接要求 Codex 浏览或建模业务、审查 Candidate,或者实现一项 +变更。生成的 Skills 和托管指令负责选择 CLI 操作;直接命令主要保留给 CI、诊断和 +高级使用。 + +如果 workspace config 后续取消选择某个 adapter,`doctor` 会报告残留的 generated +Skills,`update` 只移除仍带 OpenDomain generation ownership metadata 的文件。 + 也可以从源码运行。 克隆仓库: @@ -159,6 +174,9 @@ npm run opendomain -- help ```bash npm run opendomain -- init +npm run opendomain -- init --tools codex +npm run opendomain -- update +npm run opendomain -- doctor ``` 运行测试: @@ -250,7 +268,9 @@ OpenSpec 描述这次变更,OpenDomain 描述长期语义。 ### 3. Codex 先 grounding 再实现 -在实现非平凡 Feature 前,Codex 默认执行只读 Assurance: +在执行 `opendomain init --tools codex` 后,repository-local Codex Skills 会分别处理 +只读领域探索、Candidate-first 建模和 Candidate 审查;`AGENTS.md` 托管区块要求 +Codex 在实现非平凡 Feature 前默认执行只读 Assurance: ```bash npm run opendomain -- assure @@ -380,8 +400,10 @@ Candidate 不是 accepted truth。它只是待人类审查的提案。 | 目标 | 命令 | | --- | --- | -| 查看帮助 | `npm run opendomain -- help` | -| 初始化 OpenDomain 目录 | `npm run opendomain -- init` | +| 查看帮助 | `opendomain help` | +| 初始化 OpenDomain 与 Codex | `opendomain init --tools codex` | +| 更新托管 Agent 适配 | `opendomain update` | +| 检查 workspace 与 Agent 适配 | `opendomain doctor` | | 复制 ERP 示例 | `npm run opendomain -- init --example erp` | | 验证全部 OpenDomain 文件 | `npm run opendomain -- validate` | | 验证指定目录 | `npm run opendomain -- validate examples/erp` | diff --git a/opendomain/candidates/candidate-0012-agent-native-project-adoption.md b/opendomain/candidates/candidate-0012-agent-native-project-adoption.md new file mode 100644 index 0000000..ef72fef --- /dev/null +++ b/opendomain/candidates/candidate-0012-agent-native-project-adoption.md @@ -0,0 +1,56 @@ +--- +type: domain_candidate +id: candidate-0012-agent-native-project-adoption +status: proposed +proposed_change_type: add_rule +target: + type: business_rule + id: opendomain.project-adoption-must-not-require-host-package-manifest +confidence: high +extracted_by: codex +extracted_at: 2026-08-03 +evidence: + - type: human_review + location: README.zh-CN.md + summary: The maintainer requires OpenDomain adoption to avoid forcing npm scripts or a package.json into host projects. + confidence: high + - type: code + location: src/init.mjs + summary: Project initialization creates canonical semantic and Agent integration files without modifying host package metadata. + confidence: high + - type: test + location: tests/agent-workspace.test.mjs + summary: Conformance tests require Codex integration to initialize and update without creating package.json or package-lock.json. + confidence: high +possible_conflicts: + - The OpenDomain CLI is still distributed through npm and requires a user-level Node.js runtime until standalone binaries are released. + - Agent-specific repository files remain necessary even though host language package metadata is not. + - Homebrew and standalone binary delivery are separate changes and have not yet provided external adoption evidence. +review: + state: proposed + suggested_reviewer: opendomain-maintainer +--- + +# Candidate: Agent-native Project Adoption + +## Proposed Rule + +Adopting OpenDomain in a project must not require that project to create or +modify a package manifest, package-manager lockfile, or npm script. Runtime +installation belongs to the user's tool environment; the project contains only +canonical OpenDomain sources and explicitly selected repository-local Agent +integration files. + +## Agent Workflow Meaning + +Humans should be able to state their goal in natural language. Agent adapters +select deterministic OpenDomain CLI operations for exploration, modeling, +Candidate review, and implementation grounding. Direct CLI commands remain +available for CI, debugging, and advanced use, but they are not the primary +human workflow. + +## Requested Human Review + +Keep this rule proposed until the Codex bootstrap, standalone binary, Homebrew +installation, and at least one external project adoption confirm that a +package-manager-neutral project workspace remains practical across upgrades. diff --git a/schemas/workspace-config.schema.json b/schemas/workspace-config.schema.json new file mode 100644 index 0000000..6fabf30 --- /dev/null +++ b/schemas/workspace-config.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opendomain.dev/schemas/workspace-config.schema.json", + "title": "OpenDomain Workspace Integration Configuration v1", + "type": "object", + "required": ["schema_version", "agent_integration"], + "properties": { + "schema_version": { + "const": "1" + }, + "agent_integration": { + "type": "object", + "required": ["adapter_version", "tools"], + "properties": { + "adapter_version": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "tools": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["codex"] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index 8d3a871..ce79541 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -4,6 +4,7 @@ import { access, mkdtemp, mkdir, + readFile, rm, writeFile } from "node:fs/promises"; @@ -49,13 +50,31 @@ try { "opendomain" ); const cli = path.join(installedRoot, "bin", "opendomain.mjs"); + const hostPackageFile = path.join(consumer, "package.json"); + const hostLockFile = path.join(consumer, "package-lock.json"); + const hostPackageBefore = await readFile(hostPackageFile, "utf8"); + const hostLockBefore = await readFile(hostLockFile, "utf8"); await access(path.join(installedRoot, "schemas", "integration-profile.schema.json")); await access(path.join(installedRoot, "schemas", "domain-declaration.schema.json")); await access(path.join(installedRoot, "schemas", "assurance-result.schema.json")); + await access(path.join(installedRoot, "schemas", "workspace-config.schema.json")); await access(path.join(installedRoot, "scripts", "smoke-installed-package.mjs")); - const init = await runJsonCli(cli, ["init", "--example", "erp", "--json"], consumer); + const init = await runJsonCli(cli, [ + "init", + "--tools", + "codex", + "--example", + "erp", + "--json" + ], consumer); assert.deepEqual(init.errors, []); + assert.equal(await readFile(hostPackageFile, "utf8"), hostPackageBefore); + assert.equal(await readFile(hostLockFile, "utf8"), hostLockBefore); + await access(path.join(consumer, ".codex", "skills", "opendomain-explore", "SKILL.md")); + const doctor = await runJsonCli(cli, ["doctor", "--json"], consumer); + assert.equal(doctor.status, "healthy"); + assert.deepEqual(doctor.errors, []); const exampleRoot = path.join(consumer, "examples", "erp"); const inspection = await runJsonCli( @@ -102,6 +121,7 @@ try { `Installed-package smoke passed: ${packPayload[0].filename}, ` + `${inspection.valid_profile_count} Profile, ` + `${automatic.read_first.length} grounded sources, ` + + `Agent integration ${doctor.status}, ` + `Assurance ${assurance.policy.outcome}.\n` ); } finally { diff --git a/src/agent-resources.mjs b/src/agent-resources.mjs new file mode 100644 index 0000000..6b0ccfe --- /dev/null +++ b/src/agent-resources.mjs @@ -0,0 +1,101 @@ +import { stringify as stringifyYaml } from "yaml"; + +export const AGENT_ADAPTER_VERSION = "1"; +export const WORKSPACE_CONFIG_SCHEMA_VERSION = "1"; +export const SUPPORTED_AGENT_TOOLS = Object.freeze(["codex"]); + +const CODEX_SKILLS = Object.freeze([ + Object.freeze({ + name: "opendomain-explore", + description: "Explore accepted OpenDomain semantics and Candidate boundaries without changing the domain model.", + body: `Explore the project's OpenDomain model without mutating it. + +1. Run \`opendomain validate\` before relying on workspace sources. +2. Use \`opendomain ids list\` or the semantic index to find the smallest relevant accepted sources. +3. Read accepted concepts, rules, lifecycles, events, and their evidence. +4. Keep every Domain Candidate visibly separate from accepted knowledge. +5. Report gaps or conflicts; do not silently resolve or promote them. +` + }), + Object.freeze({ + name: "opendomain-model", + description: "Build or refine an OpenDomain model while keeping inferred knowledge in Candidate form until human review.", + body: `Build or refine the project's long-lived domain model. + +1. Separate stable business semantics from delivery intent and implementation details. +2. Read existing accepted sources and evidence before proposing changes. +3. Record uncertain, inferred, or conflicting knowledge as a Domain Candidate first. +4. Run \`opendomain validate\` after changing OpenDomain files. +5. Never accept a Candidate without an explicit human review decision. +` + }), + Object.freeze({ + name: "opendomain-review", + description: "Review OpenDomain Candidates with evidence and conflicts while preserving explicit human decision ownership.", + body: `Review a Domain Candidate without treating it as accepted truth. + +1. Use \`opendomain candidate show \` to inspect the proposal. +2. Read its evidence, target, confidence, conflicts, and existing accepted sources. +3. Explain compatibility impact and unresolved uncertainty to the human reviewer. +4. Invoke \`opendomain candidate review\` only after the human explicitly chooses a decision, reviewer, and reason. +5. Run \`opendomain validate\` after a review mutation. +` + }) +]); + +export function workspaceConfigTemplate(tools = []) { + return stringifyYaml({ + schema_version: WORKSPACE_CONFIG_SCHEMA_VERSION, + agent_integration: { + adapter_version: AGENT_ADAPTER_VERSION, + tools + } + }, { lineWidth: 0 }); +} + +export function managedAgentsTemplate() { + return ` +## OpenDomain + +This repository uses OpenDomain for long-lived business semantics. + +Before implementing a non-trivial change with an applicable Source Unit, run: + +\`\`\`bash +opendomain assure +\`\`\` + +Read every accepted source listed in \`grounding_pack.read_first\`. Treat +\`grounding_pack.candidate_boundaries\` as proposed knowledge, never accepted +truth. Report the accepted IDs and Candidate boundaries used when completing +the task. + +AI-inferred domain knowledge starts as a Domain Candidate. Human reviewers own +acceptance, rejection, risk decisions, and final validation. + +`; +} + +export function agentSkillResources(tools = []) { + if (!tools.includes("codex")) { + return []; + } + + return allAgentSkillResources(); +} + +export function allAgentSkillResources() { + return CODEX_SKILLS.map((skill) => ({ + path: `.codex/skills/${skill.name}/SKILL.md`, + content: `--- +name: ${skill.name} +description: ${skill.description} +compatibility: Requires the opendomain CLI. +metadata: + generatedBy: opendomain + adapterVersion: "${AGENT_ADAPTER_VERSION}" +--- + +${skill.body}` + })); +} diff --git a/src/atomic-write.mjs b/src/atomic-write.mjs new file mode 100644 index 0000000..8cc0796 --- /dev/null +++ b/src/atomic-write.mjs @@ -0,0 +1,38 @@ +import { randomUUID } from "node:crypto"; +import { lstat, open, rename, rm } from "node:fs/promises"; + +export async function atomicWriteUtf8(file, content) { + const temporary = `${file}.opendomain-${process.pid}-${randomUUID()}.tmp`; + const existingMode = await regularFileMode(file); + let handle = null; + + try { + handle = await open(temporary, "wx", existingMode ?? 0o666); + await handle.writeFile(content, { encoding: "utf8" }); + if (existingMode !== null) { + await handle.chmod(existingMode); + } + await handle.close(); + handle = null; + await rename(temporary, file); + } catch (error) { + await handle?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + +async function regularFileMode(file) { + try { + const fileStat = await lstat(file); + if (!fileStat.isFile()) { + throw new Error(`Atomic write target '${file}' is not a regular file.`); + } + return fileStat.mode & 0o7777; + } catch (error) { + if (error.code === "ENOENT") { + return null; + } + throw error; + } +} diff --git a/src/cli.mjs b/src/cli.mjs index 9c17a29..4c51e33 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -10,6 +10,8 @@ import { invalidAssuranceResult } from "./assurance.mjs"; import { initializeProject } from "./init.mjs"; +import { doctorWorkspaceIntegration } from "./doctor.mjs"; +import { updateWorkspaceIntegration } from "./update.mjs"; import { listCandidates, reviewCandidate, showCandidate } from "./candidates.mjs"; import { inspectIntegrations } from "./profile-registry.mjs"; import { @@ -53,6 +55,14 @@ export async function runCli(argv, options = {}) { return runInit([subcommand, ...rest].filter(Boolean), io); } + if (command === "update") { + return runUpdate([subcommand, ...rest].filter(Boolean), io); + } + + if (command === "doctor") { + return runDoctor([subcommand, ...rest].filter(Boolean), io); + } + if (command === "index" && subcommand === "build") { return runIndexBuild(rest, io); } @@ -94,7 +104,9 @@ function printHelp(stream) { stream.write(`OpenDomain CLI Usage: - opendomain init [--example erp] [--json] + opendomain init [--tools codex] [--example erp] [--json] + opendomain update [--json] + opendomain doctor [--json] opendomain validate [path] [--json] opendomain prepare [--integration openspec | --profile ] [--json] opendomain assure [--integration openspec | --profile ] [--mode advisory|enforced] [--json] @@ -133,7 +145,10 @@ async function runInit(args, io) { const result = { target: io.cwd, example: parsed.example ?? null, + tools: parsed.tools, created: [], + updated: [], + removed: [], skipped: [], warnings: [], errors: parsed.errors, @@ -147,7 +162,11 @@ async function runInit(args, io) { return 1; } - const result = await initializeProject({ cwd: io.cwd, example: parsed.example }); + const result = await initializeProject({ + cwd: io.cwd, + example: parsed.example, + tools: parsed.tools + }); if (parsed.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { @@ -157,6 +176,73 @@ async function runInit(args, io) { return result.errors.length > 0 ? 1 : 0; } +async function runUpdate(args, io) { + const parsed = parseJsonOnlyArgs(args, "update"); + const result = parsed.errors.length > 0 + ? emptyWorkspaceIntegrationResult(io.cwd, parsed.errors) + : await updateWorkspaceIntegration({ cwd: io.cwd }); + + if (parsed.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printWorkspaceIntegrationResult("update", result, io.stdout); + } + return result.errors.length > 0 ? 1 : 0; +} + +async function runDoctor(args, io) { + const parsed = parseJsonOnlyArgs(args, "doctor"); + const result = parsed.errors.length > 0 + ? { + status: "unhealthy", + target: io.cwd, + tools: [], + checks: [], + warnings: [], + errors: parsed.errors + } + : await doctorWorkspaceIntegration({ cwd: io.cwd }); + + if (parsed.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printDoctorResult(result, io.stdout); + } + return result.errors.length > 0 ? 1 : 0; +} + +function parseJsonOnlyArgs(args, command) { + const parsed = { json: false, errors: [] }; + for (const arg of args) { + if (arg === "--json") { + parsed.json = true; + continue; + } + parsed.errors.push({ + severity: "error", + file: "", + field: "$", + problem: `Unknown ${command} argument '${arg}'.`, + fix: `Run opendomain ${command} or opendomain ${command} --json.` + }); + } + return parsed; +} + +function emptyWorkspaceIntegrationResult(target, errors) { + return { + target, + tools: [], + created: [], + updated: [], + removed: [], + skipped: [], + warnings: [], + errors, + next_steps: [] + }; +} + async function runValidate(args, io) { const { json, paths } = splitArgs(args); const result = await validatePath(paths[0], { cwd: io.cwd }); @@ -229,6 +315,7 @@ function parseInitArgs(args) { const parsed = { json: false, example: undefined, + tools: undefined, errors: [] }; @@ -239,9 +326,8 @@ function parseInitArgs(args) { continue; } if (arg === "--example") { - parsed.example = args[index + 1]; - index += 1; - if (!parsed.example) { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { parsed.errors.push({ severity: "error", file: "", @@ -249,6 +335,34 @@ function parseInitArgs(args) { problem: "Missing example name.", fix: "Use --example erp." }); + } else { + parsed.example = value; + index += 1; + } + continue; + } + if (arg === "--tools") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + parsed.errors.push({ + severity: "error", + file: "", + field: "tools", + problem: "Missing Agent tool selection.", + fix: "Use --tools codex or omit --tools." + }); + } else if (value !== "codex") { + index += 1; + parsed.errors.push({ + severity: "error", + file: "", + field: "tools", + problem: `Unsupported Agent tool '${value}'.`, + fix: "Use --tools codex or omit --tools." + }); + } else { + index += 1; + parsed.tools = [value]; } continue; } @@ -258,7 +372,7 @@ function parseInitArgs(args) { file: "", field: "$", problem: `Unknown init argument '${arg}'.`, - fix: "Run opendomain init, opendomain init --example erp, or add --json." + fix: "Run opendomain init, use --tools codex, use --example erp, or add --json." }); } @@ -981,6 +1095,24 @@ function printInitResult(result, stream) { } } + stream.write("\nUpdated:\n"); + if ((result.updated ?? []).length === 0) { + stream.write("- None\n"); + } else { + for (const item of result.updated) { + stream.write(`- ${item.path}\n`); + } + } + + stream.write("\nRemoved:\n"); + if ((result.removed ?? []).length === 0) { + stream.write("- None\n"); + } else { + for (const item of result.removed) { + stream.write(`- ${item.path}\n`); + } + } + stream.write("\nNext steps:\n"); for (const step of result.next_steps) { stream.write(`- ${step}\n`); @@ -994,6 +1126,43 @@ function printInitResult(result, stream) { } } +function printWorkspaceIntegrationResult(command, result, stream) { + if (result.errors.length > 0) { + stream.write(`OpenDomain ${command} failed: ${result.errors.length} errors.\n`); + for (const issue of [...result.errors, ...(result.warnings ?? [])]) { + stream.write(`\n[${issue.severity}] ${issue.file}\n`); + stream.write(` field: ${issue.field}\n`); + stream.write(` problem: ${issue.problem}\n`); + stream.write(` fix: ${issue.fix}\n`); + } + return; + } + + stream.write(`OpenDomain ${command} completed.\n`); + for (const label of ["created", "updated", "removed", "skipped"]) { + const items = result[label] ?? []; + stream.write(`\n${label[0].toUpperCase()}${label.slice(1)}:\n`); + if (items.length === 0) { + stream.write("- None\n"); + } else { + for (const item of items) { + stream.write(`- ${item.path}${item.reason ? ` (${item.reason})` : ""}\n`); + } + } + } +} + +function printDoctorResult(result, stream) { + stream.write(`OpenDomain doctor: ${result.status}.\n`); + for (const check of result.checks) { + stream.write(`- [pass] ${check.file}\n`); + } + for (const issue of [...result.errors, ...result.warnings]) { + stream.write(`- [${issue.severity}] ${issue.file}: ${issue.problem}\n`); + stream.write(` fix: ${issue.fix}\n`); + } +} + function printIndexQueryResult(result, stream) { if (result.errors.length > 0) { stream.write(`Semantic Retrieval Index query failed: ${result.errors.length} errors.\n`); diff --git a/src/doctor.mjs b/src/doctor.mjs new file mode 100644 index 0000000..c6b7bf2 --- /dev/null +++ b/src/doctor.mjs @@ -0,0 +1,105 @@ +import { planAgentSkills } from "./managed-agent-skills.mjs"; +import { planManagedAgents } from "./managed-agents.mjs"; +import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; +import { planWorkspaceConfig } from "./workspace-config.mjs"; + +export async function doctorWorkspaceIntegration(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const result = { + status: "unhealthy", + target: cwd, + tools: [], + checks: [], + warnings: [], + errors: [] + }; + + const workspace = await inspectWorkspaceRoots({ cwd }); + result.warnings.push(...workspace.warnings); + result.errors.push(...workspace.errors); + if (workspace.mode === "canonical" || workspace.mode === "dual") { + result.checks.push(passCheck("workspace", "opendomain/")); + } else if (result.errors.length === 0) { + result.errors.push(failedIssue( + "opendomain/", + "$", + "Canonical OpenDomain workspace is not available.", + "Run opendomain init --tools codex." + )); + } + if (result.errors.length > 0) { + return result; + } + + const configPlan = await planWorkspaceConfig(cwd); + if (configPlan.action === "create") { + result.errors.push(failedIssue( + "opendomain/config.yaml", + "$", + "Workspace integration configuration is missing.", + "Run opendomain init --tools codex." + )); + return result; + } + if (configPlan.errors.length > 0) { + result.errors.push(...configPlan.errors); + return result; + } + result.tools = configPlan.tools; + if (configPlan.action === "skip") { + result.checks.push(passCheck("config", "opendomain/config.yaml")); + } else { + result.errors.push(failedIssue( + "opendomain/config.yaml", + "$", + "Workspace integration configuration is not normalized for this contract version.", + "Run opendomain update." + )); + } + + const agentsPlan = await planManagedAgents(cwd); + if (agentsPlan.errors.length > 0) { + result.errors.push(...agentsPlan.errors); + } else if (agentsPlan.action === "skip") { + result.checks.push(passCheck("managed_agents", "AGENTS.md")); + } else { + result.errors.push(failedIssue( + "AGENTS.md", + "$", + agentsPlan.action === "create" + ? "OpenDomain managed instruction block is missing." + : "OpenDomain managed instruction block is stale.", + "Run opendomain update." + )); + } + + const skillPlans = await planAgentSkills(cwd, result.tools); + result.errors.push(...skillPlans.errors); + for (const plan of skillPlans.plans) { + if (plan.action === "skip") { + result.checks.push(passCheck("agent_skill", plan.path)); + } else { + result.errors.push(failedIssue( + plan.path, + "$", + plan.action === "create" + ? `Configured Agent Skill '${plan.path}' is missing.` + : plan.action === "remove" + ? `OpenDomain-generated Agent Skill '${plan.path}' is no longer selected.` + : `Configured Agent Skill '${plan.path}' is stale.`, + "Run opendomain update." + )); + } + } + + result.status = result.errors.length === 0 ? "healthy" : "unhealthy"; + return result; +} + +function passCheck(id, file) { + return { id, status: "pass", file }; +} + +function failedIssue(file, field, problem, fix) { + return { severity: "error", file, field, problem, fix }; +} diff --git a/src/init.mjs b/src/init.mjs index fade9dc..c5ab923 100644 --- a/src/init.mjs +++ b/src/init.mjs @@ -1,7 +1,10 @@ import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { applyAgentSkills, planAgentSkills } from "./managed-agent-skills.mjs"; +import { applyManagedAgents, planManagedAgents } from "./managed-agents.mjs"; import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; +import { applyWorkspaceConfig, planWorkspaceConfig } from "./workspace-config.mjs"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -23,7 +26,10 @@ export async function initializeProject(options = {}) { const result = { target: cwd, example: options.example ?? null, + tools: options.tools ?? [], created: [], + updated: [], + removed: [], skipped: [], warnings: [], errors: [], @@ -41,6 +47,23 @@ export async function initializeProject(options = {}) { return result; } + const agentsPlan = await planManagedAgents(cwd); + if (agentsPlan.errors.length > 0) { + result.errors.push(...agentsPlan.errors); + return result; + } + const configPlan = await planWorkspaceConfig(cwd, options.tools); + if (configPlan.errors.length > 0) { + result.errors.push(...configPlan.errors); + return result; + } + result.tools = configPlan.tools; + const skillPlans = await planAgentSkills(cwd, result.tools); + if (skillPlans.errors.length > 0) { + result.errors.push(...skillPlans.errors); + return result; + } + const workspace = await inspectWorkspaceRoots({ cwd }); result.warnings.push(...workspace.warnings); result.errors.push(...workspace.errors); @@ -77,7 +100,9 @@ export async function initializeProject(options = {}) { cwd, result ); - await writeFileIfMissing(path.join(cwd, "AGENTS.md"), agentsTemplate(), cwd, result); + await applyWorkspaceConfig(configPlan, result); + await applyManagedAgents(agentsPlan, result); + await applyAgentSkills(skillPlans, result); if (options.example) { await copyExample(options.example, cwd, result); @@ -308,28 +333,3 @@ Confirm the business meaning, evidence, owner, and compatibility impact before promoting any content into accepted OpenDomain files. `; } - -function agentsTemplate() { - return `# Repository Agent Instructions - -This repository uses OpenDomain for long-lived domain semantics. - -Before implementing a non-trivial OpenSpec-style feature, run: - -\`\`\`bash -opendomain assure -\`\`\` - -Read files listed under \`Accepted grounding evidence\`. Treat files listed -under \`Candidate boundaries\` according to their review status, never as -accepted truth. A warning permits advisory progress but does not mean grounding -is complete. - -Boundaries: - -- OpenDomain stores stable business semantics. -- OpenSpec stores change intent, requirements, tasks, and acceptance criteria. -- AI-inferred domain knowledge starts as a Domain Candidate. -- Accepted domain knowledge requires evidence and human review. -`; -} diff --git a/src/integration-schema-validator.mjs b/src/integration-schema-validator.mjs index 15ddec4..4301da6 100644 --- a/src/integration-schema-validator.mjs +++ b/src/integration-schema-validator.mjs @@ -11,7 +11,8 @@ const SCHEMA_DEFINITIONS = Object.freeze({ declaration: "domain-declaration.schema.json", request: "grounding-request.schema.json", pack: "grounding-pack.schema.json", - assurance: "assurance-result.schema.json" + assurance: "assurance-result.schema.json", + workspace: "workspace-config.schema.json" }); let defaultRegistry; diff --git a/src/managed-agent-skills.mjs b/src/managed-agent-skills.mjs new file mode 100644 index 0000000..82a3cc0 --- /dev/null +++ b/src/managed-agent-skills.mjs @@ -0,0 +1,119 @@ +import { readFile, unlink } from "node:fs/promises"; +import { + agentSkillResources, + allAgentSkillResources +} from "./agent-resources.mjs"; +import { atomicWriteUtf8 } from "./atomic-write.mjs"; +import { parseMarkdown } from "./frontmatter.mjs"; +import { + ensureManagedFileParent, + inspectManagedFilePath +} from "./managed-path.mjs"; + +export async function planAgentSkills(cwd, tools) { + const plans = []; + const errors = []; + const desiredPaths = new Set( + agentSkillResources(tools).map((resource) => resource.path) + ); + + for (const resource of allAgentSkillResources()) { + const desired = desiredPaths.has(resource.path); + const managedPath = await inspectManagedFilePath(cwd, resource.path); + if (managedPath.issue) { + errors.push(managedPath.issue); + continue; + } + const file = managedPath.file; + if (!managedPath.exists) { + if (desired) { + plans.push({ + ...resource, + file, + projectRoot: managedPath.projectRoot, + action: "create" + }); + } + continue; + } + + let current; + try { + current = await readFile(file, "utf8"); + } catch (error) { + errors.push(unreadableIssue(resource.path, error)); + continue; + } + let parsed; + try { + parsed = parseMarkdown(current, resource.path); + } catch { + if (desired) { + errors.push(ownershipIssue(resource.path)); + } + continue; + } + + if (parsed.frontmatter.metadata?.generatedBy !== "opendomain") { + if (desired) { + errors.push(ownershipIssue(resource.path)); + } + continue; + } + + plans.push({ + ...resource, + file, + projectRoot: managedPath.projectRoot, + action: desired + ? current === resource.content ? "skip" : "update" + : "remove" + }); + } + + return { plans, errors }; +} + +export async function applyAgentSkills(planResult, result) { + for (const plan of planResult.plans) { + if (plan.action === "remove") { + await ensureManagedFileParent(plan.projectRoot, plan.path); + await unlink(plan.file); + result.removed.push({ path: plan.path, kind: "file" }); + continue; + } + if (plan.action === "skip") { + result.skipped.push({ path: plan.path, reason: "managed content is current" }); + continue; + } + + await ensureManagedFileParent(plan.projectRoot, plan.path); + await atomicWriteUtf8(plan.file, plan.content); + + if (plan.action === "create") { + result.created.push({ path: plan.path, kind: "file" }); + } else { + result.updated.push({ path: plan.path, kind: "file" }); + } + } +} + +function ownershipIssue(file) { + return { + severity: "error", + file, + field: "metadata.generatedBy", + problem: `Existing '${file}' is not OpenDomain-generated.`, + fix: "Move or rename the user-owned file before enabling this OpenDomain Agent adapter." + }; +} + +function unreadableIssue(file, error) { + return { + severity: "error", + file, + field: "$", + problem: `Managed Agent file '${file}' cannot be read: ${error.message}`, + fix: "Restore read access to the managed Agent file before retrying." + }; +} diff --git a/src/managed-agents.mjs b/src/managed-agents.mjs new file mode 100644 index 0000000..97c2f2c --- /dev/null +++ b/src/managed-agents.mjs @@ -0,0 +1,119 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; +import { managedAgentsTemplate } from "./agent-resources.mjs"; +import { atomicWriteUtf8 } from "./atomic-write.mjs"; + +export const AGENTS_START_MARKER = ""; +export const AGENTS_END_MARKER = ""; + +export async function planManagedAgents(cwd) { + const file = path.join(cwd, "AGENTS.md"); + let exists = false; + let current = ""; + try { + const fileStat = await lstat(file); + exists = true; + if (!fileStat.isFile()) { + return invalidFilePlan(file, "AGENTS.md is not a regular file."); + } + current = await readFile(file, "utf8"); + } catch (error) { + if (error.code !== "ENOENT") { + return invalidFilePlan(file, `AGENTS.md cannot be inspected or read: ${error.message}`); + } + } + const starts = markerIndexes(current, AGENTS_START_MARKER); + const ends = markerIndexes(current, AGENTS_END_MARKER); + + if (starts.length > 1 || ends.length > 1 || starts.length !== ends.length) { + return invalidPlan(file); + } + if (starts.length === 1 && starts[0] > ends[0]) { + return invalidPlan(file); + } + + const template = managedAgentsTemplate().trimEnd(); + let content; + if (starts.length === 0) { + const separator = current.length === 0 + ? "" + : current.endsWith("\n\n") + ? "" + : current.endsWith("\n") + ? "\n" + : "\n\n"; + content = `${current}${separator}${template}\n`; + } else { + const end = ends[0] + AGENTS_END_MARKER.length; + content = `${current.slice(0, starts[0])}${template}${current.slice(end)}`; + } + + return { + file, + relativePath: "AGENTS.md", + action: !exists ? "create" : content === current ? "skip" : "update", + content, + errors: [] + }; +} + +export async function applyManagedAgents(plan, result) { + if (plan.action === "skip") { + result.skipped.push({ path: plan.relativePath, reason: "managed content is current" }); + return; + } + + await atomicWriteUtf8(plan.file, plan.content); + + if (plan.action === "create") { + result.created.push({ path: plan.relativePath, kind: "file" }); + } else { + result.updated.push({ path: plan.relativePath, kind: "file" }); + } +} + +function invalidPlan(file) { + return { + file, + relativePath: "AGENTS.md", + action: "invalid", + content: null, + errors: [{ + severity: "error", + file: "AGENTS.md", + field: "$", + problem: "OpenDomain managed markers are malformed or ambiguous.", + fix: `Keep exactly one '${AGENTS_START_MARKER}' followed by exactly one '${AGENTS_END_MARKER}', or remove both markers before retrying.` + }] + }; +} + +function invalidFilePlan(file, problem) { + return { + file, + relativePath: "AGENTS.md", + action: "invalid", + content: null, + errors: [{ + severity: "error", + file: "AGENTS.md", + field: "$", + problem, + fix: "Replace AGENTS.md with a readable regular file or remove the path before retrying." + }] + }; +} + +function markerIndexes(value, marker) { + const indexes = []; + let cursor = 0; + while (cursor <= value.length) { + const index = value.indexOf(marker, cursor); + if (index === -1) { + break; + } + indexes.push(index); + cursor = index + marker.length; + } + return indexes; +} diff --git a/src/managed-path.mjs b/src/managed-path.mjs new file mode 100644 index 0000000..b049a9d --- /dev/null +++ b/src/managed-path.mjs @@ -0,0 +1,149 @@ +import { lstat, mkdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +export async function inspectManagedFilePath(cwd, relativeFile) { + const projectRoot = await realpath(cwd); + const segments = managedSegments(relativeFile); + const parentSegments = segments.slice(0, -1); + let current = projectRoot; + + for (let index = 0; index < parentSegments.length; index += 1) { + current = path.join(current, parentSegments[index]); + const inspection = await inspectExistingDirectory( + current, + relativeFile, + parentSegments.slice(0, index + 1).join("/") + ); + if (inspection.issue) { + return { + projectRoot, + file: path.join(projectRoot, ...segments), + exists: inspection.exists, + issue: inspection.issue + }; + } + if (!inspection.exists) { + return { + projectRoot, + file: path.join(projectRoot, ...segments), + exists: false, + issue: null + }; + } + } + + const file = path.join(projectRoot, ...segments); + let exists = false; + try { + const fileStat = await lstat(file); + exists = true; + if (fileStat.isSymbolicLink()) { + return { + projectRoot, + file, + exists, + issue: managedPathIssue(relativeFile, `Managed Agent file '${relativeFile}' is a symbolic link.`) + }; + } + if (!fileStat.isFile()) { + return { + projectRoot, + file, + exists, + issue: managedPathIssue(relativeFile, `Managed Agent file '${relativeFile}' is not a regular file.`) + }; + } + } catch (error) { + if (error.code !== "ENOENT") { + return { + projectRoot, + file, + exists, + issue: managedPathIssue(relativeFile, `Managed Agent file cannot be inspected: ${error.message}`) + }; + } + } + + return { projectRoot, file, exists, issue: null }; +} + +export async function ensureManagedFileParent(projectRoot, relativeFile) { + const segments = managedSegments(relativeFile).slice(0, -1); + let current = projectRoot; + + for (let index = 0; index < segments.length; index += 1) { + current = path.join(current, segments[index]); + const displayPath = segments.slice(0, index + 1).join("/"); + let inspection = await inspectExistingDirectory(current, relativeFile, displayPath); + if (!inspection.exists) { + try { + await mkdir(current); + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + } + inspection = await inspectExistingDirectory(current, relativeFile, displayPath); + } + if (inspection.issue) { + const error = new Error(inspection.issue.problem); + error.issue = inspection.issue; + throw error; + } + } +} + +async function inspectExistingDirectory(directory, relativeFile, displayPath) { + try { + const directoryStat = await lstat(directory); + if (directoryStat.isSymbolicLink()) { + return { + exists: true, + issue: managedPathIssue( + relativeFile, + `Managed Agent path parent '${displayPath}' is a symbolic link.` + ) + }; + } + if (!directoryStat.isDirectory()) { + return { + exists: true, + issue: managedPathIssue( + relativeFile, + `Managed Agent path parent '${displayPath}' is not a directory.` + ) + }; + } + return { exists: true, issue: null }; + } catch (error) { + if (error.code === "ENOENT") { + return { exists: false, issue: null }; + } + return { + exists: true, + issue: managedPathIssue(relativeFile, `Managed Agent path cannot be inspected: ${error.message}`) + }; + } +} + +function managedSegments(relativeFile) { + const segments = relativeFile.split("/"); + if ( + path.isAbsolute(relativeFile) + || segments.length < 2 + || segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`Invalid managed Agent path '${relativeFile}'.`); + } + return segments; +} + +function managedPathIssue(file, problem) { + return { + severity: "error", + file, + field: "$", + problem, + fix: "Replace symlinked or non-directory Agent path parents with real directories inside the project." + }; +} diff --git a/src/update.mjs b/src/update.mjs new file mode 100644 index 0000000..f5cfdf5 --- /dev/null +++ b/src/update.mjs @@ -0,0 +1,70 @@ +import { applyAgentSkills, planAgentSkills } from "./managed-agent-skills.mjs"; +import { applyManagedAgents, planManagedAgents } from "./managed-agents.mjs"; +import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; +import { applyWorkspaceConfig, planWorkspaceConfig } from "./workspace-config.mjs"; + +export async function updateWorkspaceIntegration(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const result = { + target: cwd, + tools: [], + created: [], + updated: [], + removed: [], + skipped: [], + warnings: [], + errors: [], + next_steps: [] + }; + + const workspace = await inspectWorkspaceRoots({ cwd }); + result.warnings.push(...workspace.warnings); + result.errors.push(...workspace.errors); + if (result.errors.length > 0) { + return result; + } + if (workspace.mode !== "canonical" && workspace.mode !== "dual") { + result.errors.push({ + severity: "error", + file: "opendomain/config.yaml", + field: "$", + problem: "Managed Agent integration requires the canonical 'opendomain/' workspace.", + fix: "Run opendomain init --tools codex before running update." + }); + return result; + } + + const configPlan = await planWorkspaceConfig(cwd); + if (configPlan.action === "create") { + result.errors.push({ + severity: "error", + file: "opendomain/config.yaml", + field: "$", + problem: "Workspace has not adopted managed Agent integration.", + fix: "Run opendomain init --tools codex to create the workspace configuration." + }); + return result; + } + if (configPlan.errors.length > 0) { + result.errors.push(...configPlan.errors); + return result; + } + result.tools = configPlan.tools; + + const agentsPlan = await planManagedAgents(cwd); + if (agentsPlan.errors.length > 0) { + result.errors.push(...agentsPlan.errors); + return result; + } + const skillPlans = await planAgentSkills(cwd, result.tools); + if (skillPlans.errors.length > 0) { + result.errors.push(...skillPlans.errors); + return result; + } + + await applyWorkspaceConfig(configPlan, result); + await applyManagedAgents(agentsPlan, result); + await applyAgentSkills(skillPlans, result); + result.next_steps.push("Run opendomain doctor to verify workspace and Agent integration readiness."); + return result; +} diff --git a/src/workspace-config.mjs b/src/workspace-config.mjs new file mode 100644 index 0000000..6530798 --- /dev/null +++ b/src/workspace-config.mjs @@ -0,0 +1,132 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; +import { + AGENT_ADAPTER_VERSION, + workspaceConfigTemplate +} from "./agent-resources.mjs"; +import { atomicWriteUtf8 } from "./atomic-write.mjs"; +import { parseYamlMapping } from "./frontmatter.mjs"; +import { validateIntegrationValue } from "./integration-schema-validator.mjs"; + +export async function planWorkspaceConfig(cwd, requestedTools) { + const file = path.join(cwd, "opendomain/config.yaml"); + let exists = false; + let current = ""; + let currentConfig = null; + + try { + const fileStat = await lstat(file); + exists = true; + if (fileStat.isSymbolicLink()) { + return invalidConfigFilePlan(file, "Workspace configuration must not be a symbolic link."); + } + if (!fileStat.isFile()) { + return invalidConfigFilePlan(file, "Workspace configuration is not a regular file."); + } + } catch (error) { + if (error.code !== "ENOENT") { + return invalidConfigFilePlan( + file, + `Workspace configuration cannot be inspected: ${error.message}` + ); + } + } + + if (exists) { + try { + current = await readFile(file, "utf8"); + } catch (error) { + return invalidConfigFilePlan( + file, + `Workspace configuration cannot be read: ${error.message}` + ); + } + try { + currentConfig = parseYamlMapping(current, "opendomain/config.yaml", { + label: "Workspace configuration" + }); + } catch (error) { + return invalidPlan(file, [{ + severity: "error", + file: "opendomain/config.yaml", + field: error.field ?? "$", + problem: error.problem ?? error.message, + fix: "Repair the workspace configuration before running init, update, or doctor." + }]); + } + + const issues = validateIntegrationValue("workspace", currentConfig).map((issue) => ({ + ...issue, + file: "opendomain/config.yaml" + })); + if (issues.length > 0) { + return invalidPlan(file, issues); + } + + const configuredAdapterVersion = currentConfig.agent_integration.adapter_version; + if (BigInt(configuredAdapterVersion) > BigInt(AGENT_ADAPTER_VERSION)) { + return invalidPlan(file, [{ + severity: "error", + file: "opendomain/config.yaml", + field: "agent_integration.adapter_version", + problem: `Workspace requires newer adapter contract '${configuredAdapterVersion}', but this CLI supports '${AGENT_ADAPTER_VERSION}'.`, + fix: "Install a newer OpenDomain CLI before updating this workspace." + }]); + } + } + + const tools = requestedTools ?? currentConfig?.agent_integration?.tools ?? []; + const content = workspaceConfigTemplate(tools); + return { + file, + relativePath: "opendomain/config.yaml", + action: !exists ? "create" : content === current ? "skip" : "update", + content, + config: { + schema_version: "1", + agent_integration: { + adapter_version: AGENT_ADAPTER_VERSION, + tools + } + }, + tools, + errors: [] + }; +} + +export async function applyWorkspaceConfig(plan, result) { + if (plan.action === "skip") { + result.skipped.push({ path: plan.relativePath, reason: "managed content is current" }); + return; + } + + await atomicWriteUtf8(plan.file, plan.content); + + if (plan.action === "create") { + result.created.push({ path: plan.relativePath, kind: "file" }); + } else { + result.updated.push({ path: plan.relativePath, kind: "file" }); + } +} + +function invalidPlan(file, errors) { + return { + file, + relativePath: "opendomain/config.yaml", + action: "invalid", + content: null, + config: null, + tools: [], + errors + }; +} + +function invalidConfigFilePlan(file, problem) { + return invalidPlan(file, [{ + severity: "error", + file: "opendomain/config.yaml", + field: "$", + problem, + fix: "Replace the workspace configuration path with a readable regular file before retrying." + }]); +} diff --git a/tests/agent-workspace.test.mjs b/tests/agent-workspace.test.mjs new file mode 100644 index 0000000..e1356c3 --- /dev/null +++ b/tests/agent-workspace.test.mjs @@ -0,0 +1,722 @@ +import assert from "node:assert/strict"; +import { + access, + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { parse as parseYaml } from "yaml"; +import { runCli } from "../src/cli.mjs"; + +test("init --tools codex installs Agent integration without host package metadata", async () => { + await withTempProject(async (cwd) => { + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 0); + assert.deepEqual(payload.tools, ["codex"]); + assert.deepEqual( + parseYaml(await readFile(path.join(cwd, "opendomain/config.yaml"), "utf8")), + { + schema_version: "1", + agent_integration: { + adapter_version: "1", + tools: ["codex"] + } + } + ); + await access(path.join(cwd, ".codex/skills/opendomain-explore/SKILL.md")); + await access(path.join(cwd, ".codex/skills/opendomain-model/SKILL.md")); + await access(path.join(cwd, ".codex/skills/opendomain-review/SKILL.md")); + + const agents = await readFile(path.join(cwd, "AGENTS.md"), "utf8"); + assert.match(agents, //); + assert.match(agents, /opendomain assure /); + assert.match(agents, //); + await assert.rejects(access(path.join(cwd, "package.json")), { code: "ENOENT" }); + await assert.rejects(access(path.join(cwd, "package-lock.json")), { code: "ENOENT" }); + }); +}); + +test("init preserves existing AGENTS content and updates one idempotent managed block", async () => { + await withTempProject(async (cwd) => { + const original = "# Existing Rules\n\nKeep this user-owned instruction.\n"; + await writeFile(path.join(cwd, "AGENTS.md"), original, "utf8"); + + const firstExitCode = await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }); + const afterFirst = await readFile(path.join(cwd, "AGENTS.md"), "utf8"); + + assert.equal(firstExitCode, 0); + assert.ok(afterFirst.startsWith(original)); + assert.equal(countMatches(afterFirst, ""), 1); + assert.equal(countMatches(afterFirst, ""), 1); + + const secondExitCode = await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }); + const afterSecond = await readFile(path.join(cwd, "AGENTS.md"), "utf8"); + + assert.equal(secondExitCode, 0); + assert.equal(afterSecond, afterFirst); + }); +}); + +test("init rejects malformed managed markers before mutating the project", async () => { + await withTempProject(async (cwd) => { + const malformed = "# Existing Rules\n\n\nunfinished\n"; + await writeFile(path.join(cwd, "AGENTS.md"), malformed, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => issue.problem.includes("managed markers"))); + assert.equal(await readFile(path.join(cwd, "AGENTS.md"), "utf8"), malformed); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + await assert.rejects(access(path.join(cwd, ".codex")), { code: "ENOENT" }); + }); +}); + +test("init rejects a non-file AGENTS path with structured diagnostics", async () => { + await withTempProject(async (cwd) => { + await mkdir(path.join(cwd, "AGENTS.md")); + const stdout = memoryStream(); + + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file === "AGENTS.md" + && issue.problem.includes("not a regular file") + ))); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + }); +}); + +test("integration commands reject a non-file workspace configuration with JSON diagnostics", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const config = path.join(cwd, "opendomain/config.yaml"); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + await rm(config); + await mkdir(config); + + for (const args of [ + ["init", "--tools", "codex", "--json"], + ["update", "--json"], + ["doctor", "--json"] + ]) { + const stdout = memoryStream(); + const exitCode = await runCli(args, { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file === "opendomain/config.yaml" + && issue.problem.includes("not a regular file") + ))); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + } + }); +}); + +test("integration commands route dangling config symlinks through boundary validation", async (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires privileges unavailable in standard CI."); + return; + } + + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const config = path.join(cwd, "opendomain/config.yaml"); + await rm(config); + await symlink(path.join(cwd, "missing-config.yaml"), config); + + for (const args of [ + ["init", "--tools", "codex", "--json"], + ["update", "--json"], + ["doctor", "--json"] + ]) { + const stdout = memoryStream(); + const exitCode = await runCli(args, { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file === "opendomain/config.yaml" + && issue.problem.includes("must not be a symbolic link") + ))); + } + }); +}); + +test("integration commands report an unreadable generated Skill", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX file permissions are required for this regression test."); + return; + } + + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const skill = path.join(cwd, ".codex/skills/opendomain-explore/SKILL.md"); + await chmod(skill, 0o000); + + try { + for (const args of [ + ["init", "--tools", "codex", "--json"], + ["update", "--json"], + ["doctor", "--json"] + ]) { + const stdout = memoryStream(); + const exitCode = await runCli(args, { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file.endsWith("opendomain-explore/SKILL.md") + && issue.problem.includes("cannot be read") + ))); + } + } finally { + await chmod(skill, 0o600); + } + }); +}); + +test("managed writes do not follow a pre-existing temporary-file symlink", async (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires privileges unavailable in standard CI."); + return; + } + + await withTempProject(async (cwd) => { + const victim = path.join(cwd, "user-owned.txt"); + const victimContent = "user-owned content\n"; + await writeFile(victim, victimContent, "utf8"); + await symlink( + victim, + path.join(cwd, `AGENTS.md.opendomain-${process.pid}.tmp`) + ); + + const exitCode = await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }); + + assert.equal(exitCode, 0); + assert.equal(await readFile(victim, "utf8"), victimContent); + assert.equal((await lstat(path.join(cwd, "AGENTS.md"))).isSymbolicLink(), false); + }); +}); + +test("update preserves modes when replacing existing managed files", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX file modes are required for this regression test."); + return; + } + + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + + const config = path.join(cwd, "opendomain/config.yaml"); + const agents = path.join(cwd, "AGENTS.md"); + const skill = path.join(cwd, ".codex/skills/opendomain-model/SKILL.md"); + await writeFile( + config, + `agent_integration:\n tools: [codex]\n adapter_version: "1"\nschema_version: "1"\n`, + "utf8" + ); + await writeFile( + agents, + (await readFile(agents, "utf8")).replace( + "This repository uses OpenDomain", + "STALE managed block uses OpenDomain" + ), + "utf8" + ); + await writeFile(skill, `${await readFile(skill, "utf8")}\nSTALE GENERATED CONTENT\n`, "utf8"); + await chmod(config, 0o600); + await chmod(agents, 0o640); + await chmod(skill, 0o604); + + assert.equal(await runCli(["update"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + assert.equal((await lstat(config)).mode & 0o7777, 0o600); + assert.equal((await lstat(agents)).mode & 0o7777, 0o640); + assert.equal((await lstat(skill)).mode & 0o7777, 0o604); + assert.doesNotMatch(await readFile(agents, "utf8"), /STALE managed block/); + assert.doesNotMatch(await readFile(skill, "utf8"), /STALE GENERATED CONTENT/); + }); +}); + +test("init rejects a symlinked generated Skill parent before workspace mutation", async (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires privileges unavailable in standard CI."); + return; + } + + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-external-skills-")); + try { + await withTempProject(async (cwd) => { + await symlink(external, path.join(cwd, ".codex")); + const stdout = memoryStream(); + + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file.includes(".codex/skills/") + && issue.problem.includes("symbolic link") + ))); + assert.deepEqual(await readdir(external), []); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + await assert.rejects(access(path.join(cwd, "AGENTS.md")), { code: "ENOENT" }); + }); + } finally { + await rm(external, { recursive: true, force: true }); + } +}); + +test("init --tools codex upgrades an existing unconfigured workspace", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const config = parseYaml( + await readFile(path.join(cwd, "opendomain/config.yaml"), "utf8") + ); + + assert.equal(exitCode, 0); + assert.deepEqual(config.agent_integration.tools, ["codex"]); + assert.ok(JSON.parse(stdout.toString()).updated.some((item) => ( + item.path === "opendomain/config.yaml" + ))); + }); +}); + +test("init refuses to overwrite a user-owned Codex Skill", async () => { + await withTempProject(async (cwd) => { + const skill = path.join(cwd, ".codex/skills/opendomain-explore/SKILL.md"); + await mkdir(path.dirname(skill), { recursive: true }); + await writeFile(skill, "user-owned skill\n", "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["init", "--tools", "codex", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => issue.problem.includes("not OpenDomain-generated"))); + assert.equal(await readFile(skill, "utf8"), "user-owned skill\n"); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + }); +}); + +test("update synchronizes only managed Agent resources", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + + const concept = path.join(cwd, "opendomain/concepts/example.concept.md"); + const conceptBefore = await readFile(concept, "utf8"); + const skill = path.join(cwd, ".codex/skills/opendomain-explore/SKILL.md"); + await writeFile(skill, `${await readFile(skill, "utf8")}\nSTALE GENERATED CONTENT\n`, "utf8"); + const agents = path.join(cwd, "AGENTS.md"); + const currentAgents = await readFile(agents, "utf8"); + await writeFile( + agents, + `# User Rules\n\n${currentAgents.replace("This repository uses OpenDomain", "STALE managed block uses OpenDomain")}`, + "utf8" + ); + const stdout = memoryStream(); + + const exitCode = await runCli(["update", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + const updatedAgents = await readFile(agents, "utf8"); + + assert.equal(exitCode, 0); + assert.ok(payload.updated.some((item) => item.path === "AGENTS.md")); + assert.ok(payload.updated.some((item) => item.path.endsWith("opendomain-explore/SKILL.md"))); + assert.doesNotMatch(await readFile(skill, "utf8"), /STALE GENERATED CONTENT/); + assert.ok(updatedAgents.startsWith("# User Rules\n\n")); + assert.doesNotMatch(updatedAgents, /STALE managed block/); + assert.equal(await readFile(concept, "utf8"), conceptBefore); + await assert.rejects(access(path.join(cwd, "package.json")), { code: "ENOENT" }); + }); +}); + +test("update removes generated Skills after Codex is explicitly deselected", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const config = path.join(cwd, "opendomain/config.yaml"); + await writeFile(config, `schema_version: "1"\nagent_integration:\n adapter_version: "1"\n tools: []\n`, "utf8"); + const skillFiles = ["opendomain-explore", "opendomain-model", "opendomain-review"] + .map((name) => path.join(cwd, `.codex/skills/${name}/SKILL.md`)); + const doctorStdout = memoryStream(); + + assert.equal(await runCli(["doctor", "--json"], { + cwd, + stdout: doctorStdout, + stderr: memoryStream() + }), 1); + assert.ok(JSON.parse(doctorStdout.toString()).errors.some((issue) => ( + issue.problem.includes("no longer selected") + && issue.fix === "Run opendomain update." + ))); + await Promise.all(skillFiles.map((file) => access(file))); + + const updateStdout = memoryStream(); + assert.equal(await runCli(["update", "--json"], { + cwd, + stdout: updateStdout, + stderr: memoryStream() + }), 0); + const update = JSON.parse(updateStdout.toString()); + + assert.equal(update.removed.length, 3); + await Promise.all(skillFiles.map((file) => ( + assert.rejects(access(file), { code: "ENOENT" }) + ))); + const finalDoctorStdout = memoryStream(); + assert.equal(await runCli(["doctor", "--json"], { + cwd, + stdout: finalDoctorStdout, + stderr: memoryStream() + }), 0); + assert.equal(JSON.parse(finalDoctorStdout.toString()).status, "healthy"); + }); +}); + +test("doctor reports a healthy initialized Codex integration without mutation", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["doctor", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 0); + assert.equal(payload.status, "healthy"); + assert.deepEqual(payload.tools, ["codex"]); + assert.ok(payload.checks.every((check) => check.status === "pass")); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + }); +}); + +test("doctor reports a missing configured Skill without repairing it", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const missingSkill = path.join(cwd, ".codex/skills/opendomain-review/SKILL.md"); + await rm(missingSkill); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["doctor", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.equal(payload.status, "unhealthy"); + assert.ok(payload.errors.some((issue) => ( + issue.file.endsWith("opendomain-review/SKILL.md") + && issue.problem.includes("missing") + && issue.fix === "Run opendomain update." + ))); + await assert.rejects(access(missingSkill), { code: "ENOENT" }); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + }); +}); + +test("doctor reports a stale configured Skill without rewriting it", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const staleSkill = path.join(cwd, ".codex/skills/opendomain-model/SKILL.md"); + const staleContent = `${await readFile(staleSkill, "utf8")}\nSTALE GENERATED CONTENT\n`; + await writeFile(staleSkill, staleContent, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["doctor", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.equal(payload.status, "unhealthy"); + assert.ok(payload.errors.some((issue) => ( + issue.file.endsWith("opendomain-model/SKILL.md") + && issue.problem.includes("stale") + && issue.fix === "Run opendomain update." + ))); + assert.equal(await readFile(staleSkill, "utf8"), staleContent); + }); +}); + +test("update requires explicit managed integration adoption", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + await rm(path.join(cwd, "opendomain/config.yaml")); + const concept = path.join(cwd, "opendomain/concepts/example.concept.md"); + const conceptBefore = await readFile(concept, "utf8"); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["update", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file === "opendomain/config.yaml" + && issue.problem.includes("has not adopted managed Agent integration") + ))); + assert.equal(await readFile(concept, "utf8"), conceptBefore); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + await assert.rejects(access(path.join(cwd, ".codex")), { code: "ENOENT" }); + }); +}); + +test("update fails closed on invalid workspace configuration", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const config = path.join(cwd, "opendomain/config.yaml"); + await writeFile(config, `schema_version: "1"\nagent_integration:\n adapter_version: "1"\n tools:\n - unknown-agent\n`, "utf8"); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["update", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.file === "opendomain/config.yaml" + && issue.field === "agent_integration.tools[0]" + ))); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + }); +}); + +test("doctor refuses a workspace created by a newer adapter contract", async () => { + await withTempProject(async (cwd) => { + assert.equal(await runCli(["init", "--tools", "codex"], { + cwd, + stdout: memoryStream(), + stderr: memoryStream() + }), 0); + const config = path.join(cwd, "opendomain/config.yaml"); + await writeFile(config, `schema_version: "1"\nagent_integration:\n adapter_version: "2"\n tools:\n - codex\n`, "utf8"); + const agents = path.join(cwd, "AGENTS.md"); + const agentsBefore = await readFile(agents, "utf8"); + const stdout = memoryStream(); + + const exitCode = await runCli(["doctor", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.field === "agent_integration.adapter_version" + && issue.problem.includes("newer adapter contract '2'") + && issue.fix.includes("newer OpenDomain CLI") + ))); + assert.equal(await readFile(agents, "utf8"), agentsBefore); + }); +}); + +test("missing --tools value preserves a trailing --json flag", async () => { + await withTempProject(async (cwd) => { + const stdout = memoryStream(); + + const exitCode = await runCli(["init", "--tools", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.field === "tools" + && issue.problem === "Missing Agent tool selection." + ))); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + }); +}); + +test("unsupported init tool fails before project mutation", async () => { + await withTempProject(async (cwd) => { + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--tools", "unknown-agent", "--json"], { + cwd, + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((issue) => ( + issue.field === "tools" && issue.fix.includes("--tools codex") + ))); + await assert.rejects(access(path.join(cwd, "opendomain")), { code: "ENOENT" }); + await assert.rejects(access(path.join(cwd, "AGENTS.md")), { code: "ENOENT" }); + }); +}); + +async function withTempProject(callback) { + const cwd = await mkdtemp(path.join(os.tmpdir(), "opendomain-agent-workspace-")); + try { + await callback(cwd); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +} + +function memoryStream() { + let output = ""; + return { + write(chunk) { + output += String(chunk); + }, + toString() { + return output; + } + }; +} + +function countMatches(value, needle) { + return value.split(needle).length - 1; +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c2a1f5a..daf893b 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -22,6 +22,9 @@ test("help explains canonical and legacy workspace resolution", async () => { assert.match(output, /--profile /); assert.match(output, /opendomain assure/); assert.match(output, /--mode advisory\|enforced/); + assert.match(output, /opendomain init \[--tools codex\]/); + assert.match(output, /opendomain update/); + assert.match(output, /opendomain doctor/); }); test("validate command returns JSON and zero exit code for valid ERP example", async () => { @@ -112,7 +115,7 @@ test("init command creates a minimal valid OpenDomain structure", async () => { assert.match(output, /opendomain\/candidates\/candidate-0001-first-domain-model\.md/); assert.match(output, /opendomain\/integrations\/profiles\/README\.md/); await access("opendomain/integrations/profiles/README.md"); - assert.match(await readFile("AGENTS.md", "utf8"), /opendomain assure /); + assert.match(await readFile("AGENTS.md", "utf8"), /opendomain assure /); const validateStdout = memoryStream(); const validateExitCode = await runCli(["validate", "--json"], { stdout: validateStdout, stderr: memoryStream() }); diff --git a/tests/integration-schema-validator.test.mjs b/tests/integration-schema-validator.test.mjs index 3981fb0..91c6a6b 100644 --- a/tests/integration-schema-validator.test.mjs +++ b/tests/integration-schema-validator.test.mjs @@ -156,6 +156,40 @@ test("Grounding Request schema accepts explicit decisions and requires skip rati ))); }); +test("Workspace configuration schema bounds Agent adapter selection", () => { + const valid = { + schema_version: "1", + agent_integration: { + adapter_version: "1", + tools: ["codex"] + } + }; + assert.deepEqual(validateIntegrationValue("workspace", valid), []); + + const futureAdapter = structuredClone(valid); + futureAdapter.agent_integration.adapter_version = "2"; + assert.deepEqual(validateIntegrationValue("workspace", futureAdapter), []); + + const unsupported = structuredClone(valid); + unsupported.agent_integration.tools = ["unknown-agent"]; + assert.ok(validateIntegrationValue("workspace", unsupported).some((issue) => ( + issue.field === "agent_integration.tools[0]" + && issue.problem.includes("workspace-config.schema.json") + ))); + + const extra = structuredClone(valid); + extra.package_manager = "npm"; + assert.ok(validateIntegrationValue("workspace", extra).some((issue) => ( + issue.field === "package_manager" + ))); + + const malformedVersion = structuredClone(valid); + malformedVersion.agent_integration.adapter_version = "v1"; + assert.ok(validateIntegrationValue("workspace", malformedVersion).some((issue) => ( + issue.field === "agent_integration.adapter_version" + ))); +}); + function nativeFileProfile() { return { schema_version: "1.0",