From c5653692678328ae394342aebb5c788e460c50d2 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 4 Aug 2026 14:55:42 +0800 Subject: [PATCH] feat: add agent-driven installation contract --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 8 + INSTALL.md | 111 +++++++++++++ README.md | 28 +++- README.zh-CN.md | 24 ++- ...date-0012-agent-native-project-adoption.md | 19 ++- package.json | 2 + scripts/smoke-agent-bootstrap.mjs | 151 ++++++++++++++++++ scripts/smoke-installed-package.mjs | 13 +- tests/packaged-resources.test.mjs | 3 + 10 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 INSTALL.md create mode 100644 scripts/smoke-agent-bootstrap.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ea17cf..725c9a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - name: Run installed-package smoke run: npm run smoke:package + + - name: Run Agent bootstrap smoke + run: npm run smoke:agent-bootstrap diff --git a/CHANGELOG.md b/CHANGELOG.md index 550e80f..30694f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Add a canonical Agent installation contract so Codex can install OpenDomain + without changing host project package metadata, initialize its managed + adapter, and prove readiness with diagnostics and validation. +- Make the npm prerelease channel explicit with `@alpha` and document a + checksum-verified, user-local standalone fallback. +- Add isolated global-install smoke coverage for Agent bootstrap in a non-Node + workspace. + ## 0.1.0-alpha.8 - 2026-08-03 - Add Node SEA standalone CLI builds for macOS arm64/x64, Linux x64, and diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..2ede07b --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,111 @@ +# OpenDomain Agent Installation Contract + +This is the canonical installation contract for shell-capable AI agents. + +When a user asks you to install OpenDomain in the current workspace, complete +this workflow unless the user's instructions or repository policy are stricter. +Do not add OpenDomain to the project's dependencies, scripts, package manifest, +or lockfiles. + +## Preconditions + +Before changing anything: + +1. Confirm the intended workspace root and inspect its repository instructions. +2. Confirm that shell execution and writes to the workspace are allowed. +3. Confirm that network access is available for a new installation or upgrade. +4. Preserve existing project files and report any ownership conflict. + +Installation can require the user's approval because it changes their tool +environment. Never bypass an approval boundary, expose credentials, disable +integrity checks, or silently escalate privileges. If a precondition is not +available, stop and report the exact blocker and any changes already made. + +## Select An Installation Path + +First run `opendomain --version` if `opendomain` is already on `PATH`. A usable +existing CLI does not need to be installed again; continue with workspace +initialization or update below. + +### npm Alpha Channel + +Prefer npm when a supported Node.js and npm tool environment is already +available. OpenDomain currently supports Node.js 20 or Node.js 22 and newer; +Node.js 21 is not supported. + +During the prerelease period, always name the alpha distribution tag: + +```bash +npm install --global @echopath-labs/opendomain@alpha +opendomain --version +``` + +This is a global tool installation. Do not run `npm install` without +`--global` from the user's project. If the configured global prefix is not +writable, use a user-owned npm prefix or ask the user to approve an appropriate +environment change. Do not use `sudo npm install` by default. + +### Verified Standalone Executable + +Use the standalone fallback when a compatible npm environment is unavailable: + +1. Open the official [OpenDomain releases](https://github.com/echopath-labs/openDomain/releases) + and select the intended release, normally the newest non-draft release. +2. Detect the operating system and architecture, then select the matching + `opendomain-v-` asset. Windows assets end in `.exe`. +3. Download that executable and `SHA256SUMS.txt` from the same release. +4. Verify the executable against its exact line in the checksum manifest using + `shasum -a 256` on macOS, `sha256sum` on Linux, or `Get-FileHash -Algorithm + SHA256` in PowerShell. +5. Only after verification, place it as `opendomain` (or `opendomain.exe`) in a + user-writable executable directory on `PATH`, such as `$HOME/.local/bin`. +6. Run `opendomain --version` and confirm the expected version. + +Supported standalone targets and minimum systems are listed in the project +[README](README.md#standalone-binary-recommended). Never execute an asset when +the matching checksum is absent or different. Do not silently use `sudo` to +write to a system directory. + +## Initialize The Current Workspace + +After the CLI is available, stay in the intended repository root. + +If `opendomain/config.yaml` already exists and selects Codex, synchronize the +managed integration: + +```bash +opendomain update --json +``` + +Otherwise initialize the canonical workspace and Codex adapter: + +```bash +opendomain init --tools codex --json +``` + +Initialization may create the canonical `opendomain/` workspace, generated +`.codex/skills/opendomain-*` Skills, and one managed OpenDomain block in +`AGENTS.md`. It must preserve user-owned content and existing package metadata. + +## Prove Readiness + +Run both checks before reporting success: + +```bash +opendomain doctor --json +opendomain validate --json +``` + +Installation is complete only when the CLI version command, initialization or +update, diagnostics, and validation all exit successfully. Report: + +- the installed OpenDomain version and installation path; +- whether the workspace was initialized or updated; +- the managed files created or updated; +- the `doctor` and `validate` outcomes; +- confirmation that project package metadata was not created or modified. + +If any command fails, report that command and its actionable diagnostic. Do not +claim that automatic Agent use is ready. Once the generated Codex integration +is healthy, its repository instructions and Skills route grounding assurance, +domain exploration, candidate-first modeling, and Candidate review. diff --git a/README.md b/README.md index 033577f..3b88f89 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,19 @@ The source of truth remains Markdown with YAML front matter stored in Git. ## Installation And Usage +### Install With Codex + +In a shell-enabled Codex session, users can ask: + +> Install OpenDomain in this workspace. + +Codex should follow the canonical [Agent Installation Contract](INSTALL.md). +It installs OpenDomain as a user tool, initializes or updates the +repository-local Codex adapter, and proves readiness with `doctor` and +`validate`. The workflow must not add a `package.json`, lockfile, dependency, +or npm script to the host project. Network, shell, filesystem, and approval +boundaries still apply and must be reported when unavailable. + ### Standalone Binary (Recommended) Download the binary and `SHA256SUMS.txt` for the same version from @@ -89,9 +102,12 @@ On macOS or Linux, make the file executable, rename it, and place it on `PATH`: ```bash chmod +x opendomain-v- -sudo install opendomain-v- /usr/local/bin/opendomain +mkdir -p "${XDG_BIN_HOME:-$HOME/.local/bin}" +install -m 0755 opendomain-v- "${XDG_BIN_HOME:-$HOME/.local/bin}/opendomain" ``` +Ensure that the selected user-owned directory is on `PATH`. + On Windows, rename the asset to `opendomain.exe` and place it in a directory on `PATH`. Then initialize a project without adding Node.js metadata: @@ -105,8 +121,9 @@ opendomain validate Upgrade by downloading, verifying, and replacing the binary with the asset from a newer release. The initial macOS binaries are ad-hoc signed but not notarized; Windows binaries are not Authenticode signed. Checksums detect file changes but -do not establish publisher identity. Homebrew distribution is planned as a -separate follow-up and is not yet an installation channel. +do not establish publisher identity. Homebrew is not currently an installation +channel and will be reconsidered after the project reaches stable distribution +maturity. ### npm (Alternative) @@ -114,12 +131,15 @@ Users who already manage a Node.js tool environment can install the same CLI from npm: ```bash -npm install -g @echopath-labs/opendomain +npm install -g @echopath-labs/opendomain@alpha opendomain init --tools codex opendomain doctor opendomain validate ``` +The explicit `@alpha` tag is required during the prerelease period so npm does +not resolve an older `latest` dist-tag. + Both distribution channels run the same CLI. 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, diff --git a/README.zh-CN.md b/README.zh-CN.md index bf90d25..a92139b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -134,6 +134,17 @@ OpenDomain 当前不做: ## 30 秒开始 +### 让 Codex 安装 + +在可以执行 Shell 的 Codex 会话中,可以直接提出: + +> 帮我在当前工作区安装 OpenDomain。 + +Codex 应遵循正式的 [Agent 安装契约](INSTALL.md),把 OpenDomain 安装为用户工具, +初始化或更新仓库局部的 Codex adapter,并通过 `doctor` 和 `validate` 提供可核验的 +完成证据。整个过程不得向宿主项目添加 `package.json`、lockfile、依赖或 npm +scripts。网络、Shell、文件写入或审批权限不可用时,Codex 必须明确报告阻塞原因。 + ### 独立二进制(推荐) 从 [GitHub Releases](https://github.com/echopath-labs/openDomain/releases) 下载同一 @@ -158,9 +169,12 @@ macOS 或 Linux 用户需要添加执行权限并放入 `PATH`: ```bash chmod +x opendomain-v- -sudo install opendomain-v- /usr/local/bin/opendomain +mkdir -p "${XDG_BIN_HOME:-$HOME/.local/bin}" +install -m 0755 opendomain-v- "${XDG_BIN_HOME:-$HOME/.local/bin}/opendomain" ``` +需要确保所选的用户目录已经加入 `PATH`。 + Windows 用户可将文件改名为 `opendomain.exe`,再放入 `PATH` 中的目录。随后直接在 项目中初始化,不需要 Node.js、`package.json` 或 npm scripts: @@ -173,8 +187,8 @@ opendomain validate 升级时下载新版本、重新校验 SHA-256,然后替换旧二进制。首批 macOS 产物只做 ad-hoc signing,尚未 notarize;Windows 产物尚未做 Authenticode 签名。checksum -可以发现文件变化,但不等价于发布者身份证明。Homebrew 将在后续独立阶段提供, -当前还不是可用安装渠道。 +可以发现文件变化,但不等价于发布者身份证明。Homebrew 当前不是可用安装渠道, +将在项目达到稳定分发成熟度后重新评估。 ### npm(可选) @@ -184,12 +198,14 @@ ad-hoc signing,尚未 notarize;Windows 产物尚未做 Authenticode 签名 全局安装 CLI: ```bash -npm install -g @echopath-labs/opendomain +npm install -g @echopath-labs/opendomain@alpha opendomain init --tools codex opendomain doctor opendomain validate ``` +预发布阶段必须显式使用 `@alpha`,避免 npm 解析到较旧的 `latest` dist-tag。 + 两种分发渠道运行相同 CLI。OpenDomain 不会在宿主项目中创建或修改 `package.json`、lockfile、依赖声明或 npm scripts。`init --tools codex` 会创建 canonical `opendomain/`、生成 `.codex/skills/opendomain-*`,并在 `AGENTS.md` diff --git a/opendomain/candidates/candidate-0012-agent-native-project-adoption.md b/opendomain/candidates/candidate-0012-agent-native-project-adoption.md index ef72fef..2aee85b 100644 --- a/opendomain/candidates/candidate-0012-agent-native-project-adoption.md +++ b/opendomain/candidates/candidate-0012-agent-native-project-adoption.md @@ -22,10 +22,19 @@ evidence: 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 + - type: human_review + location: INSTALL.md + summary: The maintainer approved a canonical Agent installation contract in which Codex selects a user-tool installation path, initializes the workspace, and proves readiness without changing host package metadata. + confidence: high + - type: test + location: scripts/smoke-agent-bootstrap.mjs + summary: A packed OpenDomain CLI installs into an isolated global prefix and initializes a healthy Codex integration in a non-Node workspace without creating package metadata. + 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. + - The npm path still requires a supported user-level Node.js runtime; standalone binaries provide a fallback but add platform and checksum-selection requirements. - 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. + - Natural-language installation still depends on Agent discovery plus network, shell, filesystem, and human approval boundaries. + - The installation contract and isolated smoke do not yet provide external-project adoption or upgrade evidence. review: state: proposed suggested_reviewer: opendomain-maintainer @@ -51,6 +60,6 @@ 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. +Keep this rule proposed until at least one external project adoption and an +upgrade cycle confirm that the package-manager-neutral workspace and Agent +installation contract remain practical outside OpenDomain's own repository. diff --git a/package.json b/package.json index 04988c8..b64b765 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "scripts/smoke-installed-package.mjs", "schemas/", "examples/erp/", + "INSTALL.md", "README.md", "README.zh-CN.md", "LICENSE", @@ -53,6 +54,7 @@ "prepare:demo": "cd examples/erp && node ../../bin/opendomain.mjs prepare openspec/changes/order-cancellation/spec.md", "demo": "node ./bin/opendomain.mjs demo order-cancellation", "build:standalone": "node ./scripts/build-standalone.mjs", + "smoke:agent-bootstrap": "node ./scripts/smoke-agent-bootstrap.mjs", "smoke:package": "node ./scripts/smoke-installed-package.mjs", "smoke:standalone": "node ./scripts/smoke-standalone.mjs", "prepublishOnly": "npm test && npm run opendomain -- validate" diff --git a/scripts/smoke-agent-bootstrap.mjs b/scripts/smoke-agent-bootstrap.mjs new file mode 100644 index 0000000..3f82b31 --- /dev/null +++ b/scripts/smoke-agent-bootstrap.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { + access, + mkdtemp, + mkdir, + readFile, + readdir, + rm +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFile = promisify(execFileCallback); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "opendomain-agent-bootstrap-")); +const npmEnvironment = { + ...process.env, + npm_config_cache: path.join(temporaryRoot, "npm-cache") +}; + +try { + const packResult = await run("npm", [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + temporaryRoot + ], packageRoot, npmEnvironment); + const [packed] = JSON.parse(packResult.stdout); + const tarball = path.join(temporaryRoot, packed.filename); + const prefix = path.join(temporaryRoot, "tool-prefix"); + const workspace = path.join(temporaryRoot, "workspace"); + + assert.ok( + packed.files.some((entry) => entry.path === "INSTALL.md"), + "The npm artifact must include INSTALL.md." + ); + + await mkdir(prefix, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await run("npm", [ + "install", + "--global", + "--prefix", + prefix, + "--ignore-scripts", + "--no-audit", + "--no-fund", + tarball + ], temporaryRoot, npmEnvironment); + + const cli = installedCliPath(prefix); + await access(cli); + + const version = await run(cli, ["--version"], workspace); + assert.match(version.stdout.trim(), /^0\.1\.0-alpha\.\d+$/); + + const init = await runJsonCli(cli, [ + "init", + "--tools", + "codex", + "--json" + ], workspace); + assert.deepEqual(init.errors, []); + + const doctor = await runJsonCli(cli, ["doctor", "--json"], workspace); + assert.equal(doctor.status, "healthy"); + assert.deepEqual(doctor.errors, []); + + const validation = await runJsonCli(cli, ["validate", "--json"], workspace); + assert.deepEqual(validation.errors, []); + + for (const packageMetadata of [ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock" + ]) { + await assert.rejects( + access(path.join(workspace, packageMetadata)), + (error) => error?.code === "ENOENT", + `${packageMetadata} must not be created in the host workspace.` + ); + } + + const agents = await readFile(path.join(workspace, "AGENTS.md"), "utf8"); + assert.match(agents, /opendomain assure /); + assert.match(agents, /Accepted source|accepted source/i); + assert.match(agents, /Candidate boundaries/); + + const expectedSkills = new Map([ + ["opendomain-explore", /Explore the project's OpenDomain model/], + ["opendomain-model", /Domain Candidate first/], + ["opendomain-review", /explicitly chooses a decision/] + ]); + for (const [skill, expectedInstruction] of expectedSkills) { + const skillFile = path.join(workspace, ".codex", "skills", skill, "SKILL.md"); + const contents = await readFile(skillFile, "utf8"); + assert.match(contents, /generatedBy: opendomain/); + assert.match(contents, expectedInstruction); + } + + const workspaceEntries = await readdir(workspace); + assert.deepEqual( + workspaceEntries.sort(), + [".codex", "AGENTS.md", "opendomain"], + "Agent bootstrap must only create OpenDomain-managed workspace paths." + ); + + process.stdout.write( + `Agent bootstrap smoke passed: ${version.stdout.trim()}, ` + + `integration ${doctor.status}, validation passed, host package metadata absent.\n` + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function installedCliPath(prefix) { + if (process.platform === "win32") { + return path.join(prefix, "opendomain.cmd"); + } + return path.join(prefix, "bin", "opendomain"); +} + +async function runJsonCli(cli, args, cwd) { + const result = await run(cli, args, cwd); + return JSON.parse(result.stdout); +} + +async function run(command, args, cwd, environment = process.env) { + try { + return await execFile(command, args, { + cwd, + encoding: "utf8", + env: environment, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32" + }); + } catch (error) { + const stdout = error.stdout ? `\nstdout:\n${error.stdout}` : ""; + const stderr = error.stderr ? `\nstderr:\n${error.stderr}` : ""; + throw new Error( + `Command failed: ${command} ${args.join(" ")}${stdout}${stderr}`, + { cause: error } + ); + } +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index 93378f7..4ed4c9e 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -16,6 +16,10 @@ import { fileURLToPath } from "node:url"; const execFile = promisify(execFileCallback); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "opendomain-package-smoke-")); +const npmEnvironment = { + ...process.env, + npm_config_cache: path.join(temporaryRoot, "npm-cache") +}; try { const packResult = await run("npm", [ @@ -24,7 +28,7 @@ try { "--ignore-scripts", "--pack-destination", temporaryRoot - ], packageRoot); + ], packageRoot, npmEnvironment); const packPayload = JSON.parse(packResult.stdout); const tarball = path.join(temporaryRoot, packPayload[0].filename); const consumer = path.join(temporaryRoot, "consumer"); @@ -41,7 +45,7 @@ try { "--no-audit", "--no-fund", tarball - ], consumer); + ], consumer, npmEnvironment); const installedRoot = path.join( consumer, @@ -58,11 +62,13 @@ try { 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, "INSTALL.md")); await access(path.join(installedRoot, "scripts", "smoke-installed-package.mjs")); for (const maintainerScript of [ "assemble-standalone-assets.mjs", "build-standalone.mjs", "lib/standalone-release.mjs", + "smoke-agent-bootstrap.mjs", "smoke-standalone.mjs", "write-standalone-checksums.mjs" ]) { @@ -145,11 +151,12 @@ async function runJsonCli(cli, args, cwd) { return JSON.parse(result.stdout); } -async function run(command, args, cwd) { +async function run(command, args, cwd, environment = process.env) { try { return await execFile(command, args, { cwd, encoding: "utf8", + env: environment, maxBuffer: 10 * 1024 * 1024 }); } catch (error) { diff --git a/tests/packaged-resources.test.mjs b/tests/packaged-resources.test.mjs index a58db12..1582fac 100644 --- a/tests/packaged-resources.test.mjs +++ b/tests/packaged-resources.test.mjs @@ -10,10 +10,13 @@ test("packaged resources expose schemas, package metadata, and ERP files", async } const packageMetadata = JSON.parse(resources.readPackagedText("package.json")); + const installationContract = resources.readPackagedText("INSTALL.md"); const schema = JSON.parse(resources.readPackagedText("schemas/context.schema.json")); const exampleFiles = resources.listPackagedFiles("examples/erp/"); assert.equal(packageMetadata.name, "@echopath-labs/opendomain"); + assert.match(installationContract, /OpenDomain Agent Installation Contract/); + assert.match(installationContract, /@echopath-labs\/opendomain@alpha/); assert.equal(schema.$id, "https://opendomain.dev/schemas/context.schema.json"); assert.ok(exampleFiles.includes("examples/erp/opendomain/contexts/sales.md")); assert.ok(exampleFiles.includes("examples/erp/openspec/changes/order-cancellation/spec.md"));