diff --git a/CHANGELOG.md b/CHANGELOG.md index 5125918..e3b19dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to OpenOne Workflow Kit are documented here. The project follows [Semantic Versioning](https://semver.org/). +## [1.0.0] - 2026-07-27 + +### Changed + +- Replace the unsupported project-level `.codex/prompts/` adapter with one umbrella Skill and 32 manifest-driven stage Skills under `.agents/skills/`. +- Give every development and commercialization stage a stable ASCII Skill slug, localized Codex display metadata, and explicit-only invocation policy. +- Make `workflow/core/command-manifest.yaml` the single source of truth for command IDs, descriptions, argument hints, implementation gates, and Codex Skill paths. +- Document the actual Codex surfaces: Desktop Skills search via `/`, and `/skills` or `$` in CLI/IDE; literal Claude-style project commands such as `/01-需求讨论` are not claimed. + +### Migration + +- `--upgrade` now removes only direct `.codex/prompts/` children whose content exactly matches the 0.1.0 generated template, including CRLF checkouts; nested directories remain user-owned. +- User-authored or edited prompts, workspace facts and principles, custom Skills, and symbolic links are preserved; same-name custom Skills receive merge sidecars, and orphan Skills are removed only when they carry the OpenOne managed marker. +- `--dry-run` reports the migration plan without changing the target workspace. + +### Verification + +- Add all-32-stage Codex adapter conformance, negative manifest cases, safe migration fixtures, and installed-package coverage to the release checks. +- Require a real Codex `skills/list` acceptance check before publication. + ## [0.1.0] - 2026-07-19 ### Added @@ -19,3 +39,4 @@ All notable changes to OpenOne Workflow Kit are documented here. The project fol - Release checks scan for common credential assignments, private-key markers, and private-network URLs; maintainers can add an external private denylist. [0.1.0]: https://github.com/bluecoast1379/openone-workflow-kit/releases/tag/v0.1.0 +[1.0.0]: https://github.com/bluecoast1379/openone-workflow-kit/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 3125ad1..e36a9ae 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ ```bash mkdir openone-demo && cd openone-demo -npx --yes --package openone-workflow-kit@0.1.0 openone-workflow-init --target . --tools codex,cursor --yes +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init --target . --tools codex,cursor --yes node -e "for (const f of ['workflow/team-profile.yaml','AGENTS.md']) require('node:fs').accessSync(f); console.log('OpenOne ready')" ``` 预期结果:终端输出 `OpenOne ready`,并生成 `workflow/team-profile.yaml`、`workflow/core/`、`AGENTS.md` 与所选工具 adapter。如果 npm 网络不可用,参考 [Git 或本地 tarball 安装](./docs/shareable-install.md)。 -![OpenOne Workflow Kit 30 秒体验:创建目录、运行 v0.1.0、验证产物](./docs/assets/quick-demo.svg) +![OpenOne Workflow Kit 30 秒体验:创建目录、运行 v1.0.0、验证产物](./docs/assets/quick-demo.svg) ## 架构:一套 Core,两条轨道 @@ -43,14 +43,14 @@ node -e "for (const f of ['workflow/team-profile.yaml','AGENTS.md']) require('no 2. `workflow/core/` 提供工具无关的阶段、完成合同、闸门、模板和 Oracle 能力。 3. `workflow/adapters/` 为 Claude、Codex、Cursor、Copilot、CodeBuddy、Kiro 和 Trae 生成薄入口;共享 core,但不承诺工具体验完全相同。 -深入阅读:[定义完成指南](./docs/definition-of-done.md) · [双轨工作流设计](./docs/dual-track-workflow.md) · [v0.1.0 Release Notes](./docs/releases/v0.1.0.md) +深入阅读:[定义完成指南](./docs/definition-of-done.md) · [双轨工作流设计](./docs/dual-track-workflow.md) · [v1.0.0 Release Notes](./docs/releases/v1.0.0.md) ## 一键初始化 从 npm 的不可变版本运行(Node.js 18+): ```bash -npx --yes --package openone-workflow-kit@0.1.0 openone-workflow-init --target . --tools codex,claude,cursor --yes +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init --target . --tools codex,claude,cursor --yes ``` 从源码 checkout 本地运行: @@ -65,7 +65,7 @@ node /path/to/openone-workflow-kit/bin/init-workspace.cjs --target . /path/to/openone-workflow-kit/install.sh . --tools codex,claude,cursor ``` -如果你拿到的是 Git 地址或本地 tarball,见 [可分享安装方式](./docs/shareable-install.md)。该文档也说明了如何先验证 Registry 中的 `0.1.0` 再安装,避免把尚未发布的版本当作可用事实。 +如果你拿到的是 Git 地址或本地 tarball,见 [可分享安装方式](./docs/shareable-install.md)。该文档也说明了如何先验证 Registry 中的 `1.0.0` 再安装,避免把尚未发布的版本当作可用事实。 常用参数: @@ -74,7 +74,7 @@ node /path/to/openone-workflow-kit/bin/init-workspace.cjs --target . node /path/to/openone-workflow-kit/bin/init-workspace.cjs --target . --tools codex,claude,cursor # GitHub 包安装方式 -npx --yes --package "git+https://github.com/bluecoast1379/openone-workflow-kit.git#v0.1.0" openone-workflow-init --target . --tools codex,claude,cursor +npx --yes --package "git+https://github.com/bluecoast1379/openone-workflow-kit.git#v1.0.0" openone-workflow-init --target . --tools codex,claude,cursor # 工具名支持 trea 别名,会自动归一为 trae node /path/to/openone-workflow-kit/bin/init-workspace.cjs --target . --tools codex,trea,codebuddy @@ -94,13 +94,26 @@ node /path/to/openone-workflow-kit/bin/init-workspace.cjs --target . --dry-run - 交互式终端:逐项提问。 - 非交互模式:生成 `workflow/INITIALIZATION_QUESTIONS.md`。 4. 生成跨工具入口: - - Codex: `AGENTS.md`、`.codex/prompts/` + - Codex: `AGENTS.md`、`.agents/skills/agent-workflow/` 与 32 个 `.agents/skills//` - Claude Code: `CLAUDE.md`、`.claude/commands/` - Cursor: `.cursor/rules/` 和 `.cursor/commands/` - Copilot: `.github/copilot-instructions.md` - CodeBuddy / Kiro / Trae: 各自 `instructions.md` 5. 初始化器本身不执行远程 Git、push、构建部署或数据库写入。生成后的个人工作流允许 agent 在范围明确且工作树干净时执行本地分支命名、创建、commit、tag 和本地 merge;远程 push、release、部署和生产配置写入需要用户明确授权。 +### Codex 调用方式与 0.1.0 迁移 + +Codex Desktop 输入 `/01`、`/B1` 等关键词后,从 `/` 面板的 Skills 分组选择中文阶段;CLI/IDE 使用 `/skills` 或 `$workflow-...`。这是 Skill 选择,不是 Claude 式字面项目命令 `/01-需求讨论`。 + +从 0.1.0 升级时执行: + +```bash +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init \ + --target . --tools codex --upgrade --force --yes +``` + +升级会用精确的 0.1.0 模板指纹识别旧 `.codex/prompts/` 根层文件:只删除未改动的 kit 生成文件,保留子目录、用户自定义或编辑过的文件,并生成当前 Codex 可发现的 `.agents/skills/`。已有 `team-profile.yaml`、工作区宪法和个人规范不会被 `--force` 覆盖;同名用户 Skill 会保留原文件并输出 `.agent-workflow-new` 合并副本。可先追加 `--dry-run` 查看迁移计划。 + ## 隐私与脱敏边界 本 kit 自身不应包含任何真实客户字段、内部系统地址、真实 URL 或凭证。对外分发前运行: diff --git a/bin/check-command-manifest.cjs b/bin/check-command-manifest.cjs new file mode 100644 index 0000000..472e38b --- /dev/null +++ b/bin/check-command-manifest.cjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { loadCommandManifest } = require('./command-manifest.cjs'); + +const root = path.resolve(__dirname, '..'); +const manifestFile = path.join(root, 'workflow/core/command-manifest.yaml'); +const commandsDir = path.join(root, 'workflow/core/commands'); +const errors = []; +let manifest; + +try { + manifest = loadCommandManifest(manifestFile); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +const expectedFiles = new Set(manifest.commands.map((command) => `${command.id}.md`)); +for (const command of manifest.commands) { + const file = path.join(commandsDir, `${command.id}.md`); + if (!fs.existsSync(file)) { + errors.push(`缺少 core command: ${command.id}.md`); + continue; + } + const content = fs.readFileSync(file, 'utf8'); + if (!content.includes(`# /${command.id}`)) errors.push(`${command.id}.md 缺少匹配标题`); +} + +for (const name of fs.readdirSync(commandsDir)) { + if (!name.endsWith('.md') || name === 'README.md') continue; + if (!expectedFiles.has(name)) errors.push(`core command 未登记到 manifest: ${name}`); +} + +if (errors.length) { + console.error(`命令清单校验失败(${errors.length} 项):`); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`命令清单校验通过:${manifest.commands.length} 个命令,id、slug、core 文件与实现闸门映射一致。`); diff --git a/bin/command-manifest.cjs b/bin/command-manifest.cjs new file mode 100644 index 0000000..db77a97 --- /dev/null +++ b/bin/command-manifest.cjs @@ -0,0 +1,199 @@ +const fs = require('fs'); + +const REQUIRED_FIELDS = [ + 'id', + 'skill_slug', + 'title', + 'description', + 'argument_hint', + 'implementation_gate' +]; + +const EXPECTED_V1_COMMAND_IDS = [ + 'init-workspace', + 'new-feature', + '01-需求讨论', + '澄清', + '02-产品文档', + '02B-UI设计', + '03-技术架构', + '03-06-研发准备', + '04-代码实现', + '04A-前端代码实现', + '04B-后端代码实现', + '05-代码审查', + '06-测试用例', + '定义完成', + '一致性检查', + '交付至完成', + '07-测试执行', + '08-发布准备', + '09-发布执行', + '10-复盘总结', + 'new-product', + 'B1-业务定位', + 'B1-B8-商业化准备', + 'B2-商业模式', + 'B3-PMF与客户画像', + 'B4-场景与购买旅程', + 'B5-渠道漏斗映射', + 'B6-营销获客策略', + 'B7-营销预算', + 'B8-渠道执行策略', + 'B9-策略复盘', + 'workflow-status' +]; +const RESERVED_SKILL_SLUGS = new Set(['agent-workflow']); + +function loadCommandManifest(file) { + const source = fs.readFileSync(file, 'utf8'); + const manifest = parseCommandManifest(source, file); + validateCommandManifest(manifest, file); + return manifest; +} + +function parseCommandManifest(source, file = 'command-manifest.yaml') { + const manifest = { schemaVersion: '', commandCount: NaN, commands: [] }; + const seenRootFields = new Set(); + let current = null; + let inCommands = false; + + source.split(/\r?\n/).forEach((line, index) => { + const lineNumber = index + 1; + if (!line.trim() || line.trimStart().startsWith('#')) return; + + let match = line.match(/^schema_version:\s*(.+)$/); + if (match) { + assertUniqueField(seenRootFields, 'schema_version', file, lineNumber); + manifest.schemaVersion = parseScalar(match[1], file, lineNumber); + return; + } + match = line.match(/^command_count:\s*(.+)$/); + if (match) { + assertUniqueField(seenRootFields, 'command_count', file, lineNumber); + manifest.commandCount = parseScalar(match[1], file, lineNumber); + return; + } + if (/^commands:\s*$/.test(line)) { + assertUniqueField(seenRootFields, 'commands', file, lineNumber); + inCommands = true; + return; + } + if (!inCommands) throw syntaxError(file, lineNumber, 'commands 之前存在未知字段'); + + match = line.match(/^ - ([a-z_]+):\s*(.+)$/); + if (match) { + if (match[1] !== 'id') throw syntaxError(file, lineNumber, '每个 command 的首字段必须是 id'); + current = {}; + manifest.commands.push(current); + current[match[1]] = parseScalar(match[2], file, lineNumber); + return; + } + match = line.match(/^ ([a-z_]+):\s*(.+)$/); + if (match && current) { + if (!REQUIRED_FIELDS.includes(match[1])) throw syntaxError(file, lineNumber, `未知 command 字段: ${match[1]}`); + if (match[1] in current) throw syntaxError(file, lineNumber, `重复 command 字段: ${match[1]}`); + current[match[1]] = parseScalar(match[2], file, lineNumber); + return; + } + throw syntaxError(file, lineNumber, `无法解析: ${line.trim()}`); + }); + + return manifest; +} + +function assertUniqueField(seen, field, file, lineNumber) { + if (seen.has(field)) throw syntaxError(file, lineNumber, `重复根字段: ${field}`); + seen.add(field); +} + +function validateCommandManifest(manifest, file = 'command-manifest.yaml') { + const errors = []; + if (manifest.schemaVersion !== '1.0') errors.push('schema_version 必须为 "1.0"'); + if (!Number.isInteger(manifest.commandCount) || manifest.commandCount < 1) { + errors.push('command_count 必须为正整数'); + } + if (manifest.schemaVersion === '1.0' && manifest.commandCount !== EXPECTED_V1_COMMAND_IDS.length) { + errors.push(`schema 1.0 的 command_count 必须为 ${EXPECTED_V1_COMMAND_IDS.length}`); + } + if (manifest.commandCount !== manifest.commands.length) { + errors.push(`command_count=${manifest.commandCount},实际 commands=${manifest.commands.length}`); + } + + const ids = new Set(); + const foldedIds = new Set(); + const slugs = new Set(); + for (const [index, command] of manifest.commands.entries()) { + const label = `commands[${index}]`; + for (const field of REQUIRED_FIELDS) { + if (!(field in command)) errors.push(`${label} 缺少 ${field}`); + } + if (!isPortableCommandId(command.id)) { + errors.push(`${label}.id 不是安全文件名`); + } else if (ids.has(command.id) || foldedIds.has(command.id.toLocaleLowerCase('en-US'))) { + errors.push(`重复 id: ${command.id}`); + } else { + ids.add(command.id); + foldedIds.add(command.id.toLocaleLowerCase('en-US')); + } + if (typeof command.skill_slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(command.skill_slug)) { + errors.push(`${label}.skill_slug 必须是小写 ASCII kebab-case`); + } else if (command.skill_slug.length > 63) { + errors.push(`${label}.skill_slug 长度必须小于 64`); + } else if (RESERVED_SKILL_SLUGS.has(command.skill_slug)) { + errors.push(`${label}.skill_slug 是保留的总入口 slug: ${command.skill_slug}`); + } else if (slugs.has(command.skill_slug)) { + errors.push(`重复 skill_slug: ${command.skill_slug}`); + } else { + slugs.add(command.skill_slug); + } + for (const field of ['title', 'description', 'argument_hint']) { + if (typeof command[field] !== 'string' || !command[field].trim()) errors.push(`${label}.${field} 必须为非空字符串`); + } + if (typeof command.implementation_gate !== 'boolean') { + errors.push(`${label}.implementation_gate 必须为 boolean`); + } + } + + if (manifest.schemaVersion === '1.0') { + const expected = new Set(EXPECTED_V1_COMMAND_IDS); + const missing = EXPECTED_V1_COMMAND_IDS.filter((id) => !ids.has(id)); + const unexpected = [...ids].filter((id) => !expected.has(id)); + if (missing.length || unexpected.length) { + errors.push(`schema 1.0 command 集合不完整;缺少: ${missing.join(', ') || '无'};多余: ${unexpected.join(', ') || '无'}`); + } + } + + const gateIds = manifest.commands.filter((item) => item.implementation_gate).map((item) => item.id).sort(); + const expectedGateIds = ['04-代码实现', '04A-前端代码实现', '04B-后端代码实现', '交付至完成'].sort(); + if (JSON.stringify(gateIds) !== JSON.stringify(expectedGateIds)) { + errors.push(`implementation_gate 只能标记 ${expectedGateIds.join(', ')}`); + } + if (errors.length) throw new Error(`${file} 校验失败:\n- ${errors.join('\n- ')}`); +} + +function isPortableCommandId(value) { + if (typeof value !== 'string' || !value || value !== value.trim()) return false; + if (value === '.' || value === '..' || value.includes('..')) return false; + if (/[<>:"/\\|?*\x00-\x1F]/.test(value) || /[. ]$/.test(value)) return false; + return !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(value); +} + +function parseScalar(raw, file, lineNumber) { + try { + return JSON.parse(raw); + } catch { + throw syntaxError(file, lineNumber, '标量必须使用 JSON 字符串、数字或 boolean 格式'); + } +} + +function syntaxError(file, lineNumber, message) { + return new Error(`${file}:${lineNumber} ${message}`); +} + +module.exports = { + loadCommandManifest, + parseCommandManifest, + validateCommandManifest, + EXPECTED_V1_COMMAND_IDS +}; diff --git a/bin/init-workspace.cjs b/bin/init-workspace.cjs index 4ffd30a..40cc355 100644 --- a/bin/init-workspace.cjs +++ b/bin/init-workspace.cjs @@ -2,8 +2,10 @@ const fs = require('fs'); const path = require('path'); const readline = require('readline'); +const { loadCommandManifest } = require('./command-manifest.cjs'); const KIT_ROOT = path.resolve(__dirname, '..'); +const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(KIT_ROOT, 'package.json'), 'utf8')).version; const SUPPORTED_TOOLS = ['codex', 'claude', 'cursor', 'copilot', 'codebuddy', 'kiro', 'trae']; const TOOL_ALIASES = { trea: 'trae', @@ -12,42 +14,12 @@ const TOOL_ALIASES = { github_copilot: 'copilot', 'github-copilot': 'copilot' }; -const GENERATED_BY = 'openone-workflow-kit 0.1.0'; - -const STAGES = [ - ['init-workspace', '初始化工作区', '扫描本地资料、生成 team-profile、缺资料提问,并生成当前工具 adapter。'], - ['new-feature', '初始化功能工作流', '创建 features/{feature}/ 容器、状态文件和截图目录,并完成 S/M/L 复杂度分级。'], - ['01-需求讨论', '需求讨论', '澄清业务目标、边界、验收口径和待确认项。'], - ['澄清', '澄清', '每轮不超过 5 个针对性问题消融语义歧义,答案写回文档并清理待澄清标记。'], - ['02-产品文档', '产品文档', '输出 PRD、业务规则、高层 UI 方向、非功能需求和验收口径。'], - ['02B-UI设计', 'UI 设计', '在产品文档后输出可被实现遵循的信息架构、关键流程、页面清单、组件规范、平台适配、可访问性和 04A 交接规范。'], - ['03-技术架构', '技术架构', '识别项目族、影响仓库、调用链、分支基线和实现准入风险。'], - ['03-06-研发准备', '研发准备编排', '在已有 PRD 和必要的 02B UI 设计基线后串联生成 03 到 06 的研发准备文档;不授权代码实现。'], - ['04-代码实现', '代码实现总览', '在准入通过后记录后端、前端、配置、数据和发布影响的真实改动。'], - ['04A-前端代码实现', '前端代码实现', '记录页面、组件、接口、状态、回显和前端验证。'], - ['04B-后端代码实现', '后端代码实现', '记录接口、服务、数据、事务、消息、配置和后端验证。'], - ['05-代码审查', '代码审查', '以问题优先方式审查真实 diff、发布边界、PRD 一致性和残余风险。'], - ['06-测试用例', '测试用例', '以风险驱动方式输出覆盖矩阵,每条用例可绑定为完成合同的验收 Oracle。'], - ['定义完成', '定义完成', '把 01-06 结论编译成完成合同,通过 Definition Lint 后经用户确认冻结。'], - ['一致性检查', '一致性检查', '实现前对合同与各阶段文档做只读交叉检查,逮住冲突、漂移和覆盖缺口。'], - ['交付至完成', '交付至完成', '合同冻结后在范围内自主循环实现-验证-修复,直到 blocking Oracle 全绿或精确阻塞。'], - ['07-测试执行', '测试执行', '记录真实执行结果与证据,翻转完成合同的 Oracle 状态。'], - ['08-发布准备', '发布准备', '完成个人项目的本地集成、版本号、tag、发布清单、回滚点和渠道材料。'], - ['09-发布执行', '发布执行', '在用户明确授权后执行远程 push、release、商店/平台发布或部署,并记录证据。'], - ['10-复盘总结', '复盘总结', '沉淀项目结论、可复用规则、知识库更新和下一轮改进项。'], - ['new-product', '初始化商业化工作流', '创建 business/{product}/ 容器、商业化状态文件和素材目录。'], - ['B1-业务定位', '业务定位', '基于产品服务盘点、市场调研和竞争对手分析形成定位陈述与细分选择。'], - ['B1-B8-商业化准备', '商业化准备编排', '在容器初始化后串联生成 B1 到 B8 的商业化文档;不授权对外投放。'], - ['B2-商业模式', '商业模式', '梳理价值主张、收入与定价假设、成本结构、单位经济和关键假设。'], - ['B3-PMF与客户画像', 'PMF 与客户画像', '验证产品市场匹配度,确定理想客户画像 ICP 和负面画像。'], - ['B4-场景与购买旅程', '场景与购买旅程', '深挖客户使用场景(JTBD)和付费购买旅程、异议与流失点。'], - ['B5-渠道漏斗映射', '渠道漏斗映射', '明确线上线下各渠道在转化漏斗中的位置、作用、成本和 ICP 匹配度。'], - ['B6-营销获客策略', '营销获客策略', '制定总体营销/获客策略:阶段目标、主攻渠道、信息一致性和内容支柱。'], - ['B7-营销预算', '营销预算', '确定金钱与时间双预算、渠道分配、CAC 目标和止损线。'], - ['B8-渠道执行策略', '渠道执行策略', '制定 SEO 及其他已选渠道的执行 playbook,并把营销工程需求回流研发轨。'], - ['B9-策略复盘', '策略复盘', '按周期复盘漏斗数据、渠道 ROI 和假设判定,输出渠道加码/保持/降配/砍掉决策。'], - ['workflow-status', '工作流状态', '汇总 features 与 business 下所有需求和产品的阶段状态、阻塞和下一步。'] -]; +const GENERATED_BY = `openone-workflow-kit ${PACKAGE_VERSION}`; +const MANAGED_ADAPTER_MARKER = 'generated-by: openone-workflow-kit; managed-adapter: true'; +const COMMAND_MANIFEST = loadCommandManifest(path.join(KIT_ROOT, 'workflow/core/command-manifest.yaml')); +const COMMANDS = COMMAND_MANIFEST.commands; +// Keep the existing tuple consumers small while making the manifest the single source of truth. +const STAGES = COMMANDS.map(({ id, title, description }) => [id, title, description]); const REQUIRED_SOURCES = [ { @@ -153,10 +125,11 @@ function toPortablePath(value) { async function main() { const options = parseArgs(process.argv.slice(2)); - const target = path.resolve(options.target || process.cwd()); - if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { - throw new Error(`目标目录不存在: ${target}`); + const requestedTarget = path.resolve(options.target || process.cwd()); + if (!fs.existsSync(requestedTarget) || !fs.statSync(requestedTarget).isDirectory()) { + throw new Error(`目标目录不存在: ${requestedTarget}`); } + const target = fs.realpathSync(requestedTarget); const detectedTools = detectTools(target); let enabledTools = options.tools ? normalizeTools(options.tools) : detectedTools; @@ -197,14 +170,17 @@ async function main() { } const plannedWrites = buildInstallPlan(target, profile, options); + const legacyPlan = planLegacyCleanup(target, options, plannedWrites, profile.enabledTools); if (options.dryRun) { - printDryRun(target, profile, plannedWrites); + printDryRun(target, profile, plannedWrites, legacyPlan); return; } + assertSafeWritePlan(plannedWrites, options, target); for (const write of plannedWrites) { - writeManagedFile(write.file, write.content, options); + writeManagedFile(write, options, target); } + executeLegacyCleanup(legacyPlan); console.log(`已在 ${target} 初始化 agent 工作流`); console.log(`启用工具: ${enabledTools.join(', ')}`); @@ -405,11 +381,16 @@ function walkFiles(root, maxDepth, visitor) { function buildInstallPlan(target, profile, options) { const writes = []; - const add = (rel, content) => writes.push({ file: path.join(target, rel), content }); + const add = (rel, content, policy = {}) => writes.push({ + file: path.join(target, rel), + content, + ...policy + }); - add('workflow/team-profile.yaml', makeTeamProfileYaml(profile)); + add('workflow/team-profile.yaml', makeTeamProfileYaml(profile), { preserveOnUpgrade: true }); add('workflow/README.md', makeWorkflowReadme()); add('workflow/core/README.md', readKitFile('workflow/core/README.md')); + add('workflow/core/command-manifest.yaml', readKitFile('workflow/core/command-manifest.yaml')); add('workflow/core/commands/README.md', readKitFile('workflow/core/commands/README.md')); add('workflow/core/templates/README.md', readKitFile('workflow/core/templates/README.md')); add('workflow/core/templates/00-workflow-status.md', readKitFile('workflow/core/templates/00-workflow-status.md')); @@ -419,9 +400,9 @@ function buildInstallPlan(target, profile, options) { add('workflow/core/templates/completion-contract.md', readKitFile('workflow/core/templates/completion-contract.md')); add('workflow/core/templates/constitution.template.md', readKitFile('workflow/core/templates/constitution.template.md')); add('workflow/core/templates/living-spec.md', readKitFile('workflow/core/templates/living-spec.md')); - add('workflow/constitution.md', readKitFile('workflow/core/templates/constitution.template.md')); - add('workflow/standards/README.md', makeStandardsReadme()); - add('specs/README.md', makeSpecsReadme()); + add('workflow/constitution.md', readKitFile('workflow/core/templates/constitution.template.md'), { preserveOnUpgrade: true }); + add('workflow/standards/README.md', makeStandardsReadme(), { preserveOnUpgrade: true }); + add('specs/README.md', makeSpecsReadme(), { preserveOnUpgrade: true }); add('workflow/core/templates/team-profile.template.yaml', readKitFile('workflow/core/templates/team-profile.template.yaml')); add('workflow/core/capabilities/README.md', readKitFile('workflow/core/capabilities/README.md')); for (const name of CAPABILITY_FILES) { @@ -440,7 +421,12 @@ function buildInstallPlan(target, profile, options) { // Generate it regardless of selected tools so every adapter can point to it. add('AGENTS.md', makeAgentsEntry(profile)); if (profile.enabledTools.includes('codex')) { - for (const [id] of STAGES) add(`.codex/prompts/${id}.md`, makePrompt(id)); + add('.agents/skills/agent-workflow/SKILL.md', makeAgentWorkflowSkill(), { managedAdapter: true }); + for (const command of COMMANDS) { + const base = `.agents/skills/${command.skill_slug}`; + add(`${base}/SKILL.md`, makeStageSkill(command), { managedAdapter: true }); + add(`${base}/agents/openai.yaml`, makeStageSkillMetadata(command), { managedAdapter: true }); + } } if (profile.enabledTools.includes('claude')) { add('CLAUDE.md', '先读取 AGENTS.md,再遵循 workflow/core 和 workflow/team-profile.yaml。.claude/commands 下的工具命令只是薄 adapter。\n'); @@ -476,31 +462,291 @@ function readKitFileIfExists(rel, fallback) { return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : fallback; } -function writeManagedFile(file, content, options) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - if (fs.existsSync(file) && !options.force) { - const current = fs.readFileSync(file, 'utf8'); - if (current === content) { - console.log(`unchanged ${path.relative(process.cwd(), file)}`); - return; - } - const next = file + '.agent-workflow-new'; - fs.writeFileSync(next, content); - console.log(`exists ${path.relative(process.cwd(), file)} -> wrote ${path.relative(process.cwd(), next)}`); +function assertSafeWritePlan(writes, options, targetRoot) { + for (const write of writes) determineManagedWriteAction(write, options, targetRoot); +} + +function writeManagedFile(write, options, targetRoot) { + assertNoManagedPathSymlink(targetRoot, write.file); + fs.mkdirSync(path.dirname(write.file), { recursive: true }); + // Re-evaluate after mkdir so a path change between the plan and the write is + // caught as close as possible to the mutation. + const action = determineManagedWriteAction(write, options, targetRoot); + const rel = relativeForDisplay(process.cwd(), action.file); + if (action.type === 'unchanged') { + console.log(`unchanged ${rel}`); + return; + } + if (action.type === 'preserve') { + console.log(`preserved ${rel}(升级时保留用户事实/原则文件)`); + return; + } + if (action.type === 'write-alternate') { + // The action resolver guarantees this path does not exist. `wx` closes the + // last ordinary race without overwriting an existing merge sidecar. + fs.writeFileSync(action.file, write.content, { flag: 'wx' }); + console.log(`exists ${relativeForDisplay(process.cwd(), write.file)} -> wrote ${rel}`); return; } - fs.writeFileSync(file, content); - console.log(`wrote ${path.relative(process.cwd(), file)}`); + fs.writeFileSync(action.file, write.content); + console.log(`wrote ${rel}`); +} + +function determineManagedWriteAction(write, options, targetRoot) { + const { file, content } = write; + assertNoManagedPathSymlink(targetRoot, file); + const existing = readRegularFileIfPresent(file, 'managed path'); + if (existing.exists && existing.content === content) { + return { type: 'unchanged', file }; + } + if (existing.exists && options.upgrade && write.preserveOnUpgrade) { + return { type: 'preserve', file }; + } + + const customAdapter = write.managedAdapter && isUserOwnedSkillPath(targetRoot, file, existing); + if ((!existing.exists && !customAdapter) || (existing.exists && options.force && !customAdapter)) { + return { type: 'write', file }; + } + + const alternate = `${file}.agent-workflow-new`; + assertNoManagedPathSymlink(targetRoot, alternate); + const alternateExisting = readRegularFileIfPresent(alternate, 'managed merge sidecar'); + if (alternateExisting.exists) { + if (alternateExisting.content === content) return { type: 'unchanged', file: alternate }; + throw new Error(`拒绝覆盖已有 managed merge sidecar: ${alternate}`); + } + return { type: 'write-alternate', file: alternate }; +} + +function readRegularFileIfPresent(file, label) { + if (!fs.existsSync(file)) return { exists: false, content: '' }; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} 不是普通文件: ${file}`); + } + return { exists: true, content: fs.readFileSync(file, 'utf8') }; +} + +function isUserOwnedSkillPath(targetRoot, file, existing) { + if (existing.exists && !existing.content.includes(MANAGED_ADAPTER_MARKER)) return true; + const skillsRoot = path.join(path.resolve(targetRoot), '.agents', 'skills'); + const relative = path.relative(skillsRoot, path.resolve(file)); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false; + const [slug] = relative.split(path.sep); + if (!slug) return false; + const owner = path.join(skillsRoot, slug, 'SKILL.md'); + if (path.resolve(owner) === path.resolve(file) || !fs.existsSync(owner)) return false; + assertNoManagedPathSymlink(targetRoot, owner); + const ownerFile = readRegularFileIfPresent(owner, 'Skill owner'); + return ownerFile.exists && !ownerFile.content.includes(MANAGED_ADAPTER_MARKER); +} + +function assertNoManagedPathSymlink(targetRoot, file) { + const root = path.resolve(targetRoot); + const resolved = path.resolve(file); + const relative = path.relative(root, resolved); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`拒绝写入目标工作区外的 managed path: ${resolved}`); + } + let current = root; + for (const part of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let stat; + try { + stat = fs.lstatSync(current); + } catch (error) { + if (error.code === 'ENOENT') break; + throw error; + } + if (stat.isSymbolicLink()) throw new Error(`拒绝通过 symbolic link 写入 managed path: ${current}`); + if (current !== resolved && !stat.isDirectory()) { + throw new Error(`managed path 的父路径不是目录: ${current}`); + } + } } -function printDryRun(target, profile, writes) { +function printDryRun(target, profile, writes, legacyPlan) { console.log(`Dry run 目标目录: ${target}`); console.log(`已识别工具: ${profile.detectedTools.join(', ') || '(无)'}`); console.log(`启用工具: ${profile.enabledTools.join(', ')}`); console.log(`已识别仓库: ${profile.repos.length}`); console.log(`缺失资料组: ${profile.missing.map((item) => item.key).join(', ') || '(无)'}`); console.log('计划写入:'); - for (const write of writes) console.log(`- ${path.relative(target, write.file)}`); + for (const write of writes) console.log(`- ${relativeForDisplay(target, write.file)}`); + if (legacyPlan && (legacyPlan.remove.length || legacyPlan.keep.length)) { + console.log('旧版 Codex adapter 清理计划(--upgrade 时执行):'); + for (const item of legacyPlan.remove) { + console.log(`- 将删除 ${relativeForDisplay(target, item.file)}(kit 指纹匹配;${item.reason})`); + } + for (const item of legacyPlan.keep) { + console.log(`- 保留 ${relativeForDisplay(target, item.file)}(内容不匹配 kit 指纹或路径不可安全遍历)`); + } + } +} + +function relativeForDisplay(base, file) { + return toPortablePath(path.relative(base, file)); +} + +function planLegacyCleanup(target, options, plannedWrites = [], enabledTools = []) { + const plan = { remove: [], keep: [], dirs: [], targetRoot: target }; + if (!options.upgrade || !enabledTools.includes('codex')) return plan; + + const legacyRoot = path.join(target, '.codex/prompts'); + planLegacyCodexPrompts(target, legacyRoot, plan); + planOrphanCodexSkills(target, plannedWrites, plan); + return plan; +} + +function planLegacyCodexPrompts(target, root, plan) { + try { + assertNoManagedPathSymlink(target, root); + } catch { + plan.keep.push({ file: root, reason: '旧版 Codex adapter 路径含 symbolic link,拒绝遍历或删除' }); + return; + } + if (!fs.existsSync(root)) return; + const stat = fs.lstatSync(root); + const reason = 'Codex 不加载项目级 .codex/prompts;1.0.0 起迁移到 .agents/skills'; + if (stat.isSymbolicLink() || !stat.isDirectory()) { + plan.keep.push({ file: root, reason: `${reason};路径不是可安全遍历的普通目录` }); + return; + } + + // 0.1.0 only generated the 32 direct children below `.codex/prompts`. + // Nested directories are user-owned, even when a copied file still has an + // exact historical template fingerprint. + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const file = path.join(root, entry.name); + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink() || stat.isDirectory() || !stat.isFile()) { + plan.keep.push({ file, reason: `${reason};只迁移 0.1.0 生成的根层普通文件` }); + continue; + } + const text = fs.readFileSync(file, 'utf8'); + const id = path.basename(file, '.md'); + const command = COMMANDS.find((item) => item.id === id); + const generated = path.extname(file) === '.md' && command && + normalizeManagedText(text) === normalizeManagedText(makeHistoricalCodexPrompt(id)); + if (generated) { + plan.remove.push({ file, reason, content: text }); + plan.dirs.push(path.dirname(file)); + } else { + plan.keep.push({ file, reason }); + } + } + plan.dirs.push(root, path.dirname(root)); +} + +function planOrphanCodexSkills(target, plannedWrites, plan) { + const root = path.join(target, '.agents/skills'); + try { + assertNoManagedPathSymlink(target, root); + } catch { + plan.keep.push({ file: root, reason: 'Codex Skills 路径含 symbolic link,拒绝遍历或删除' }); + return; + } + if (!fs.existsSync(root)) return; + const stat = fs.lstatSync(root); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + plan.keep.push({ file: root, reason: 'Codex Skills 根路径不是可安全遍历的普通目录' }); + return; + } + + const expected = new Set(plannedWrites.map((item) => path.resolve(item.file))); + const { files, unsafe } = listRegularFiles(root); + for (const file of unsafe) { + plan.keep.push({ file, reason: '拒绝跟随 Codex Skill 目录中的 symbolic link' }); + } + for (const file of files) { + if (expected.has(path.resolve(file))) continue; + const text = fs.readFileSync(file, 'utf8'); + if (text.includes(MANAGED_ADAPTER_MARKER)) { + plan.remove.push({ + file, + reason: '当前 command manifest 已删除或重命名该 openone 管理的 Codex Skill', + content: text + }); + let directory = path.dirname(file); + while (directory.startsWith(path.resolve(root))) { + plan.dirs.push(directory); + if (directory === path.resolve(root)) break; + directory = path.dirname(directory); + } + } else { + plan.keep.push({ file, reason: '不含 openone managed marker,按用户自定义 Skill 保留' }); + } + } +} + +function listRegularFiles(root) { + const files = []; + const unsafe = []; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink()) { + unsafe.push(file); + } else if (stat.isDirectory()) { + visit(file); + } else if (stat.isFile()) { + files.push(file); + } + } + } + visit(root); + return { files, unsafe }; +} + +function executeLegacyCleanup(plan) { + for (const item of plan.remove) { + let current; + try { + assertNoManagedPathSymlink(plan.targetRoot, item.file); + const stat = fs.lstatSync(item.file); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file'); + current = fs.readFileSync(item.file, 'utf8'); + } catch { + console.log(`kept-changed ${item.file}(清理前路径状态已变化,拒绝删除)`); + continue; + } + if (current !== item.content) { + console.log(`kept-changed ${item.file}(清理计划生成后内容已变化,拒绝删除)`); + continue; + } + try { + assertNoManagedPathSymlink(plan.targetRoot, item.file); + } catch { + console.log(`kept-changed ${item.file}(清理前父路径状态已变化,拒绝删除)`); + continue; + } + fs.unlinkSync(item.file); + console.log(`removed-legacy ${item.file}(${item.reason})`); + } + for (const item of plan.keep) { + console.log(`kept-unrecognized ${item.file}(${item.reason})`); + } + + const directories = [...new Set(plan.dirs.map((dir) => path.resolve(dir)))] + .sort((a, b) => b.split(path.sep).length - a.split(path.sep).length); + for (const directory of directories) { + try { + assertNoManagedPathSymlink(plan.targetRoot, directory); + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) continue; + if (fs.readdirSync(directory).length === 0) { + fs.rmdirSync(directory); + console.log(`removed-legacy-dir ${directory}`); + } + } catch { + // Directory removal is best-effort; files are protected by exact fingerprints above. + } + } +} + +function normalizeManagedText(value) { + return String(value).replace(/\r\n/g, '\n'); } function makeTeamProfileYaml(profile) { @@ -745,8 +991,11 @@ function makeToolUsage(profile) { if (tools.includes('codex')) { blocks.push(`### Codex -- Codex 会自动读取本 \`AGENTS.md\`。 -- 阶段 prompt 位于 \`.codex/prompts/\`。可以调用阶段 prompt,或要求 Codex 按 \`workflow/core/commands/.md\` 执行。`); +- Codex 会自动读取本 \`AGENTS.md\`,并从 \`.agents/skills/\` 发现本项目的总入口和 ${COMMANDS.length} 个阶段 Skill。 +- Codex Desktop 输入 \`/01\`、\`/B1\` 等关键词后,在 Skills 分组选择中文阶段;CLI/IDE 使用 \`/skills\` 或 \`$\`。 +- Codex 的项目 Skill 不是 Claude 式字面自定义命令:不能把 \`/01-需求讨论\` 当作可直接提交的项目命令 ID。 +- 所有阶段 Skill 都设置 \`allow_implicit_invocation: false\`;选择 Skill 只选择阶段,不会绕过实现、发布、投放或其他授权闸门。 +- 项目级 \`.codex/prompts/\` 不会被 Codex 加载,本 kit 不再生成该目录。`); } if (tools.includes('cursor')) { @@ -813,6 +1062,7 @@ ${makeCommandTable()} ## 单一事实源 - 工作流规则:\`workflow/core/\` +- 阶段清单:\`workflow/core/command-manifest.yaml\` - 工作区配置(事实与路径):\`workflow/team-profile.yaml\` - 工作区宪法(不可协商原则):\`workflow/constitution.md\` - 个人规范层:\`workflow/standards/\` @@ -887,7 +1137,7 @@ function makeThinCommand(toolName, id) { `; } -function makePrompt(id) { +function makeHistoricalCodexPrompt(id) { return `# ${id} 读取 \`AGENTS.md\`、\`workflow/team-profile.yaml\` 和 \`workflow/core/commands/${id}.md\`。 @@ -896,6 +1146,96 @@ function makePrompt(id) { `; } +function makeStageSkill(command) { + const priorContext = command.id === 'init-workspace' + ? '目标工作区中的本地事实与资料路径' + : command.id === 'workflow-status' + ? '\`features/\` 与 \`business/\` 下的状态和阶段文档' + : isBusinessCommand(command.id) + ? '\`business//\` 下的前序阶段文档' + : '\`features//\` 下的前序阶段文档'; + const authorizationNote = isBusinessCommand(command.id) + ? '商业化 Skill 只授权生成文档和清单;发布、投放、outreach 或付费动作仍需用户明确授权。' + : '用户显式选择本 Skill 只表示选择阶段;实现、远程 push、release、部署、数据库或生产配置写入仍按 core 的闸门和授权边界处理。'; + + return `--- +name: ${command.skill_slug} +description: ${yamlQuote(`仅在用户显式选择 ${command.id}(${command.title})阶段时使用。${command.description}`)} +--- + + + +# /${command.id} ${command.title} + +本 Skill 是由 \`workflow/core/command-manifest.yaml\` 生成的分阶段发现入口,不复制阶段规则。 + +- 阶段作用:${command.description} +- 参数提示:\`${command.argument_hint}\` + +执行时必须按顺序读取: + +1. 根目录 \`AGENTS.md\` +2. \`workflow/team-profile.yaml\` +3. \`workflow/core/command-manifest.yaml\` +4. \`workflow/core/commands/${command.id}.md\` +5. ${priorContext} + +${authorizationNote} +`; +} + +function makeStageSkillMetadata(command) { + return `# ${MANAGED_ADAPTER_MARKER} +interface: + display_name: ${yamlQuote(`${command.id} ${command.title}`)} + short_description: ${yamlQuote(shortDescription(command.description))} + default_prompt: ${yamlQuote(`执行 /${command.id} 阶段,并严格读取 AGENTS.md、team profile 与对应 core command;若参数不足先说明缺失项。`)} +policy: + allow_implicit_invocation: false +`; +} + +function makeAgentWorkflowSkill() { + return `--- +name: agent-workflow +description: 按 openone-workflow-kit 的阶段契约推进个人开发者的研发与商业化双轨工作。适用于需求、完成合同、实现、测试、发布、复盘、定位、商业模式、PMF、渠道、预算与策略复盘等阶段请求。 +--- + + + +# agent-workflow + +本 Skill 只负责把请求路由到正确阶段契约。自动加载或语义匹配本 Skill 不等于用户显式选择了某个阶段,也不授权代码实现、远程发布、营销投放或其他高风险动作。 + +执行任何工作流阶段时,按顺序读取: + +1. 根目录 \`AGENTS.md\`(快速开始、命令表、硬闸门) +2. \`workflow/team-profile.yaml\`(事实、仓库、分支与授权策略) +3. \`workflow/core/command-manifest.yaml\`(全部 ${COMMANDS.length} 个阶段及 Skill slug) +4. \`workflow/core/commands/<用户选择的阶段>.md\`(阶段契约) + +规则: + +- 研发阶段产物写入工作区级 \`features//\`;商业化阶段产物写入 \`business//\`。 +- 实现必须通过完成合同、功能分支、阶段和并行开发闸门。 +- 商业化阶段不授权公开发布、投放、outreach 或付费动作;营销工程需求必须回流研发轨。 +- 远程 push、release、部署、数据库写入和生产配置写入仍需要用户明确授权。 +- 优先使用本地证据;资料缺失时记录精确缺口。 +`; +} + +function isBusinessCommand(id) { + return id === 'new-product' || /^B\d/.test(id); +} + +function shortDescription(value) { + return value.length <= 60 ? value : `${value.slice(0, 59)}…`; +} + +function yamlQuote(value) { + return JSON.stringify(String(value)); +} + function makeCursorRule() { return `--- description: "个人开发者 agent 工作流:分阶段交付、审查、测试、发布和复盘。" diff --git a/docs/assets/quick-demo.svg b/docs/assets/quick-demo.svg index 431e28f..e09ba45 100644 --- a/docs/assets/quick-demo.svg +++ b/docs/assets/quick-demo.svg @@ -1,6 +1,6 @@ OpenOne Workflow Kit 30-second quick demo - Three steps: create a clean workspace, run OpenOne Workflow Kit version 0.1.0 for Codex and Cursor, then verify the team profile and agent guide were generated. + Three steps: create a clean workspace, run OpenOne Workflow Kit version 1.0.0 for Codex and Cursor, then verify the team profile and agent guide were generated. @@ -36,7 +36,7 @@ 2 - 运行 v0.1.0 + 运行 v1.0.0 Generate selected adapters @@ -62,7 +62,7 @@ $ mkdir openone-demo && cd openone-demo $ - npx --yes --package openone-workflow-kit@0.1.0 openone-workflow-init + npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init --target . --tools codex,cursor --yes $ node -e "for (const f of ['workflow/team-profile.yaml','AGENTS.md']) diff --git a/docs/assets/visual-manifest.json b/docs/assets/visual-manifest.json index 3de6359..6e7c352 100644 --- a/docs/assets/visual-manifest.json +++ b/docs/assets/visual-manifest.json @@ -27,7 +27,7 @@ "width": 1440, "height": 720, "purpose": "Three-step quick demo", - "sha256": "cc18d2857e6ebdd91140f4232adfeeea31917aef5b0958ed267a34b5e20556ff" + "sha256": "57d3b06b635d3e97e1e3b46f4717d5bfbe8b85c6166d2e28ca0cdbe20061af4a" }, { "file": "social-preview.svg", diff --git a/docs/manual-publish.md b/docs/manual-publish.md index 5bc0564..defde41 100644 --- a/docs/manual-publish.md +++ b/docs/manual-publish.md @@ -2,7 +2,7 @@ 本指南用于维护 OpenOne Workflow Kit 自身。agent 可以准备本地验证和发布说明;远程仓库创建、push、tag push 或 package 发布需要维护者明确授权。 -## v0.1.0 本地准入 +## v1.0.0 本地准入 先运行: @@ -11,6 +11,8 @@ npm run check npm run build:release ``` +再从 tarball 安装到隔离目录,并用真实 Codex `skills/list` 验证 `agent-workflow` 加 32 个阶段 Skill 均可发现、enabled=true、errors=0。自动结构测试不能代替这一步消费面验收。 + `build:release` 会拒绝存在已修改或未跟踪文件的工作树;生成的 `dist/RELEASE_MANIFEST.md` 必须记录当前 `source_commit`、`source_tree` 与 `source_dirty: false`。任何源码变更都要先形成新的 reviewed commit,再重新构建 tarball。 可分享的本地归档位于: @@ -28,7 +30,7 @@ dist/RELEASE_MANIFEST.md 再核对 npm 上没有已发布的同版本: ```bash -npm view openone-workflow-kit@0.1.0 version +npm view openone-workflow-kit@1.0.0 version ``` 首次发布前预期 Registry 返回 404/`E404`。只有精确的不存在结果才能解读为“尚未发布”;认证、网络或权限错误都必须先解决。 @@ -44,23 +46,29 @@ npm view openone-workflow-kit@0.1.0 version - 人工检查 tarball 文件列表; - 确认没有私有资料、真实业务数据或凭证。 -## 发布 v0.1.0(授权后) +## 发布 v1.0.0(授权后) -以已验证的 `main` commit 为唯一基线。先 push 该 commit,并等待该 SHA 的 GitHub Actions 全绿: +先通过 PR 把 reviewed commit 合入 `main`,等待该 `main` SHA 的 GitHub Actions 全绿;随后切换到本地 `main` 并只允许 fast-forward 到远端真相: ```bash -git push origin main +git switch main +git pull --ff-only origin main +npm run check +npm run build:release ``` -发布前在同一个 clean checkout 中再次确认工作树、manifest 和本地 tarball SHA-1 都绑定同一 commit,并确认 npm 身份有效: +发布前在同一个 clean `main` checkout 中再次确认工作树、远程 `main`、manifest 和本地 tarball SHA-1 都绑定同一 commit,并确认 npm 身份有效: ```bash source_commit="$(sed -n 's/^- source_commit: //p' dist/RELEASE_MANIFEST.md)" source_tree="$(sed -n 's/^- source_tree: //p' dist/RELEASE_MANIFEST.md)" -local_shasum="$(node -e "const fs=require('node:fs'),c=require('node:crypto');process.stdout.write(c.createHash('sha1').update(fs.readFileSync('dist/openone-workflow-kit-0.1.0.tgz')).digest('hex'))")" +local_shasum="$(node -e "const fs=require('node:fs'),c=require('node:crypto');process.stdout.write(c.createHash('sha1').update(fs.readFileSync('dist/openone-workflow-kit-1.0.0.tgz')).digest('hex'))")" +remote_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" +test "$(git branch --show-current)" = "main" test -z "$(git status --porcelain=v1 --untracked-files=all)" test "$(git rev-parse HEAD)" = "$source_commit" test "$(git rev-parse 'HEAD^{tree}')" = "$source_tree" +test "$remote_main" = "$source_commit" npm whoami ``` @@ -68,14 +76,17 @@ npm whoami ```bash source_commit="$(sed -n 's/^- source_commit: //p' dist/RELEASE_MANIFEST.md)" -local_shasum="$(node -e "const fs=require('node:fs'),c=require('node:crypto');process.stdout.write(c.createHash('sha1').update(fs.readFileSync('dist/openone-workflow-kit-0.1.0.tgz')).digest('hex'))")" +local_shasum="$(node -e "const fs=require('node:fs'),c=require('node:crypto');process.stdout.write(c.createHash('sha1').update(fs.readFileSync('dist/openone-workflow-kit-1.0.0.tgz')).digest('hex'))")" +remote_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" +test "$(git branch --show-current)" = "main" test -z "$(git status --porcelain=v1 --untracked-files=all)" test "$(git rev-parse HEAD)" = "$source_commit" +test "$remote_main" = "$source_commit" npm publish --access public -registry_version="$(npm view openone-workflow-kit@0.1.0 version)" -registry_git_head="$(npm view openone-workflow-kit@0.1.0 gitHead)" -registry_shasum="$(npm view openone-workflow-kit@0.1.0 dist.shasum)" -test "$registry_version" = "0.1.0" +registry_version="$(npm view openone-workflow-kit@1.0.0 version)" +registry_git_head="$(npm view openone-workflow-kit@1.0.0 gitHead)" +registry_shasum="$(npm view openone-workflow-kit@1.0.0 dist.shasum)" +test "$registry_version" = "1.0.0" test "$registry_git_head" = "$source_commit" test "$registry_shasum" = "$local_shasum" ``` @@ -84,14 +95,15 @@ Registry 刚写入时允许做有上限的短暂重试;任何字段持续为 ```bash source_commit="$(sed -n 's/^- source_commit: //p' dist/RELEASE_MANIFEST.md)" -test -z "$(git tag --list v0.1.0)" -git tag -a v0.1.0 "$source_commit" -m "OpenOne Workflow Kit v0.1.0" -git push origin v0.1.0 -gh release create v0.1.0 \ - dist/openone-workflow-kit-0.1.0.tgz \ +test -z "$(git tag --list v1.0.0)" +git tag -a v1.0.0 "$source_commit" -m "OpenOne Workflow Kit v1.0.0" +git push origin v1.0.0 +gh release create v1.0.0 \ + dist/openone-workflow-kit-1.0.0.tgz \ dist/RELEASE_MANIFEST.md \ - --title "OpenOne Workflow Kit v0.1.0" \ - --notes-file docs/releases/v0.1.0.md + --verify-tag \ + --title "OpenOne Workflow Kit v1.0.0" \ + --notes-file docs/releases/v1.0.0.md ``` 不得移动或覆盖已存在的 Tag,也不得尝试覆盖 npm 中已存在的同版本。上述命令需要维护者明确授权后执行。 @@ -99,21 +111,33 @@ gh release create v0.1.0 \ 发布后公开验收: ```bash -npm view openone-workflow-kit@latest version -npm view openone-workflow-kit@0.1.0 gitHead -npm view openone-workflow-kit@0.1.0 dist.shasum -git ls-remote --tags origin refs/tags/v0.1.0 -gh release view v0.1.0 --json tagName,isDraft,isPrerelease,assets,url +source_commit="$(sed -n 's/^- source_commit: //p' dist/RELEASE_MANIFEST.md)" +local_shasum="$(node -e "const fs=require('node:fs'),c=require('node:crypto');process.stdout.write(c.createHash('sha1').update(fs.readFileSync('dist/openone-workflow-kit-1.0.0.tgz')).digest('hex'))")" +test "$(npm view openone-workflow-kit@latest version)" = "1.0.0" +test "$(npm view openone-workflow-kit@1.0.0 gitHead)" = "$source_commit" +test "$(npm view openone-workflow-kit@1.0.0 dist.shasum)" = "$local_shasum" +peeled_tag="$(git ls-remote --tags origin 'refs/tags/v1.0.0^{}' | awk '{print $1}')" +test "$peeled_tag" = "$source_commit" +test "$(gh release view v1.0.0 --json isDraft --jq '.isDraft')" = "false" +test "$(gh release view v1.0.0 --json isPrerelease --jq '.isPrerelease')" = "false" +test "$(gh release view v1.0.0 --json tagName --jq '.tagName')" = "v1.0.0" +test "$(gh release view v1.0.0 --json assets --jq '[.assets[].name] | sort | join(",")')" = \ + "RELEASE_MANIFEST.md,openone-workflow-kit-1.0.0.tgz" +test "$(gh run list --branch main --workflow check.yml --limit 1 --json headSha,status,conclusion --jq '.[0].headSha')" = "$source_commit" +test "$(gh run list --branch main --workflow check.yml --limit 1 --json conclusion --jq '.[0].conclusion')" = "success" ``` +这些命令会以非零退出码拒绝 npm 元数据、annotated tag 的 peeled commit、Release 状态/资产或默认分支 CI 与 `source_commit` 的任何漂移。 + ## 远程发布前必须完成 - 运行 `npm run check`。 - 运行 `npm run build:release`。 +- 从 tarball 初始化隔离项目,并用真实 Codex `skills/list` 验证 33 个 repo Skill、0 个错误。 - 使用 `bin/check-sanitized.cjs --extra-banned ` 执行私有 denylist 扫描。 - 检查 `dist/RELEASE_MANIFEST.md`。 - 检查 tarball 内的每个文件。 - 确认 directory publish 后 Registry 的 `gitHead` 等于 manifest `source_commit`,`dist.shasum` 等于本地已验证 tarball 的 SHA-1。 -- 确认 GitHub Release 同时包含 `openone-workflow-kit-0.1.0.tgz` 与 `RELEASE_MANIFEST.md` 两个证据资产。 +- 确认 GitHub Release 同时包含 `openone-workflow-kit-1.0.0.tgz` 与 `RELEASE_MANIFEST.md` 两个证据资产。 - 检查 README、license、示例和安装脚本。 - push、tag push、npm publish 等远程写入必须有维护者明确授权。 diff --git a/docs/publication-decisions.md b/docs/publication-decisions.md index 00b7684..7e545e8 100644 --- a/docs/publication-decisions.md +++ b/docs/publication-decisions.md @@ -7,7 +7,7 @@ OpenOne Workflow Kit 可以进行本地验证和本地打包。远程发布需 | 决策 | 选项 | 当前建议 | | --- | --- | --- | | License | Apache-2.0 / MIT / proprietary | Apache-2.0 | -| 发布渠道 | GitHub / npm / 内部包仓 / tarball | GitHub + npm + tarball(v0.1.0 已授权目标) | +| 发布渠道 | GitHub / npm / 内部包仓 / tarball | GitHub + npm + tarball(v1.0.0 已获当前任务授权) | | 贡献模型 | 关闭 / 仅 issue / 接收 PR / 需要 CLA | 接收 issue 和 PR 模板 | | 支持范围 | best-effort / paid / internal-only | best-effort | | 安全报告 | 私有邮箱 / GitHub private advisory / 内部渠道 | GitHub private advisory 或维护者私有渠道 | @@ -16,7 +16,7 @@ OpenOne Workflow Kit 可以进行本地验证和本地打包。远程发布需 ## 首次外部试用建议 - 使用 Apache-2.0。 -- 在 CI、tarball 和安装 smoke 全部通过后,从 clean reviewed commit 的仓库目录发布 npm `0.1.0`;公开核对 Registry `gitHead` / `dist.shasum` 后,再创建同 commit 的不可变 Git tag `v0.1.0` 和对应 GitHub Release。 +- 在 CI、tarball、Codex adapter conformance 和真实 `skills/list` 验收全部通过后,从 clean reviewed commit 的仓库目录发布 npm `1.0.0`;公开核对 Registry `gitHead` / `dist.shasum` 后,再创建同 commit 的不可变 Git tag `v1.0.0` 和对应 GitHub Release。 - GitHub Release 同时上传已验证 tgz 与 `RELEASE_MANIFEST.md`,让 source commit、source tree、npm 内容哈希和 Release 资产形成可复核证据链。 - 接收 issue 和 PR,但要求脱敏。 - 不承诺生产级支持。 @@ -31,6 +31,6 @@ OpenOne Workflow Kit 可以进行本地验证和本地打包。远程发布需 5. 通过 `npm whoami` 确认发布身份,从 clean reviewed commit directory 发布,并核对 Registry `gitHead` 与 `dist.shasum`。 6. 由维护者手动创建同 commit 的 release tag,并上传 tarball 与 manifest 资产。 -agent 不得执行 publish、push、tag 或远程仓库创建动作。 +未经维护者在当前任务明确授权,agent 不得执行 publish、push、tag 或远程仓库创建动作;获得明确授权后仍必须满足本页和发布清单的全部证据闸门。 手动命令示例维护在 `docs/manual-publish.md`。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 520fdd5..9a6a643 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,6 +6,8 @@ - `npm run check` 通过。 - `npm run build:release` 通过。 +- `node test/codex-adapter-conformance.cjs` 验证 1 个总入口 + 32 个阶段 Skill、manifest 反例与禁止生成 `.codex/prompts`。 +- 从待发布 tarball 初始化隔离目录后,本机 Codex `skills/list` 返回 33 个 enabled repo Skill 且 errors 为 0。 - `dist/RELEASE_MANIFEST.md` 的 `source_commit` 等于 reviewed commit、`source_tree` 等于该 commit 的 tree,且 `source_dirty: false`。 - `npm pack --dry-run` 中 package/version/files/bin/repository/homepage/bugs 与 `package.json` 一致。 - `dist/RELEASE_MANIFEST.md` 已人工检查。 @@ -14,6 +16,7 @@ - README、INIT、CONTRIBUTING、SECURITY、CODE_OF_CONDUCT、LICENSE、NOTICE 已检查。 - 示例数据均为合成数据,不能追溯到真实客户、员工、项目、事故或生产系统。 - 工具 adapter 只指向 workflow core,不削弱硬闸门。 +- Codex 文档只承诺 Desktop `/` 的 Skills 搜索、`/skills` 与 `$`;不宣称支持 Claude 式字面项目命令。 - 初始化器不会执行远程 Git、创建分支、push、构建 / 部署触发、数据库写入或生产配置写入。 ## 私有 denylist 扫描 @@ -59,14 +62,15 @@ node bin/check-sanitized.cjs --extra-banned /path/to/private-denylist.txt - 私有安全报告渠道; - 是否允许提交 `dist/` 产物。 -## v0.1.0 远程真相源 +## v1.0.0 远程真相源 只有下列验证全部通过,才能宣布正式发布: -- `npm view openone-workflow-kit@latest version` 返回 `0.1.0`。 -- `npm view openone-workflow-kit@0.1.0 gitHead` 等于 `RELEASE_MANIFEST.md` 的 `source_commit`。 -- `npm view openone-workflow-kit@0.1.0 dist.shasum` 等于本地已验证 tarball 的 SHA-1。 +- `npm view openone-workflow-kit@latest version` 返回 `1.0.0`。 +- `npm view openone-workflow-kit@1.0.0 gitHead` 等于 `RELEASE_MANIFEST.md` 的 `source_commit`。 +- `npm view openone-workflow-kit@1.0.0 dist.shasum` 等于本地已验证 tarball 的 SHA-1。 - GitHub Actions 默认分支最新 `Check` workflow 全部成功。 -- `refs/tags/v0.1.0` 指向已验证的发布 commit,且没有移动历史 Tag。 -- GitHub Release `v0.1.0` 为 non-draft、non-prerelease,正文使用 `docs/releases/v0.1.0.md`,并包含已验证的 tgz 与 `RELEASE_MANIFEST.md` 两个资产。 +- annotated tag 的 peeled ref `refs/tags/v1.0.0^{}` 等于已验证的发布 commit,且没有移动历史 Tag。 +- GitHub Release `v1.0.0` 为 non-draft、non-prerelease,正文使用 `docs/releases/v1.0.0.md`,并包含已验证的 tgz 与 `RELEASE_MANIFEST.md` 两个资产。 - 在一个新临时目录中从 npm 安装并生成 `workflow/team-profile.yaml`、`workflow/core/` 与 `AGENTS.md`。 +- 在该临时目录中再次运行真实 Codex `skills/list`,确认发布包仍返回 33 个 repo Skill、0 个错误。 diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 0000000..aef4a81 --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,63 @@ +# OpenOne Workflow Kit v1.0.0 + +This release repairs Codex project command discovery while preserving OpenOne's complete 32-stage development and commercialization workflow. + +## What changed + +- Codex now receives one umbrella Skill and 32 explicit stage Skills under `.agents/skills/`. +- Each stage has a stable ASCII slug, a Chinese `display_name`, and `allow_implicit_invocation: false`. +- `workflow/core/command-manifest.yaml` is the single source of truth for stage IDs, Skill slugs, descriptions, argument hints, and implementation gates. +- The generated Skill for every B1-B9 stage reads `business//`; development stages read `features//`; `workflow-status` covers both tracks. +- Generated AGENTS and installation documentation now describe the real Codex experience: Desktop users type `/01` or `/B1` and select a Skill; CLI/IDE users use `/skills` or `$workflow-...`. + +Codex does not provide Claude-style literal project commands such as `/01-需求讨论`. Selecting a Skill chooses a workflow stage; it never bypasses implementation, release, deployment, marketing, or data-write authorization gates. + +## Upgrade from 0.1.0 + +Preview the migration first: + +```bash +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init \ + --target . --tools codex --upgrade --force --dry-run +``` + +Then apply it: + +```bash +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init \ + --target . --tools codex --upgrade --force --yes +``` + +The migration removes only direct child files of `.codex/prompts/` whose complete content matches the 0.1.0 generated template. Nested directories, edited prompts, custom files, custom Skills, workspace facts, constitutions, personal standards, and symbolic links are preserved. A same-name user Skill receives an `.agent-workflow-new` merge sidecar instead of being overwritten. Empty legacy `.codex/` directories are removed. + +## Install + +Requires Node.js 18 or newer. + +```bash +npm install --save-dev openone-workflow-kit@1.0.0 +npx openone-workflow-init --target . --tools codex,claude,cursor --yes +``` + +## Verify + +```bash +npm view openone-workflow-kit@1.0.0 version +node -e "for (const f of ['workflow/team-profile.yaml','workflow/core/command-manifest.yaml','AGENTS.md','.agents/skills/agent-workflow/SKILL.md']) require('node:fs').accessSync(f); console.log('OpenOne ready')" +``` + +Maintainers reproduce the release artifact with: + +```bash +npm run check +npm run build:release +npm pack --dry-run +``` + +The GitHub Release attaches `openone-workflow-kit-1.0.0.tgz` and `RELEASE_MANIFEST.md`. Registry `gitHead`, tarball `dist.shasum`, the annotated `v1.0.0` tag, and the manifest source commit must all identify the same reviewed release. + +## Supported boundary + +The workflow core remains tool-neutral. Generated adapter conformance and a real Codex `skills/list` result prove project Skill discovery; they do not claim identical behavior across tools or authorize high-risk external actions. + +See the [installation guide](https://github.com/bluecoast1379/openone-workflow-kit/blob/v1.0.0/docs/shareable-install.md), [tool recipes](https://github.com/bluecoast1379/openone-workflow-kit/blob/v1.0.0/docs/tool-install-recipes.md), and [maintainer handoff](https://github.com/bluecoast1379/openone-workflow-kit/blob/v1.0.0/docs/maintainer-handoff.md). diff --git a/docs/shareable-install.md b/docs/shareable-install.md index 5e0b060..ceecd60 100644 --- a/docs/shareable-install.md +++ b/docs/shareable-install.md @@ -14,7 +14,7 @@ npx openone-workflow-init --target . --tools codex,claude,cursor --yes ```bash cd /path/to/target-workspace -npm install "git+https://github.com/bluecoast1379/openone-workflow-kit.git#v0.1.0" --save-dev +npm install "git+https://github.com/bluecoast1379/openone-workflow-kit.git#v1.0.0" --save-dev npx openone-workflow-init --target . --tools codex,claude,cursor --yes ``` @@ -26,12 +26,12 @@ npx openone-workflow-init --target . --tools codex,claude,cursor --yes ```bash cd /path/to/target-workspace -npm view openone-workflow-kit@0.1.0 version -npm install openone-workflow-kit@0.1.0 --save-dev +npm view openone-workflow-kit@1.0.0 version +npm install openone-workflow-kit@1.0.0 --save-dev npx openone-workflow-init --target . --tools codex,claude,cursor --yes ``` -预期 `npm view` 输出 `0.1.0`。如果 Registry 尚未返回该版本,不应声称 npm 安装路径可用;请使用上方的已验证 tarball 或不可变 Git 引用。 +预期 `npm view` 输出 `1.0.0`。如果 Registry 尚未返回该版本,不应声称 npm 安装路径可用;请使用上方的已验证 tarball 或不可变 Git 引用。 ## 会生成什么 @@ -40,7 +40,25 @@ npx openone-workflow-init --target . --tools codex,claude,cursor --yes - `workflow/adapters/` - `workflow/INSTALL_REPORT.md` - 必要资料缺失时生成 `workflow/INITIALIZATION_QUESTIONS.md` -- 选中工具的薄入口,例如 `AGENTS.md`、`CLAUDE.md`、`.cursor/commands/` +- 选中工具的薄入口,例如 Codex 的 `AGENTS.md` 与 `.agents/skills/`、Claude 的 `CLAUDE.md`、Cursor 的 `.cursor/commands/` + +## 从 0.1.0 升级 Codex adapter + +先预览迁移: + +```bash +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init \ + --target . --tools codex --upgrade --force --dry-run +``` + +确认后执行: + +```bash +npx --yes --package openone-workflow-kit@1.0.0 openone-workflow-init \ + --target . --tools codex --upgrade --force --yes +``` + +1.0.0 会生成 `.agents/skills/`,并只删除内容精确匹配 0.1.0 模板的 `.codex/prompts/` 根层文件。子目录、用户自定义或被编辑的 prompt 和 symbolic link 均保留,不会自动跟随或删除。升级也保留已有 `team-profile.yaml`、工作区宪法、个人规范和同名用户 Skill;需要合并的新版 Skill 写入 `.agent-workflow-new`,不会静默覆盖。 ## 安全边界 diff --git a/docs/tool-install-recipes.md b/docs/tool-install-recipes.md index ba18ecc..afc67e4 100644 --- a/docs/tool-install-recipes.md +++ b/docs/tool-install-recipes.md @@ -11,9 +11,15 @@ agent-workflow-init --target . --tools codex --yes 生成文件: - `AGENTS.md` -- `.codex/prompts/` +- `.agents/skills/agent-workflow/SKILL.md` +- `.agents/skills/{skill_slug}/SKILL.md` +- `.agents/skills/{skill_slug}/agents/openai.yaml` - `workflow/` +Codex 的项目级入口是根 `AGENTS.md` 和 `.agents/skills/`。Codex Desktop 可输入 `/01`,从 Skills 分组选择中文 `01-需求讨论`;CLI/IDE 使用 `/skills` 或 `$workflow-01-requirement-discussion`。这是 Skill 选择,不是 Claude 式可直接提交的字面 `/01-需求讨论` 命令。全部阶段 Skill 的 `allow_implicit_invocation` 为 `false`。 + +项目级 `.codex/prompts/` 不会被当前 Codex 加载,1.0.0 不再生成该目录。升级 0.1.0 工作区时使用 `--upgrade --force`;初始化器只删除内容精确匹配旧模板的 prompts,保留用户自定义或已编辑文件。先加 `--dry-run` 可只查看迁移计划。 + ## Claude Code ```bash diff --git a/examples/team-profile.example.yaml b/examples/team-profile.example.yaml index f3b626e..5bd37b6 100644 --- a/examples/team-profile.example.yaml +++ b/examples/team-profile.example.yaml @@ -1,6 +1,6 @@ schema_version: "1.0" generated_at: "2026-06-05T00:00:00.000Z" -generated_by: "agent-workflow-init 0.1.0" +generated_by: "openone-workflow-kit 1.0.0" team: name: "Example Team" diff --git a/package.json b/package.json index 0462b1b..7c25048 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openone-workflow-kit", - "version": "0.1.0", + "version": "1.0.0", "private": false, "description": "面向独立开发者的工具无关 Agent 研发与商业化双轨工作流 kit,支持可验证交付、多工具适配、发布与增长复盘。", "keywords": [ @@ -54,7 +54,7 @@ "openone-workflow-check-contract": "bin/check-contract.cjs" }, "scripts": { - "check": "node --check bin/init-workspace.cjs && node --check bin/check-sanitized.cjs && node --check bin/check-contract.cjs && node --check test/smoke.cjs && node --check test/release-readiness.cjs && node --check scripts/release-source-state.cjs && node --check scripts/build-release.cjs && node bin/check-sanitized.cjs && node test/smoke.cjs && node test/release-readiness.cjs", + "check": "node --check bin/init-workspace.cjs && node --check bin/command-manifest.cjs && node --check bin/check-command-manifest.cjs && node --check bin/check-sanitized.cjs && node --check bin/check-contract.cjs && node --check test/smoke.cjs && node --check test/codex-adapter-conformance.cjs && node --check test/release-readiness.cjs && node --check scripts/release-source-state.cjs && node --check scripts/build-release.cjs && node bin/check-command-manifest.cjs && node bin/check-sanitized.cjs && node test/smoke.cjs && node test/codex-adapter-conformance.cjs && node test/release-readiness.cjs", "build:release": "node scripts/build-release.cjs", "init:dry-run": "node bin/init-workspace.cjs --target . --dry-run" }, diff --git a/scripts/build-release.cjs b/scripts/build-release.cjs index 6d97839..db33e7f 100755 --- a/scripts/build-release.cjs +++ b/scripts/build-release.cjs @@ -63,6 +63,9 @@ const installedEntry = path.join( run(process.execPath, [installedEntry, '--target', installTarget, '--tools', 'codex,trea,codebuddy', '--yes']); for (const rel of [ 'AGENTS.md', + '.agents/skills/agent-workflow/SKILL.md', + '.agents/skills/workflow-01-requirement-discussion/SKILL.md', + '.agents/skills/workflow-b9-strategy-review/agents/openai.yaml', '.trae/instructions.md', '.codebuddy/instructions.md', 'workflow/team-profile.yaml', @@ -100,7 +103,7 @@ const manifest = [ '', '## 人工发布边界', '', - '本清单由本地构建生成。创建远程仓库、git push、创建 tag、npm publish 或其他远程写入动作都必须由维护者手动执行。', + '本清单由本地构建生成。创建远程仓库、git push、创建 tag、npm publish 或其他远程写入动作都必须先获得维护者在当前任务中的明确授权。', '' ].join('\n'); diff --git a/test/codex-adapter-conformance.cjs b/test/codex-adapter-conformance.cjs new file mode 100644 index 0000000..d4aa73d --- /dev/null +++ b/test/codex-adapter-conformance.cjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { + loadCommandManifest, + parseCommandManifest, + validateCommandManifest +} = require('../bin/command-manifest.cjs'); + +const root = path.resolve(__dirname, '..'); +const init = path.join(root, 'bin/init-workspace.cjs'); +const manifestFile = path.join(root, 'workflow/core/command-manifest.yaml'); +const manifestSource = fs.readFileSync(manifestFile, 'utf8'); +const commands = loadCommandManifest(manifestFile).commands; +const target = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-conformance-')); + +const result = spawnSync( + process.execPath, + [init, '--target', target, '--tools', 'codex', '--yes'], + { cwd: target, encoding: 'utf8' } +); +if (result.status !== 0) { + throw new Error(`Codex adapter 初始化失败:\n${result.stdout}\n${result.stderr}`); +} + +const errors = validateCodexAdapter(target, commands); +if (errors.length) throw new Error(`Codex adapter conformance 失败:\n- ${errors.join('\n- ')}`); + +const removedRel = `.agents/skills/${commands[0].skill_slug}/agents/openai.yaml`; +const removed = path.join(target, removedRel); +fs.unlinkSync(removed); +const negativeErrors = validateCodexAdapter(target, commands); +if (!negativeErrors.some((message) => message.includes(removedRel))) { + throw new Error('删除一个阶段 metadata 后,conformance 未捕获缺失入口'); +} + +expectManifestFailure( + manifestSource.replace('command_count: 32', 'command_count: 31'), + 'command_count' +); +expectManifestFailure( + manifestSource + .replace('command_count: 32', 'command_count: 31') + .replace(/\n - id: "workflow-status"[\s\S]*$/, '\n'), + '必须为 32' +); +expectManifestFailure( + manifestSource + .replace('id: "workflow-status"', 'id: "unexpected-stage"') + .replace('skill_slug: "workflow-status"', 'skill_slug: "workflow-unexpected-stage"'), + 'command 集合不完整' +); +expectManifestFailure( + manifestSource.replace('skill_slug: "workflow-new-feature"', 'skill_slug: "workflow-init-workspace"'), + '重复 skill_slug' +); +expectManifestFailure( + manifestSource.replace('skill_slug: "workflow-new-feature"', 'skill_slug: "Workflow New Feature"'), + 'ASCII kebab-case' +); +expectManifestFailure( + manifestSource.replace('id: "new-feature"', 'id: "CON"'), + '安全文件名' +); +expectManifestFailure( + manifestSource.replace('id: "new-feature"', 'id: "stage:name"'), + '安全文件名' +); +expectManifestFailure( + manifestSource.replace('id: "new-feature"', 'id: "stage*name"'), + '安全文件名' +); +expectManifestFailure( + manifestSource.replace('id: "new-feature"', 'id: "stage."'), + '安全文件名' +); +expectManifestFailure( + manifestSource.replace('skill_slug: "workflow-new-feature"', 'skill_slug: "agent-workflow"'), + '保留的总入口 slug' +); +expectManifestFailure( + manifestSource.replace( + /(- id: "09-发布执行"[\s\S]*?implementation_gate:) false/, + '$1 true' + ), + 'implementation_gate' +); + +console.log(`Codex adapter conformance passed: 1 umbrella + ${commands.length} stage Skills; negative cases covered.`); + +function validateCodexAdapter(workspace, commandList) { + const failures = []; + const required = [ + 'AGENTS.md', + 'workflow/core/command-manifest.yaml', + '.agents/skills/agent-workflow/SKILL.md' + ]; + for (const rel of required) { + if (!isFile(workspace, rel)) failures.push(`缺少 ${rel}`); + } + if (fs.existsSync(path.join(workspace, '.codex/prompts'))) { + failures.push('不得生成项目级 .codex/prompts'); + } + + const expectedSlugs = new Set(commandList.map((command) => command.skill_slug)); + const actualSlugs = new Set( + fs.readdirSync(path.join(workspace, '.agents/skills'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== 'agent-workflow') + .map((entry) => entry.name) + ); + if (actualSlugs.size !== expectedSlugs.size || [...expectedSlugs].some((slug) => !actualSlugs.has(slug))) { + failures.push('阶段 Skill 目录集合与 command manifest 不一致'); + } + + for (const command of commandList) { + const skillRel = `.agents/skills/${command.skill_slug}/SKILL.md`; + const metadataRel = `.agents/skills/${command.skill_slug}/agents/openai.yaml`; + for (const rel of [skillRel, metadataRel]) { + if (!isFile(workspace, rel)) failures.push(`缺少 ${rel}`); + } + if (!isFile(workspace, skillRel) || !isFile(workspace, metadataRel)) continue; + const skill = fs.readFileSync(path.join(workspace, skillRel), 'utf8'); + const metadata = fs.readFileSync(path.join(workspace, metadataRel), 'utf8'); + for (const marker of [ + `name: ${command.skill_slug}`, + `workflow/core/commands/${command.id}.md`, + command.description, + command.argument_hint, + 'generated-by: openone-workflow-kit; managed-adapter: true' + ]) { + if (!skill.includes(marker)) failures.push(`${skillRel} 缺少 ${marker}`); + } + for (const marker of [ + `display_name: "${command.id} ${command.title}"`, + `执行 /${command.id} 阶段`, + 'allow_implicit_invocation: false', + 'generated-by: openone-workflow-kit; managed-adapter: true' + ]) { + if (!metadata.includes(marker)) failures.push(`${metadataRel} 缺少 ${marker}`); + } + } + + return failures; +} + +function expectManifestFailure(source, expectedMessage) { + let message = ''; + try { + validateCommandManifest(parseCommandManifest(source, 'negative.yaml'), 'negative.yaml'); + } catch (error) { + message = error.message; + } + if (!message.includes(expectedMessage)) { + throw new Error(`manifest 反例未按预期失败(需要包含 ${expectedMessage}): ${message || '未失败'}`); + } +} + +function isFile(workspace, rel) { + try { + return fs.statSync(path.join(workspace, rel)).isFile(); + } catch { + return false; + } +} diff --git a/test/release-readiness.cjs b/test/release-readiness.cjs index 6b57745..d4e052f 100644 --- a/test/release-readiness.cjs +++ b/test/release-readiness.cjs @@ -15,10 +15,11 @@ const pkg = readJson('package.json'); const failures = []; expect(pkg.name === 'openone-workflow-kit', 'package name must be openone-workflow-kit'); -expect(pkg.version === '0.1.0', 'package version must be 0.1.0'); +expect(pkg.version === '1.0.0', 'package version must be 1.0.0'); expect(pkg.private === false, 'package must be publishable'); expect(pkg.license === 'Apache-2.0', 'package license must be Apache-2.0'); expect(pkg.engines && pkg.engines.node === '>=18', 'Node.js support must start at 18'); +expect(!pkg.dependencies || Object.keys(pkg.dependencies).length === 0, 'package must add zero runtime dependencies'); expect(pkg.publishConfig && pkg.publishConfig.access === 'public', 'npm access must be public'); expect( pkg.repository && pkg.repository.url === 'git+https://github.com/bluecoast1379/openone-workflow-kit.git', @@ -63,7 +64,8 @@ for (const marker of [ './docs/assets/quick-demo.svg', './docs/assets/architecture.svg', '## 30 秒 Quick Demo', - 'openone-workflow-kit@0.1.0', + 'openone-workflow-kit@1.0.0', + '.agents/skills/', 'open-workflow-kit', 'business-agent' ]) { @@ -127,13 +129,15 @@ if (sourceCheckout) { } } -expect(fs.existsSync(path.join(root, 'docs', 'releases', 'v0.1.0.md')), 'v0.1.0 release notes must exist'); +expect(fs.existsSync(path.join(root, 'docs', 'releases', 'v0.1.0.md')), 'v0.1.0 release notes must remain available'); +expect(fs.existsSync(path.join(root, 'docs', 'releases', 'v1.0.0.md')), 'v1.0.0 release notes must exist'); +expect(fs.existsSync(path.join(root, 'workflow', 'core', 'command-manifest.yaml')), 'command manifest must be packaged'); const manualPublish = fs.readFileSync(path.join(root, 'docs', 'manual-publish.md'), 'utf8'); for (const marker of [ 'npm publish --access public', - 'npm view openone-workflow-kit@0.1.0 gitHead', - 'npm view openone-workflow-kit@0.1.0 dist.shasum', - 'dist/openone-workflow-kit-0.1.0.tgz', + 'npm view openone-workflow-kit@1.0.0 gitHead', + 'npm view openone-workflow-kit@1.0.0 dist.shasum', + 'dist/openone-workflow-kit-1.0.0.tgz', 'dist/RELEASE_MANIFEST.md' ]) { expect(manualPublish.includes(marker), `manual publish guide must include ${marker}`); diff --git a/test/smoke.cjs b/test/smoke.cjs index 41cbe32..877132d 100755 --- a/test/smoke.cjs +++ b/test/smoke.cjs @@ -6,6 +6,8 @@ const { spawnSync } = require('child_process'); const root = path.resolve(__dirname, '..'); const init = path.join(root, 'bin', 'init-workspace.cjs'); +const { loadCommandManifest } = require(path.join(root, 'bin/command-manifest.cjs')); +const commands = loadCommandManifest(path.join(root, 'workflow/core/command-manifest.yaml')).commands; const { toPortablePath } = require(init); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-workflow-smoke-')); @@ -38,11 +40,28 @@ function run(args, options = {}) { return result; } +function runAt(target, args, options = {}) { + const result = spawnSync(process.execPath, [init, ...args], { + cwd: target, + encoding: 'utf8', + ...options + }); + if (result.status !== 0) { + throw new Error(`命令执行失败: ${result.stderr || result.stdout}`); + } + return result; +} + function assertFile(rel) { const file = path.join(tmp, rel); if (!fs.existsSync(file)) throw new Error(`missing file: ${rel}`); } +function assertNotExists(rel) { + const file = path.join(tmp, rel); + if (fs.existsSync(file)) throw new Error(`unexpected file: ${rel}`); +} + function assertContains(rel, text) { const file = path.join(tmp, rel); const content = fs.readFileSync(file, 'utf8'); @@ -66,12 +85,13 @@ run([ for (const rel of [ 'AGENTS.md', 'CLAUDE.md', - '.codex/prompts/init-workspace.md', - '.codex/prompts/04-代码实现.md', - '.codex/prompts/05-代码审查.md', - '.codex/prompts/10-复盘总结.md', - '.codex/prompts/new-product.md', - '.codex/prompts/B1-业务定位.md', + '.agents/skills/agent-workflow/SKILL.md', + '.agents/skills/workflow-init-workspace/SKILL.md', + '.agents/skills/workflow-04-code-implementation/SKILL.md', + '.agents/skills/workflow-04-code-implementation/agents/openai.yaml', + '.agents/skills/workflow-new-product/SKILL.md', + '.agents/skills/workflow-b1-positioning/SKILL.md', + '.agents/skills/workflow-b9-strategy-review/SKILL.md', '.claude/commands/04-代码实现.md', '.claude/commands/B1-业务定位.md', '.claude/commands/B9-策略复盘.md', @@ -81,6 +101,7 @@ for (const rel of [ '.kiro/instructions.md', '.trae/instructions.md', 'workflow/team-profile.yaml', + 'workflow/core/command-manifest.yaml', 'workflow/INITIALIZATION_QUESTIONS.md', 'workflow/core/commands/init-workspace.md', 'workflow/core/commands/04-代码实现.md', @@ -119,6 +140,39 @@ for (const rel of [ assertFile(rel); } +assertNotExists('.codex/prompts'); +assertContains('.agents/skills/agent-workflow/SKILL.md', 'name: agent-workflow'); +assertContains('.agents/skills/agent-workflow/SKILL.md', '研发与商业化双轨'); +assertContains('.agents/skills/agent-workflow/SKILL.md', '不授权代码实现、远程发布、营销投放'); + +const stageSkillDirectories = fs.readdirSync(path.join(tmp, '.agents/skills'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== 'agent-workflow'); +if (stageSkillDirectories.length !== commands.length) { + throw new Error(`expected ${commands.length} stage Skill directories, found ${stageSkillDirectories.length}`); +} + +for (const command of commands) { + const base = `.agents/skills/${command.skill_slug}`; + const skill = `${base}/SKILL.md`; + const metadata = `${base}/agents/openai.yaml`; + assertFile(skill); + assertFile(metadata); + assertContains(skill, `name: ${command.skill_slug}`); + assertContains(skill, `workflow/core/commands/${command.id}.md`); + assertContains(skill, command.description); + assertContains(skill, command.argument_hint); + assertContains(metadata, `display_name: \"${command.id} ${command.title}\"`); + assertContains(metadata, `执行 /${command.id} 阶段`); + assertContains(metadata, 'allow_implicit_invocation: false'); +} + +for (const id of ['new-product', 'B1-业务定位', 'B1-B8-商业化准备', 'B9-策略复盘']) { + const command = commands.find((item) => item.id === id); + assertContains(`.agents/skills/${command.skill_slug}/SKILL.md`, 'business//'); +} +const statusCommand = commands.find((item) => item.id === 'workflow-status'); +assertContains(`.agents/skills/${statusCommand.skill_slug}/SKILL.md`, '`features/` 与 `business/`'); + assertContains('workflow/team-profile.yaml', '- trae'); assertContains('workflow/team-profile.yaml', 'apps/web'); assertContains('workflow/team-profile.yaml', 'services/api'); @@ -236,10 +290,16 @@ for (const stale of fs.readdirSync(path.join(tmp, 'workflow'))) { } const profileBefore = fs.readFileSync(path.join(tmp, 'workflow/team-profile.yaml'), 'utf8'); fs.writeFileSync(path.join(tmp, 'workflow/team-profile.yaml'), profileBefore + '\n# user note\n'); +const constitutionFile = path.join(tmp, 'workflow/constitution.md'); +const constitutionBefore = fs.readFileSync(constitutionFile, 'utf8'); +fs.writeFileSync(constitutionFile, constitutionBefore + '\n# user constitution note\n'); run(['--target', tmp, '--tools', 'codex,claude,cursor', '--upgrade', '--force', '--yes']); const profileAfter = fs.readFileSync(path.join(tmp, 'workflow/team-profile.yaml'), 'utf8'); -if (profileAfter.includes('# user note')) { - throw new Error('upgrade --force did not overwrite team-profile.yaml'); +if (!profileAfter.includes('# user note')) { + throw new Error('upgrade --force overwrote user facts in team-profile.yaml'); +} +if (!fs.readFileSync(constitutionFile, 'utf8').includes('# user constitution note')) { + throw new Error('upgrade --force overwrote user principles in constitution.md'); } const upgradeStrayFiles = fs .readdirSync(path.join(tmp, 'workflow')) @@ -248,6 +308,181 @@ if (upgradeStrayFiles.length) { throw new Error(`upgrade --force should not produce new .agent-workflow-new files, found: ${upgradeStrayFiles.join(',')}`); } +// Codex 0.1.0 migration: exact generated prompts are removed, while custom, +// edited and symlinked content is preserved. Dry-run must remain side-effect free. +const migrationTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-migration-')); +const promptsRoot = path.join(migrationTmp, '.codex/prompts'); +fs.mkdirSync(promptsRoot, { recursive: true }); +for (const [index, command] of commands.entries()) { + let content = historicalCodexPrompt(command.id); + if (index === 0) content = content.replace(/\n/g, '\r\n'); + fs.writeFileSync(path.join(promptsRoot, `${command.id}.md`), content); +} +fs.writeFileSync(path.join(promptsRoot, 'my-custom-prompt.md'), '# 用户自定义 prompt\n'); +const nestedLegacyCopy = path.join(promptsRoot, 'archive/01-需求讨论.md'); +fs.mkdirSync(path.dirname(nestedLegacyCopy), { recursive: true }); +fs.writeFileSync(nestedLegacyCopy, historicalCodexPrompt('01-需求讨论')); +const orphanManaged = path.join(migrationTmp, '.agents/skills/workflow-removed-stage/SKILL.md'); +fs.mkdirSync(path.dirname(orphanManaged), { recursive: true }); +fs.writeFileSync(orphanManaged, '\n# removed\n'); +const customSkill = path.join(migrationTmp, '.agents/skills/my-custom-skill/SKILL.md'); +fs.mkdirSync(path.dirname(customSkill), { recursive: true }); +fs.writeFileSync(customSkill, '---\nname: my-custom-skill\ndescription: user owned\n---\n'); + +const dryRun = runAt(migrationTmp, [ + '--target', migrationTmp, '--tools', 'codex', '--upgrade', '--force', '--dry-run' +]); +if (!dryRun.stdout.includes('将删除 .codex/prompts/01-需求讨论.md')) { + throw new Error('upgrade dry-run did not report a generated legacy prompt'); +} +for (const command of commands) { + if (!fs.existsSync(path.join(promptsRoot, `${command.id}.md`))) { + throw new Error(`upgrade dry-run deleted ${command.id}.md`); + } +} + +runAt(migrationTmp, [ + '--target', migrationTmp, '--tools', 'codex', '--upgrade', '--force', '--yes' +]); +for (const command of commands) { + if (fs.existsSync(path.join(promptsRoot, `${command.id}.md`))) { + throw new Error(`upgrade did not remove generated prompt ${command.id}.md`); + } +} +if (!fs.existsSync(path.join(promptsRoot, 'my-custom-prompt.md'))) { + throw new Error('upgrade removed a user custom prompt'); +} +if (!fs.existsSync(nestedLegacyCopy)) { + throw new Error('upgrade removed a nested user backup with a historical fingerprint'); +} +if (fs.existsSync(orphanManaged)) throw new Error('upgrade did not remove an orphan managed Skill'); +if (!fs.existsSync(customSkill)) throw new Error('upgrade removed a user custom Skill'); + +const customCollisionTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-custom-skill-collision-')); +const collidingSkill = path.join(customCollisionTmp, '.agents/skills/workflow-init-workspace/SKILL.md'); +fs.mkdirSync(path.dirname(collidingSkill), { recursive: true }); +fs.writeFileSync(collidingSkill, '---\nname: workflow-init-workspace\ndescription: user owned\n---\n'); +runAt(customCollisionTmp, [ + '--target', customCollisionTmp, '--tools', 'codex', '--upgrade', '--force', '--yes' +]); +if (!fs.readFileSync(collidingSkill, 'utf8').includes('description: user owned')) { + throw new Error('upgrade overwrote a same-name user-owned Skill'); +} +const collidingSkillSidecar = `${collidingSkill}.agent-workflow-new`; +if (!fs.readFileSync(collidingSkillSidecar, 'utf8').includes('managed-adapter: true')) { + throw new Error('upgrade did not write a merge sidecar for a same-name user-owned Skill'); +} +const collidingMetadata = path.join(customCollisionTmp, '.agents/skills/workflow-init-workspace/agents/openai.yaml'); +if (fs.existsSync(collidingMetadata)) { + throw new Error('upgrade mixed generated metadata into a user-owned Skill directory'); +} +if (!fs.existsSync(`${collidingMetadata}.agent-workflow-new`)) { + throw new Error('upgrade did not sidecar generated metadata for a user-owned Skill directory'); +} + +const sidecarCollisionTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-sidecar-collision-')); +fs.writeFileSync(path.join(sidecarCollisionTmp, 'AGENTS.md'), '# user entry\n'); +fs.writeFileSync(path.join(sidecarCollisionTmp, 'AGENTS.md.agent-workflow-new'), '# merge in progress\n'); +const sidecarCollision = spawnSync(process.execPath, [ + init, '--target', sidecarCollisionTmp, '--tools', 'codex', '--yes' +], { cwd: sidecarCollisionTmp, encoding: 'utf8' }); +if (sidecarCollision.status === 0 || + !`${sidecarCollision.stdout}\n${sidecarCollision.stderr}`.includes('managed merge sidecar')) { + throw new Error('initializer overwrote or ignored an occupied regular merge sidecar'); +} +if (fs.existsSync(path.join(sidecarCollisionTmp, 'workflow'))) { + throw new Error('write-plan preflight left partial workflow files before a sidecar collision'); +} + +const editedTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-edited-')); +const editedPrompt = path.join(editedTmp, '.codex/prompts/01-需求讨论.md'); +fs.mkdirSync(path.dirname(editedPrompt), { recursive: true }); +fs.writeFileSync(editedPrompt, `${historicalCodexPrompt('01-需求讨论')}\n# 用户追加规则\n`); +runAt(editedTmp, ['--target', editedTmp, '--tools', 'codex', '--upgrade', '--force', '--yes']); +if (!fs.existsSync(editedPrompt)) throw new Error('upgrade removed an edited historical prompt'); + +const cleanMigrationTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-clean-migration-')); +for (const command of commands) { + const file = path.join(cleanMigrationTmp, `.codex/prompts/${command.id}.md`); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, historicalCodexPrompt(command.id)); +} +runAt(cleanMigrationTmp, [ + '--target', cleanMigrationTmp, '--tools', 'codex', '--upgrade', '--force', '--yes' +]); +if (fs.existsSync(path.join(cleanMigrationTmp, '.codex'))) { + throw new Error('upgrade should remove the empty legacy .codex directory'); +} +runAt(cleanMigrationTmp, [ + '--target', cleanMigrationTmp, '--tools', 'codex', '--upgrade', '--force', '--yes' +]); +if (fs.existsSync(path.join(cleanMigrationTmp, '.codex'))) { + throw new Error('a repeated upgrade recreated the legacy .codex directory'); +} + +if (process.platform !== 'win32') { + const symlinkTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-symlink-')); + const externalPrompts = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-external-prompts-')); + const externalFile = path.join(externalPrompts, '01-需求讨论.md'); + fs.writeFileSync(externalFile, historicalCodexPrompt('01-需求讨论')); + fs.mkdirSync(path.join(symlinkTmp, '.codex'), { recursive: true }); + fs.symlinkSync(externalPrompts, path.join(symlinkTmp, '.codex/prompts'), 'dir'); + runAt(symlinkTmp, ['--target', symlinkTmp, '--tools', 'codex', '--upgrade', '--force', '--yes']); + if (!fs.existsSync(externalFile)) throw new Error('upgrade followed a legacy prompts symlink'); + + const parentSymlinkTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-codex-parent-symlink-')); + const externalCodex = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-external-codex-')); + const externalParentFile = path.join(externalCodex, 'prompts/01-需求讨论.md'); + fs.mkdirSync(path.dirname(externalParentFile), { recursive: true }); + fs.writeFileSync(externalParentFile, historicalCodexPrompt('01-需求讨论')); + fs.symlinkSync(externalCodex, path.join(parentSymlinkTmp, '.codex'), 'dir'); + runAt(parentSymlinkTmp, [ + '--target', parentSymlinkTmp, '--tools', 'codex', '--upgrade', '--force', '--yes' + ]); + if (!fs.existsSync(externalParentFile)) { + throw new Error('upgrade followed a parent symlink above legacy prompts'); + } + + const writeSymlinkTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-skill-write-symlink-')); + const externalSkillDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-external-skill-')); + fs.mkdirSync(path.join(writeSymlinkTmp, '.agents/skills'), { recursive: true }); + fs.symlinkSync( + externalSkillDir, + path.join(writeSymlinkTmp, '.agents/skills/workflow-init-workspace'), + 'dir' + ); + const unsafeWrite = spawnSync(process.execPath, [ + init, '--target', writeSymlinkTmp, '--tools', 'codex', '--force', '--yes' + ], { cwd: writeSymlinkTmp, encoding: 'utf8' }); + if (unsafeWrite.status === 0 || !`${unsafeWrite.stdout}\n${unsafeWrite.stderr}`.includes('symbolic link')) { + throw new Error('initializer did not reject a managed Skill path symlink'); + } + if (fs.readdirSync(externalSkillDir).length !== 0) { + throw new Error('initializer wrote through a managed Skill path symlink'); + } + if (fs.existsSync(path.join(writeSymlinkTmp, 'workflow'))) { + throw new Error('write-plan preflight left partial workflow files before a Skill symlink failure'); + } + + const alternateTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openone-alternate-symlink-')); + const alternateExternal = path.join(os.tmpdir(), `openone-alternate-external-${process.pid}.txt`); + fs.writeFileSync(path.join(alternateTmp, 'AGENTS.md'), '# 用户现有说明\n'); + fs.writeFileSync(alternateExternal, 'must remain unchanged\n'); + fs.symlinkSync(alternateExternal, path.join(alternateTmp, 'AGENTS.md.agent-workflow-new')); + const alternateWrite = spawnSync(process.execPath, [ + init, '--target', alternateTmp, '--tools', 'codex', '--yes' + ], { cwd: alternateTmp, encoding: 'utf8' }); + if (alternateWrite.status === 0 || !`${alternateWrite.stdout}\n${alternateWrite.stderr}`.includes('symbolic link')) { + throw new Error('initializer did not reject an alternate managed-file symlink'); + } + if (fs.readFileSync(alternateExternal, 'utf8') !== 'must remain unchanged\n') { + throw new Error('initializer wrote through an .agent-workflow-new symlink'); + } + if (fs.existsSync(path.join(alternateTmp, 'workflow'))) { + throw new Error('write-plan preflight left partial workflow files before a sidecar symlink failure'); + } +} + // Cursor-only install must still generate AGENTS.md (the tool-neutral usage guide), // even though codex is not selected. const cursorTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-workflow-cursor-')); @@ -270,3 +505,7 @@ if (!cursorAgents.includes('### Cursor')) { } console.log('Smoke test passed.'); + +function historicalCodexPrompt(id) { + return `# ${id}\n\n读取 \`AGENTS.md\`、\`workflow/team-profile.yaml\` 和 \`workflow/core/commands/${id}.md\`。\n\n优先使用本地证据。必要资料缺失时,更新 \`workflow/INITIALIZATION_QUESTIONS.md\` 或向用户索要缺失路径。\n`; +} diff --git a/workflow/adapters/README.md b/workflow/adapters/README.md index b6da5e2..b3b7d97 100644 --- a/workflow/adapters/README.md +++ b/workflow/adapters/README.md @@ -5,9 +5,11 @@ Adapters 是从同一套 workflow core 和 `team-profile.yaml` 生成的工具 它们的职责: - 帮当前工具找到 `AGENTS.md`、`workflow/core/` 和 `workflow/team-profile.yaml`。 -- 按当前工具能力暴露 slash commands、prompts、rules 或 instructions。 +- 按当前工具能力暴露 Skills、slash commands、prompts、rules 或 instructions。 - 保持工具入口轻量,不复制或改写 core 规则。 +Codex 的项目级入口使用 `.agents/skills/`:总入口负责双轨路由,每个 manifest 阶段各有一个显式 Skill。项目级 `.codex/prompts/` 不受支持,也不再生成。 + 它们不能: - 削弱 `workflow/core` 的硬闸门。 diff --git a/workflow/core/command-manifest.yaml b/workflow/core/command-manifest.yaml new file mode 100644 index 0000000..6723a1d --- /dev/null +++ b/workflow/core/command-manifest.yaml @@ -0,0 +1,195 @@ +schema_version: "1.0" +command_count: 32 +commands: + - id: "init-workspace" + skill_slug: "workflow-init-workspace" + title: "初始化工作区" + description: "扫描本地资料、生成 team-profile、缺资料提问,并生成当前工具 adapter。" + argument_hint: "<目标目录>" + implementation_gate: false + - id: "new-feature" + skill_slug: "workflow-new-feature" + title: "初始化功能工作流" + description: "创建 features/{feature}/ 容器、状态文件和截图目录,并完成 S/M/L 复杂度分级。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "01-需求讨论" + skill_slug: "workflow-01-requirement-discussion" + title: "需求讨论" + description: "澄清业务目标、边界、验收口径和待确认项。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "澄清" + skill_slug: "workflow-clarification" + title: "澄清" + description: "每轮不超过 5 个针对性问题消融语义歧义,答案写回文档并清理待澄清标记。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "02-产品文档" + skill_slug: "workflow-02-product-document" + title: "产品文档" + description: "输出 PRD、业务规则、高层 UI 方向、非功能需求和验收口径。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "02B-UI设计" + skill_slug: "workflow-02b-ui-design" + title: "UI 设计" + description: "在产品文档后输出可被实现遵循的信息架构、关键流程、页面清单、组件规范、平台适配、可访问性和 04A 交接规范。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "03-技术架构" + skill_slug: "workflow-03-technical-architecture" + title: "技术架构" + description: "识别项目族、影响仓库、调用链、分支基线和实现准入风险。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "03-06-研发准备" + skill_slug: "workflow-03-06-rd-readiness" + title: "研发准备编排" + description: "在已有 PRD 和必要的 02B UI 设计基线后串联生成 03 到 06 的研发准备文档;不授权代码实现。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "04-代码实现" + skill_slug: "workflow-04-code-implementation" + title: "代码实现总览" + description: "在准入通过后记录后端、前端、配置、数据和发布影响的真实改动。" + argument_hint: "<功能名称>" + implementation_gate: true + - id: "04A-前端代码实现" + skill_slug: "workflow-04a-frontend-implementation" + title: "前端代码实现" + description: "记录页面、组件、接口、状态、回显和前端验证。" + argument_hint: "<功能名称>" + implementation_gate: true + - id: "04B-后端代码实现" + skill_slug: "workflow-04b-backend-implementation" + title: "后端代码实现" + description: "记录接口、服务、数据、事务、消息、配置和后端验证。" + argument_hint: "<功能名称>" + implementation_gate: true + - id: "05-代码审查" + skill_slug: "workflow-05-code-review" + title: "代码审查" + description: "以问题优先方式审查真实 diff、发布边界、PRD 一致性和残余风险。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "06-测试用例" + skill_slug: "workflow-06-test-cases" + title: "测试用例" + description: "以风险驱动方式输出覆盖矩阵,每条用例可绑定为完成合同的验收 Oracle。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "定义完成" + skill_slug: "workflow-define-done" + title: "定义完成" + description: "把 01-06 结论编译成完成合同,通过 Definition Lint 后经用户确认冻结。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "一致性检查" + skill_slug: "workflow-consistency-check" + title: "一致性检查" + description: "实现前对合同与各阶段文档做只读交叉检查,逮住冲突、漂移和覆盖缺口。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "交付至完成" + skill_slug: "workflow-deliver-until-done" + title: "交付至完成" + description: "合同冻结后在范围内自主循环实现-验证-修复,直到 blocking Oracle 全绿或精确阻塞。" + argument_hint: "<功能名称>" + implementation_gate: true + - id: "07-测试执行" + skill_slug: "workflow-07-test-execution" + title: "测试执行" + description: "记录真实执行结果与证据,翻转完成合同的 Oracle 状态。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "08-发布准备" + skill_slug: "workflow-08-release-preparation" + title: "发布准备" + description: "完成个人项目的本地集成、版本号、tag、发布清单、回滚点和渠道材料。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "09-发布执行" + skill_slug: "workflow-09-release-execution" + title: "发布执行" + description: "在用户明确授权后执行远程 push、release、商店/平台发布或部署,并记录证据。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "10-复盘总结" + skill_slug: "workflow-10-retrospective" + title: "复盘总结" + description: "沉淀项目结论、可复用规则、知识库更新和下一轮改进项。" + argument_hint: "<功能名称>" + implementation_gate: false + - id: "new-product" + skill_slug: "workflow-new-product" + title: "初始化商业化工作流" + description: "创建 business/{product}/ 容器、商业化状态文件和素材目录。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B1-业务定位" + skill_slug: "workflow-b1-positioning" + title: "业务定位" + description: "基于产品服务盘点、市场调研和竞争对手分析形成定位陈述与细分选择。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B1-B8-商业化准备" + skill_slug: "workflow-b1-b8-commercial-readiness" + title: "商业化准备编排" + description: "在容器初始化后串联生成 B1 到 B8 的商业化文档;不授权对外投放。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B2-商业模式" + skill_slug: "workflow-b2-business-model" + title: "商业模式" + description: "梳理价值主张、收入与定价假设、成本结构、单位经济和关键假设。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B3-PMF与客户画像" + skill_slug: "workflow-b3-pmf-customer-profile" + title: "PMF 与客户画像" + description: "验证产品市场匹配度,确定理想客户画像 ICP 和负面画像。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B4-场景与购买旅程" + skill_slug: "workflow-b4-scenarios-buying-journey" + title: "场景与购买旅程" + description: "深挖客户使用场景(JTBD)和付费购买旅程、异议与流失点。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B5-渠道漏斗映射" + skill_slug: "workflow-b5-channel-funnel" + title: "渠道漏斗映射" + description: "明确线上线下各渠道在转化漏斗中的位置、作用、成本和 ICP 匹配度。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B6-营销获客策略" + skill_slug: "workflow-b6-acquisition-strategy" + title: "营销获客策略" + description: "制定总体营销/获客策略:阶段目标、主攻渠道、信息一致性和内容支柱。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B7-营销预算" + skill_slug: "workflow-b7-marketing-budget" + title: "营销预算" + description: "确定金钱与时间双预算、渠道分配、CAC 目标和止损线。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B8-渠道执行策略" + skill_slug: "workflow-b8-channel-execution" + title: "渠道执行策略" + description: "制定 SEO 及其他已选渠道的执行 playbook,并把营销工程需求回流研发轨。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "B9-策略复盘" + skill_slug: "workflow-b9-strategy-review" + title: "策略复盘" + description: "按周期复盘漏斗数据、渠道 ROI 和假设判定,输出渠道加码/保持/降配/砍掉决策。" + argument_hint: "<产品名称>" + implementation_gate: false + - id: "workflow-status" + skill_slug: "workflow-status" + title: "工作流状态" + description: "汇总 features 与 business 下所有需求和产品的阶段状态、阻塞和下一步。" + argument_hint: "[功能名称或产品名称]" + implementation_gate: false