diff --git a/README.md b/README.md index a7cba1c..ce0190c 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,13 @@ AI Project Workflow makes delivery explicit: v your-project/ ├── AGENTS.md, CLAUDE.md entry points read by the AI tool - ├── core/ full copy of the canonical source ├── .cursor/ or .trae/ or ... adapter files where your tool loads them - └── .ai-workflow/state.json workflow state + └── .ai-workflow/ everything APW-internal + ├── VERSION + ├── state.json workflow state + ├── project.yaml optional custom stage order + ├── runtime/ skills, rules, templates, schemas, agents + └── adapters/ platform notes ``` Enforcement is prompt-based. The adapter instructs the AI agent to read the active Skill, verify upstream documents, and update the state file before moving on. The CLI verifies the files (`apw validate`, `apw status`); the agent follows the contract because every entry point tells it to. If an agent drifts, point it back to `AGENTS.md`. @@ -87,7 +91,7 @@ This is what actually happens after installation, using a todo web app in Cursor npx @dayahs/ai-project-workflow init todo-app --platform cursor ``` - `todo-app/` now contains `AGENTS.md`, `core/`, `.cursor/`, and `.ai-workflow/state.json` with `currentStage: "init"`. + `todo-app/` now contains `AGENTS.md`, `CLAUDE.md`, `.cursor/`, and a `.ai-workflow/` directory holding the runtime, the state file (`currentStage: "init"`) and everything else APW-internal. The project root stays clean. 2. **Start the first stage.** Open `todo-app` in Cursor and tell the agent: @@ -95,7 +99,7 @@ This is what actually happens after installation, using a todo web app in Cursor /prd I want to build a todo web app for small teams ``` - The rule in `.cursor/rules/ai-sdd.mdc` is always attached, so the agent reads `core/skills/prd/SKILL.md`, asks clarifying questions first, then writes `docs/PRD.md` and updates the state file. + The rule in `.cursor/rules/ai-sdd.mdc` is always attached, so the agent reads `.ai-workflow/runtime/skills/prd/SKILL.md`, asks clarifying questions first, then writes `docs/PRD.md` and updates the state file. 3. **Review and gate.** Read `docs/PRD.md` yourself. The agent stops after each stage; nothing advances without your confirmation. @@ -133,13 +137,13 @@ Upstream documents are authoritative. If the code disagrees with the SDD, fix th ## Configurable workflows -APW uses the built-in eight-stage workflow when a project has no `.apw/project.yaml` file: +APW uses the built-in eight-stage workflow when a project has no `.ai-workflow/project.yaml` file: ```text init -> prd -> hld -> sdd -> impl -> review -> deploy -> retro ``` -Projects can define a stage order in `.apw/project.yaml`: +Projects can define a stage order in `.ai-workflow/project.yaml` (a legacy `.apw/project.yaml` is still honored): ```yaml protocolVersion: 1.0.0 @@ -157,9 +161,9 @@ Stage ids must start with a lowercase letter and may contain lowercase letters, Custom stage Skills are resolved in this order: ```text -1. .apw/skills//SKILL.md +1. .ai-workflow/skills//SKILL.md (legacy .apw/skills/ still honored) 2. installed platform Skill -3. core/skills//SKILL.md +3. .ai-workflow/runtime/skills//SKILL.md (core/skills/ in this repository) ``` The project-level YAML parser intentionally supports only this documented subset: scalar `protocolVersion`, a `stages` list, string stage ids, and object stage entries with `id`, `title`, and `description`. It does not support anchors, aliases, multiline blocks, inline collections, or arbitrary nesting. @@ -171,8 +175,8 @@ What gets installed and how the workflow is triggered, per tool: | Tool | Files installed | How it loads | |---|---|---| | Cursor | `.cursor/rules/ai-sdd.mdc` (always applied), `.cursor/skills/`, `.cursor/agents/` | The rule auto-attaches to every chat and points the agent at the active Skill. Type the stage command (for example `/prd`) in chat. | -| Claude Code | `CLAUDE.md`, `AGENTS.md`, `core/` | `CLAUDE.md` is read automatically at session start. | -| Codex | `AGENTS.md`, `core/` | Codex reads `AGENTS.md` natively. | +| Claude Code | `CLAUDE.md`, `AGENTS.md`, `.ai-workflow/runtime/` | `CLAUDE.md` is read automatically at session start. | +| Codex | `AGENTS.md`, `.ai-workflow/runtime/` | Codex reads `AGENTS.md` natively. | | TRAE | `.trae/rules.md`, `.trae/skills/`, `.trae/agents/` | Add `.trae/rules.md` as project rules. TRAE custom agents are configured in its UI — paste the role definitions from `.trae/agents/` when creating them. | | Qoder | `.qoder/skills/`, `.qoder/agents/` | Reference the Skill files in your prompts, or rely on the root `AGENTS.md`. | | CodeBuddy | `.codebuddy/skills/`, `.codebuddy/agents/`, adapter note | Minimal compatibility adapter; falls back to `AGENTS.md`. | @@ -204,7 +208,7 @@ Target projects track progress in `.ai-workflow/state.json`: } ``` -The schema lives at `core/schemas/workflow-state.schema.json`. The agent updates this file at the end of every stage; `apw status` reads it back. +The schema lives at `.ai-workflow/runtime/schemas/workflow-state.schema.json` in installed projects (`core/schemas/` in this repository). The agent updates this file at the end of every stage; `apw status` reads it back. ## CLI reference @@ -237,7 +241,7 @@ Yes — say so explicitly. The agent records the skip and your reason in `skippe Yes. Install with `--platform all` (or run `apw install` again with another platform). Adapters live in separate directories and coexist. **How do I upgrade after a new package version?** -Update the package, then re-run `apw install .` in the project — your edited files are preserved and incoming changes appear as `.ai-sdd.new` files. Run `apw validate .` afterwards. +Update the package, then re-run `apw install .` in the project — your edited files are preserved and incoming changes appear as `.ai-sdd.new` files. Run `apw validate .` afterwards. Projects installed before the consolidated layout (with `core/` at the project root) upgrade with `apw migrate . --apply`. **A stage went wrong. How do I go back?** Each Skill defines rollback rules: broken design returns to `sdd`, wrong architecture boundaries return to `hld`, missing requirements return to `prd`. Revise the upstream document first, then rerun the downstream stages. @@ -263,7 +267,7 @@ examples/configurable-workflow/ test-node/ test suite (node --test) ``` -`core/` is the only canonical source. Adapter files are generated — edit `core/` and run `apw sync . --platform all`. +`core/` is the only canonical source in this repository. Installed projects receive the same content under `.ai-workflow/runtime/`. Adapter files are generated — edit `core/` and run `apw sync . --platform all`. ## Extending diff --git a/README.zh-CN.md b/README.zh-CN.md index f240e14..4e4d1b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,9 +33,13 @@ AI Project Workflow 把交付过程显式化: v your-project/ ├── AGENTS.md, CLAUDE.md AI 工具读取的入口 - ├── core/ 规范源的完整副本 ├── .cursor/ 或 .trae/ 或 ... 适配文件,放在工具真正加载的位置 - └── .ai-workflow/state.json 工作流状态 + └── .ai-workflow/ APW 内部实现全部收纳于此 + ├── VERSION + ├── state.json 工作流状态 + ├── project.yaml 可选的自定义阶段配置 + ├── runtime/ skills、rules、templates、schemas、agents + └── adapters/ 平台说明文件 ``` 约束是基于提示词的:适配层指示 AI 先读当前阶段的 Skill、核对上游文档、更新状态文件,然后才能推进。CLI 负责校验文件(`apw validate`、`apw status`);AI 遵守契约是因为每个入口都这样要求它。如果 AI 跑偏了,让它重新阅读 `AGENTS.md`。 @@ -88,7 +92,7 @@ Qoder 待验证:当前测试账号额度不足,尚未完成真实环境验 npx @dayahs/ai-project-workflow init todo-app --platform cursor ``` - `todo-app/` 现在包含 `AGENTS.md`、`core/`、`.cursor/` 和 `.ai-workflow/state.json`(`currentStage: "init"`)。 + `todo-app/` 现在包含 `AGENTS.md`、`CLAUDE.md`、`.cursor/`,以及一个收纳全部 APW 内部实现的 `.ai-workflow/` 目录(runtime、状态文件 `currentStage: "init"` 等)。项目根目录保持干净。 2. **启动第一个阶段。** 在 Cursor 中打开 `todo-app`,对 AI 说: @@ -96,7 +100,7 @@ Qoder 待验证:当前测试账号额度不足,尚未完成真实环境验 /prd 我要做一个面向小团队的待办清单 Web 应用 ``` - `.cursor/rules/ai-sdd.mdc` 规则始终挂载,AI 会先读 `core/skills/prd/SKILL.md`,先反问澄清,然后把产出写入 `docs/PRD.md` 并更新状态文件。 + `.cursor/rules/ai-sdd.mdc` 规则始终挂载,AI 会先读 `.ai-workflow/runtime/skills/prd/SKILL.md`,先反问澄清,然后把产出写入 `docs/PRD.md` 并更新状态文件。 3. **人工审查与门禁。** 你自己读一遍 `docs/PRD.md`。AI 在每个阶段结束后都会停下;没有你的确认,流程不会前进。 @@ -134,13 +138,13 @@ PRD -> Architecture -> SDD -> Test Plan -> Code ## 可配置工作流 -当项目没有 `.apw/project.yaml` 时,APW 使用内置八阶段: +当项目没有 `.ai-workflow/project.yaml` 时,APW 使用内置八阶段: ```text init -> prd -> hld -> sdd -> impl -> review -> deploy -> retro ``` -项目可以在 `.apw/project.yaml` 中定义阶段顺序: +项目可以在 `.ai-workflow/project.yaml` 中定义阶段顺序(旧路径 `.apw/project.yaml` 仍被兼容): ```yaml protocolVersion: 1.0.0 @@ -158,9 +162,9 @@ stages: 自定义阶段的 Skill 查找顺序: ```text -1. .apw/skills//SKILL.md +1. .ai-workflow/skills//SKILL.md(旧路径 .apw/skills/ 仍被兼容) 2. 已安装平台中的 Skill -3. core/skills//SKILL.md +3. .ai-workflow/runtime/skills//SKILL.md(本仓库内为 core/skills/) ``` 项目级 YAML 解析器只支持这里明确展示的子集:标量 `protocolVersion`、`stages` 列表、字符串阶段 id,以及包含 `id`、`title`、`description` 的对象阶段。它不支持 anchors、aliases、多行块、行内集合或任意深层嵌套。 @@ -172,8 +176,8 @@ stages: | 工具 | 安装的文件 | 加载方式 | |---|---|---| | Cursor | `.cursor/rules/ai-sdd.mdc`(始终生效)、`.cursor/skills/`、`.cursor/agents/` | 规则自动挂载到每次对话,并把 AI 指向当前阶段的 Skill。在对话里输入阶段命令(如 `/prd`)。 | -| Claude Code | `CLAUDE.md`、`AGENTS.md`、`core/` | 会话启动时自动读取 `CLAUDE.md`。 | -| Codex | `AGENTS.md`、`core/` | Codex 原生读取 `AGENTS.md`。 | +| Claude Code | `CLAUDE.md`、`AGENTS.md`、`.ai-workflow/runtime/` | 会话启动时自动读取 `CLAUDE.md`。 | +| Codex | `AGENTS.md`、`.ai-workflow/runtime/` | Codex 原生读取 `AGENTS.md`。 | | TRAE | `.trae/rules.md`、`.trae/skills/`、`.trae/agents/` | 把 `.trae/rules.md` 添加为项目规则。TRAE 的自定义智能体在其 UI 中配置——创建时把 `.trae/agents/` 里的角色定义粘贴进去。 | | Qoder | `.qoder/skills/`、`.qoder/agents/` | 在提示词中引用 Skill 文件,或依赖根目录的 `AGENTS.md`。 | | CodeBuddy | `.codebuddy/skills/`、`.codebuddy/agents/`、适配说明 | 最小兼容适配;回退到 `AGENTS.md`。 | @@ -205,7 +209,7 @@ Claude Code 已通过自然语言阶段指令完成真实验证,例如输入 ` } ``` -schema 位于 `core/schemas/workflow-state.schema.json`。AI 在每个阶段结束时更新此文件;`apw status` 负责读取展示。 +schema 在已安装项目中位于 `.ai-workflow/runtime/schemas/workflow-state.schema.json`(本仓库内为 `core/schemas/`)。AI 在每个阶段结束时更新此文件;`apw status` 负责读取展示。 ## CLI 参考 @@ -238,7 +242,7 @@ apw --version 可以。用 `--platform all` 安装(或对另一个平台再跑一次 `apw install`)。各适配层目录独立,互不干扰。 **包发新版后如何升级?** -更新包,然后在项目里重新运行 `apw install .`——你改过的文件会被保留,新内容以 `.ai-sdd.new` 出现。之后运行 `apw validate .`。 +更新包,然后在项目里重新运行 `apw install .`——你改过的文件会被保留,新内容以 `.ai-sdd.new` 出现。之后运行 `apw validate .`。旧布局项目(`core/` 在项目根)用 `apw migrate . --apply` 一键升级到收纳布局。 **某个阶段做坏了,怎么回退?** 每个 Skill 都定义了回退规则:设计无法实现回 `sdd`,架构边界错了回 `hld`,需求缺失回 `prd`。先修订上游文档,再重跑下游阶段。 @@ -264,7 +268,7 @@ examples/configurable-workflow/ test-node/ 测试套件(node --test) ``` -`core/` 是唯一规范源。适配文件是生成物——改 `core/` 后运行 `apw sync . --platform all`。 +`core/` 是本仓库的唯一规范源;已安装项目中同样的内容位于 `.ai-workflow/runtime/`。适配文件是生成物——改 `core/` 后运行 `apw sync . --platform all`。 ## 扩展 diff --git a/src/commands/install.js b/src/commands/install.js index 54e723d..8eedc34 100644 --- a/src/commands/install.js +++ b/src/commands/install.js @@ -1,15 +1,36 @@ import { basename, join, relative, sep } from "node:path"; import { PLATFORM_CHOICES, PLATFORM_DOT_DIRS } from "../lib/constants.js"; import { expandPlatforms } from "../lib/adapters.js"; -import { copyFileSafe, copyTreeSafe, writeFileSafe } from "../lib/safe-copy.js"; +import { copyFileSafe, writeFileSafe } from "../lib/safe-copy.js"; import { initialState } from "../lib/state.js"; -import { exists, isDirectory, listFiles } from "../lib/fs-utils.js"; +import { exists, isDirectory, listFiles, readText } from "../lib/fs-utils.js"; import { ROOT, rel } from "../lib/path-utils.js"; import { loadWorkflowConfig } from "../lib/workflow-config.js"; +import { RUNTIME_DIR, WORKFLOW_DIR, isTextDocPath, toProjectPaths } from "../lib/layout.js"; + +// Installed projects keep every APW-internal file under .ai-workflow/ so the +// project root only shows the user's own files, the AI entry points +// (AGENTS.md, CLAUDE.md) and the tool dot-directories that the tools +// themselves require at the root. Canonical core/ path references inside +// markdown content are rewritten to .ai-workflow/runtime/ on the way in. +async function installFile(source, destination, context) { + if (isTextDocPath(source)) { + await writeFileSafe(destination, toProjectPaths(await readText(source)), context); + } else { + await copyFileSafe(source, destination, context); + } +} + +async function installTree(sourceRoot, targetRoot, context) { + for (const source of await listFiles(sourceRoot)) { + await installFile(source, join(targetRoot, relative(sourceRoot, source)), context); + } +} // Adapter dot-directories (.cursor/, .trae/, ...) must be installed at the // project root because that is the only location the tools load them from. -// Everything else in an adapter (README notes) stays under adapters//. +// Everything else in an adapter (README notes) goes to +// .ai-workflow/adapters//. async function installAdapter(platform, target, context) { const source = join(ROOT, "adapters", platform); if (!(await exists(source))) return; @@ -17,8 +38,10 @@ async function installAdapter(platform, target, context) { for (const file of await listFiles(source)) { const relPath = relative(source, file); const inDotDir = dotDir && (relPath === dotDir || relPath.startsWith(`${dotDir}${sep}`)); - const destination = inDotDir ? join(target, relPath) : join(target, "adapters", platform, relPath); - await copyFileSafe(file, destination, context); + const destination = inDotDir + ? join(target, relPath) + : join(target, WORKFLOW_DIR, "adapters", platform, relPath); + await installFile(file, destination, context); } } @@ -36,12 +59,13 @@ export async function installCommand(target, options = {}) { } const context = { force: options.force, dryRun: options.dryRun, installed: [], conflicts: [] }; - for (const name of ["AGENTS.md", "CLAUDE.md", "VERSION"]) { - await copyFileSafe(join(ROOT, name), join(target, name), context); + for (const name of ["AGENTS.md", "CLAUDE.md"]) { + await installFile(join(ROOT, name), join(target, name), context); } - await copyTreeSafe(join(ROOT, "core"), join(target, "core"), context); + await copyFileSafe(join(ROOT, "VERSION"), join(target, WORKFLOW_DIR, "VERSION"), context); + await installTree(join(ROOT, "core"), join(target, RUNTIME_DIR), context); await writeFileSafe( - join(target, ".ai-workflow", "state.json"), + join(target, WORKFLOW_DIR, "state.json"), `${JSON.stringify(initialState(basename(target), workflowConfig.config), null, 2)}\n`, context ); diff --git a/src/commands/migrate.js b/src/commands/migrate.js index e622cb2..b5a766c 100644 --- a/src/commands/migrate.js +++ b/src/commands/migrate.js @@ -1,6 +1,8 @@ import { join } from "node:path"; -import { movePath, exists } from "../lib/fs-utils.js"; +import { movePath, exists, listFiles, readText, writeText } from "../lib/fs-utils.js"; import { rel } from "../lib/path-utils.js"; +import { PLATFORMS } from "../lib/constants.js"; +import { RUNTIME_DIR, WORKFLOW_DIR, isTextDocPath, toProjectPaths } from "../lib/layout.js"; const LEGACY_PATHS = [".trae/skills", ".trae/agents", ".trae/rules.md", "skills", "agents", "rules.md"]; @@ -8,32 +10,95 @@ function stamp() { return new Date().toISOString().replaceAll("-", "").replaceAll(":", "").replace(/\..+$/, "").replace("T", "-"); } +// Moves that upgrade an older installed project to the consolidated +// .ai-workflow/ layout. Source repositories (which have src/) are never +// touched: core/ and adapters/ are their canonical sources. +async function layoutMoves(target) { + if (await exists(join(target, "src", "lib", "adapters.js"))) return []; + const moves = []; + if (await exists(join(target, "core"))) moves.push(["core", RUNTIME_DIR]); + if (await exists(join(target, "VERSION"))) moves.push(["VERSION", join(WORKFLOW_DIR, "VERSION")]); + if (await exists(join(target, ".apw", "project.yaml"))) { + moves.push([join(".apw", "project.yaml"), join(WORKFLOW_DIR, "project.yaml")]); + } + if (await exists(join(target, ".apw", "skills"))) { + moves.push([join(".apw", "skills"), join(WORKFLOW_DIR, "skills")]); + } + for (const platform of PLATFORMS) { + if (await exists(join(target, "adapters", platform))) { + moves.push([join("adapters", platform), join(WORKFLOW_DIR, "adapters", platform)]); + } + } + return moves; +} + +// After moving core/ to .ai-workflow/runtime/, markdown content still points +// at core/... paths; rewrite the moved runtime docs, the entry points and any +// installed adapter dot-directories. +async function rewriteProjectPaths(target, options) { + const rewritten = []; + const starts = [ + join(target, RUNTIME_DIR), + join(target, "AGENTS.md"), + join(target, "CLAUDE.md"), + ...[".cursor", ".trae", ".qoder", ".codebuddy", ".catpaw"].map((dir) => join(target, dir)) + ]; + for (const start of starts) { + if (!(await exists(start))) continue; + const files = (await listFiles(start)).length ? await listFiles(start) : [start]; + for (const file of files) { + if (!isTextDocPath(file)) continue; + const text = await readText(file); + const next = toProjectPaths(text); + if (next === text) continue; + await writeText(file, next, { dryRun: options.dryRun }); + rewritten.push(rel(target, file)); + } + } + return rewritten; +} + export async function migrateCommand(target, options = {}) { - const existing = []; + const legacy = []; for (const path of LEGACY_PATHS) { - const source = join(target, path); - if (await exists(source)) existing.push(path); + if (await exists(join(target, path))) legacy.push(path); } + const moves = await layoutMoves(target); - if (!existing.length) return { code: 0, messages: ["PASS no legacy paths found"] }; + if (!legacy.length && !moves.length) return { code: 0, messages: ["PASS no legacy paths found"] }; const destRoot = join(target, "legacy", `backup-${stamp()}`); const messages = ["Migration mapping:"]; - for (const path of existing) { + for (const path of legacy) { messages.push(`- ${path} -> ${rel(target, join(destRoot, path))}`); } + for (const [from, to] of moves) { + messages.push(`- ${from} -> ${to}`); + } if (!options.apply) { messages.push("Dry run only. Re-run with --apply to modify files."); return { code: 0, messages }; } - for (const path of existing) { + for (const path of legacy) { const source = join(target, path); const destination = join(destRoot, path); if (await exists(destination)) return { code: 1, messages: [`FAIL target already exists: ${rel(target, destination)}`] }; await movePath(source, destination, { dryRun: options.dryRun }); } - messages.push(`${options.dryRun ? "DRY-RUN" : "PASS"} migrated legacy files to ${rel(target, destRoot)}`); + + for (const [from, to] of moves) { + const destination = join(target, to); + if (await exists(destination)) return { code: 1, messages: [`FAIL target already exists: ${to}`] }; + await movePath(join(target, from), destination, { dryRun: options.dryRun }); + } + + if (moves.length) { + const rewritten = await rewriteProjectPaths(target, options); + if (rewritten.length) messages.push(`Rewrote core/ path references in ${rewritten.length} file(s).`); + } + + messages.push(`${options.dryRun ? "DRY-RUN" : "PASS"} migrated to the .ai-workflow layout`); return { code: 0, messages }; } diff --git a/src/lib/adapters.js b/src/lib/adapters.js index 01f2944..47df9cb 100644 --- a/src/lib/adapters.js +++ b/src/lib/adapters.js @@ -2,6 +2,7 @@ import { basename, join } from "node:path"; import { GENERATED_HEADER, LEGACY_GENERATED_HEADER, PLATFORM_DOT_DIRS, PLATFORMS, STAGES } from "./constants.js"; import { exists, listFiles, readText, writeText } from "./fs-utils.js"; import { rel } from "./path-utils.js"; +import { WORKFLOW_DIR, detectContentMode, runtimeBase, toProjectPaths } from "./layout.js"; function generatedHeader(root, source) { return `${GENERATED_HEADER}\n\n`; @@ -20,22 +21,13 @@ function isGeneratedContent(text) { ); } -async function renderSkill(root, stage) { - const source = join(root, "core", "skills", stage, "SKILL.md"); - return [source, `${generatedHeader(root, source)}${await readText(source)}`]; -} - -async function renderAgent(root, source) { - return [source, `${generatedHeader(root, source)}${await readText(source)}`]; -} - -// Two layouts exist for adapter output: +// Two path layouts exist for adapter output: // - "repo": files live under `adapters//...` (this repository, // where adapters are generated and committed as the distribution source). // - "project": dot-directories live at the project root, where the tools // actually load them (Cursor only reads `.cursor/` at the workspace root, -// TRAE reads `.trae/`, and so on). README notes stay under -// `adapters//` in both layouts. +// TRAE reads `.trae/`, and so on). README notes live under +// `.ai-workflow/adapters//` (or legacy `adapters//`). export async function detectLayout(root, platform) { const dotDir = PLATFORM_DOT_DIRS[platform]; if (!dotDir) return "repo"; @@ -44,16 +36,26 @@ export async function detectLayout(root, platform) { return "repo"; } -export async function plannedFiles(root, platform, layout = "repo") { +async function renderDoc(root, source, contentMode) { + const content = `${generatedHeader(root, source)}${await readText(source)}`; + return contentMode === "runtime" ? toProjectPaths(content) : content; +} + +export async function plannedFiles(root, platform, layout = "repo", contentMode = "core") { const files = new Map(); - const base = join(root, "adapters", platform); - const dotBase = layout === "project" ? root : base; + const noteBase = contentMode === "runtime" + ? join(root, WORKFLOW_DIR, "adapters", platform) + : join(root, "adapters", platform); + const dotBase = layout === "project" ? root : join(root, "adapters", platform); + const put = (path, content) => { + files.set(path, contentMode === "runtime" ? toProjectPaths(content) : content); + }; let skillRoot = null; let agentRoot = null; if (platform === "cursor") { const rule = join(dotBase, ".cursor", "rules", "ai-sdd.mdc"); - files.set( + put( rule, `---\ndescription: AI Project Workflow rules for Cursor\nalwaysApply: true\n---\n\n${GENERATED_HEADER}\n\n# AI Project Workflow Cursor Adapter\n\nBefore starting a task, read \`AGENTS.md\`, \`core/rules/\`, \`core/skills//SKILL.md\`, and \`.ai-workflow/state.json\` from the project root.\nThis file is a lightweight entry point. The canonical source is \`core/\`.\n` ); @@ -61,7 +63,7 @@ export async function plannedFiles(root, platform, layout = "repo") { agentRoot = join(dotBase, ".cursor", "agents"); } else if (platform === "trae") { const rules = join(dotBase, ".trae", "rules.md"); - files.set( + put( rules, `${GENERATED_HEADER}\n\n# AI Project Workflow Rules\n\nKeep the slash command convention: \`/init\`, \`/prd\`, \`/hld\`, \`/sdd\`, \`/impl\`, \`/review\`, \`/deploy\`, and \`/retro\`.\nBefore acting, read \`AGENTS.md\`, \`core/rules/\`, the active Skill, and workflow state from the project root.\n` ); @@ -75,30 +77,30 @@ export async function plannedFiles(root, platform, layout = "repo") { // YAML header; `ruleType: Always` applies the rule to every conversation. // Docs: https://catpaw.meituan.com/guides/settings/rules const rule = join(dotBase, ".catpaw", "rules", "ai-sdd.md"); - files.set( + put( rule, `---\nruleType: Always\ndescription: AI Project Workflow rules for CatPaw\n---\n\n${GENERATED_HEADER}\n\n# AI Project Workflow CatPaw Adapter\n\nBefore starting a task, read \`AGENTS.md\`, \`core/rules/\`, \`core/skills//SKILL.md\`, and \`.ai-workflow/state.json\` from the project root.\nKeep the stage command convention: \`/init\`, \`/prd\`, \`/hld\`, \`/sdd\`, \`/impl\`, \`/review\`, \`/deploy\`, and \`/retro\`.\nThis file is a lightweight entry point. The canonical source is \`core/\`.\n` ); skillRoot = join(dotBase, ".catpaw", "skills"); agentRoot = join(dotBase, ".catpaw", "agents"); } else if (platform === "codebuddy") { - const note = join(base, "README.md"); - files.set( + const note = join(noteBase, "README.md"); + put( note, `${GENERATED_HEADER}\n\n# CodeBuddy Adapter\n\nThis adapter uses a minimal compatibility convention. Prefer \`AGENTS.md\` and \`core/skills/\` from the project root.\nIf the current CodeBuddy version recognizes \`.codebuddy/skills/\` or \`.codebuddy/rules/\`, use the generated files in this directory. Otherwise, fall back to \`AGENTS.md\`.\n` ); skillRoot = join(dotBase, ".codebuddy", "skills"); agentRoot = join(dotBase, ".codebuddy", "agents"); } else if (platform === "claude-code") { - const note = join(base, "README.md"); - files.set( + const note = join(noteBase, "README.md"); + put( note, `${GENERATED_HEADER}\n\n# Claude Code Adapter\n\nUse root \`CLAUDE.md\` as the lightweight entry point, then continue with \`AGENTS.md\`, \`core/rules/\`, and \`core/skills/\`.\n` ); return files; } else if (platform === "codex") { - const note = join(base, "README.md"); - files.set( + const note = join(noteBase, "README.md"); + put( note, `${GENERATED_HEADER}\n\n# Codex Adapter\n\nCodex CLI primarily relies on root \`AGENTS.md\` and \`core/skills/\`. This adapter does not duplicate the full rules.\n` ); @@ -107,25 +109,25 @@ export async function plannedFiles(root, platform, layout = "repo") { throw new Error(`unsupported platform: ${platform}`); } + const skillSourceRoot = join(runtimeBase(root, contentMode), "skills"); for (const stage of STAGES) { - const [, content] = await renderSkill(root, stage); - files.set(join(skillRoot, stage, "SKILL.md"), content); + const source = join(skillSourceRoot, stage, "SKILL.md"); + files.set(join(skillRoot, stage, "SKILL.md"), await renderDoc(root, source, contentMode)); } - const agentSourceRoot = join(root, "core", "agents"); + const agentSourceRoot = join(runtimeBase(root, contentMode), "agents"); for (const source of await listFiles(agentSourceRoot)) { if (!source.endsWith(".md")) continue; - const [, content] = await renderAgent(root, source); - files.set(join(agentRoot, basename(source)), content); + files.set(join(agentRoot, basename(source)), await renderDoc(root, source, contentMode)); } return files; } -export async function ensureCoreSkills(root) { +export async function ensureCoreSkills(root, contentMode = "core") { const missing = []; for (const stage of STAGES) { - const path = join(root, "core", "skills", stage, "SKILL.md"); + const path = join(runtimeBase(root, contentMode), "skills", stage, "SKILL.md"); if (!(await exists(path))) missing.push(path); } return missing; @@ -142,6 +144,10 @@ export async function presentPlatforms(root) { found.push(platform); continue; } + if (await exists(join(root, WORKFLOW_DIR, "adapters", platform))) { + found.push(platform); + continue; + } const dotDir = PLATFORM_DOT_DIRS[platform]; if (dotDir && (await exists(join(root, dotDir)))) found.push(platform); } @@ -150,14 +156,15 @@ export async function presentPlatforms(root) { export async function syncAdapters(root, options = {}) { const platforms = expandPlatforms(options.platform ?? "all"); - const missing = await ensureCoreSkills(root); + const contentMode = options.contentMode ?? (await detectContentMode(root)); + const missing = await ensureCoreSkills(root, contentMode); if (missing.length) return { code: 2, messages: missing.map((path) => `FAIL missing ${rel(root, path)}`) }; const messages = []; let ok = true; for (const platform of platforms) { const layout = options.layout ?? (await detectLayout(root, platform)); - for (const [path, content] of await plannedFiles(root, platform, layout)) { + for (const [path, content] of await plannedFiles(root, platform, layout, contentMode)) { if (await exists(path)) { const old = await readText(path); if (old === content) continue; @@ -176,14 +183,15 @@ export async function syncAdapters(root, options = {}) { export async function checkAdapters(root, options = {}) { const platforms = options.platform ? expandPlatforms(options.platform) : ((await presentPlatforms(root)).length ? await presentPlatforms(root) : PLATFORMS); - const missing = await ensureCoreSkills(root); + const contentMode = options.contentMode ?? (await detectContentMode(root)); + const missing = await ensureCoreSkills(root, contentMode); if (missing.length) return { code: 2, messages: missing.map((path) => `FAIL missing ${rel(root, path)}`) }; const messages = []; let ok = true; for (const platform of platforms) { const layout = options.layout ?? (await detectLayout(root, platform)); - for (const [path, content] of await plannedFiles(root, platform, layout)) { + for (const [path, content] of await plannedFiles(root, platform, layout, contentMode)) { if (!(await exists(path))) { messages.push(`FAIL missing generated file: ${rel(root, path)}`); ok = false; diff --git a/src/lib/layout.js b/src/lib/layout.js new file mode 100644 index 0000000..d90ef92 --- /dev/null +++ b/src/lib/layout.js @@ -0,0 +1,36 @@ +import { join } from "node:path"; +import { exists } from "./fs-utils.js"; + +// Installed-project layout: everything APW-internal lives under .ai-workflow/ +// so the target project root only shows the user's own files plus the AI +// entry points (AGENTS.md, CLAUDE.md) and the tool dot-directories that the +// tools themselves require at the root (.cursor/, .trae/, ...). +export const WORKFLOW_DIR = ".ai-workflow"; +export const RUNTIME_DIR = join(WORKFLOW_DIR, "runtime"); + +// "core" mode: canonical source lives in core/ (this repository and legacy +// installs). "runtime" mode: installed projects keep it under +// .ai-workflow/runtime/. +export async function detectContentMode(root) { + if (await exists(join(root, "core"))) return "core"; + if (await exists(join(root, RUNTIME_DIR))) return "runtime"; + return "core"; +} + +export function runtimeBase(root, contentMode) { + return contentMode === "runtime" ? join(root, RUNTIME_DIR) : join(root, "core"); +} + +export function versionPath(root, contentMode) { + return contentMode === "runtime" ? join(root, WORKFLOW_DIR, "VERSION") : join(root, "VERSION"); +} + +// Rewrite canonical core/ path references for installed projects. The +// replacement contains no "core/" substring, so the transform is idempotent. +export function toProjectPaths(text) { + return text.replace(/(^|[\s`(>])core\//g, "$1.ai-workflow/runtime/"); +} + +export function isTextDocPath(path) { + return path.endsWith(".md") || path.endsWith(".mdc"); +} diff --git a/src/lib/validate.js b/src/lib/validate.js index a2badb6..1767cba 100644 --- a/src/lib/validate.js +++ b/src/lib/validate.js @@ -5,6 +5,7 @@ import { exists, listFiles, readText } from "./fs-utils.js"; import { rel } from "./path-utils.js"; import { loadWorkflowConfig, stageIds } from "./workflow-config.js"; import { readState } from "./state.js"; +import { WORKFLOW_DIR, detectContentMode, runtimeBase, versionPath } from "./layout.js"; const fixedModelTerms = ["so" + "nnet", "o" + "pus", "g" + "pt-4", "g" + "pt-5", "claude" + "-3", "claude" + "-4"]; const fixedModelRe = new RegExp(`\\b(${fixedModelTerms.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "i"); @@ -75,43 +76,60 @@ function adapterSkillPaths(root, stage, platforms) { return paths; } -async function skillLookupPaths(root, stage) { +function customSkillDirs(root) { + return [join(root, WORKFLOW_DIR, "skills"), join(root, ".apw", "skills")]; +} + +async function skillLookupPaths(root, stage, contentMode) { const platforms = await presentPlatforms(root); return [ - rel(root, join(root, ".apw", "skills", stage, "SKILL.md")), + ...customSkillDirs(root).map((dir) => rel(root, join(dir, stage, "SKILL.md"))), ...adapterSkillPaths(root, stage, platforms).map((path) => rel(root, path)), - rel(root, join(root, "core", "skills", stage, "SKILL.md")) + rel(root, join(runtimeBase(root, contentMode), "skills", stage, "SKILL.md")) ]; } -async function findStageSkill(root, stage) { - const projectSkill = join(root, ".apw", "skills", stage, "SKILL.md"); - if (await exists(projectSkill)) return projectSkill; +async function findStageSkill(root, stage, contentMode) { + for (const dir of customSkillDirs(root)) { + const projectSkill = join(dir, stage, "SKILL.md"); + if (await exists(projectSkill)) return projectSkill; + } for (const path of adapterSkillPaths(root, stage, await presentPlatforms(root))) { if (await exists(path)) return path; } - const coreSkill = join(root, "core", "skills", stage, "SKILL.md"); + const coreSkill = join(runtimeBase(root, contentMode), "skills", stage, "SKILL.md"); if (await exists(coreSkill)) return coreSkill; return null; } -async function checkStructure(root, r, config) { - for (const path of ["AGENTS.md", "CLAUDE.md", "VERSION", "core/rules", "core/skills", "core/agents", "core/templates", "core/schemas"]) { +async function checkStructure(root, r, config, contentMode) { + const base = rel(root, runtimeBase(root, contentMode)) || "core"; + const required = [ + "AGENTS.md", + "CLAUDE.md", + rel(root, versionPath(root, contentMode)), + `${base}/rules`, + `${base}/skills`, + `${base}/agents`, + `${base}/templates`, + `${base}/schemas` + ]; + for (const path of required) { if (await exists(join(root, path))) r.pass(`exists ${path}`); else r.fail(`missing ${path}`); } for (const stage of stageIds(config)) { - const path = await findStageSkill(root, stage); + const path = await findStageSkill(root, stage, contentMode); if (path) r.pass(`skill exists ${stage}`); - else r.fail(`missing skill ${stage}; looked in ${(await skillLookupPaths(root, stage)).join(", ")}`); + else r.fail(`missing skill ${stage}; looked in ${(await skillLookupPaths(root, stage, contentMode)).join(", ")}`); } } -async function checkFrontmatter(root, r, config) { +async function checkFrontmatter(root, r, config, contentMode) { for (const stage of stageIds(config)) { - const path = await findStageSkill(root, stage); + const path = await findStageSkill(root, stage, contentMode); if (!path) continue; const fm = parseFrontmatter(await readText(path)); if (!Object.keys(fm).length) { @@ -126,14 +144,15 @@ async function checkFrontmatter(root, r, config) { } } -async function checkTemplatesSchemaAgents(root, r, config) { +async function checkTemplatesSchemaAgents(root, r, config, contentMode) { + const base = runtimeBase(root, contentMode); for (const name of TEMPLATES) { - const path = join(root, "core", "templates", `${name}.template.md`); + const path = join(base, "templates", `${name}.template.md`); if ((await exists(path)) && (await readText(path)).trim()) r.pass(`template ${name}`); else r.fail(`missing template ${name}`); } - const schema = join(root, "core", "schemas", "workflow-state.schema.json"); + const schema = join(base, "schemas", "workflow-state.schema.json"); try { JSON.parse(await readText(schema)); r.pass("state schema valid json"); @@ -142,7 +161,7 @@ async function checkTemplatesSchemaAgents(root, r, config) { } const valid = new Set([...STAGES, ...stageIds(config)]); - for (const path of await listFiles(join(root, "core", "agents"))) { + for (const path of await listFiles(join(base, "agents"))) { if (!path.endsWith(".md")) continue; const fm = parseFrontmatter(await readText(path)); const skills = Array.isArray(fm.default_skills) ? fm.default_skills : (fm.default_skills ? [fm.default_skills] : []); @@ -183,10 +202,10 @@ async function checkStateClosure(root, r, config) { } } -async function checkReferences(root, r) { +async function checkReferences(root, r, contentMode) { const pattern = /`([^`\n]+(?:\.md|\.json))`/g; const optional = new Set(["README.md", "package.json", "pyproject.toml", "pom.xml", "build.gradle", "go.mod", "Cargo.toml"]); - const starts = [join(root, "AGENTS.md"), join(root, "CLAUDE.md"), join(root, "core")]; + const starts = [join(root, "AGENTS.md"), join(root, "CLAUDE.md"), runtimeBase(root, contentMode)]; for (const start of starts) { const files = (await exists(start)) && (await listFiles(start)).length ? await listFiles(start) : ((await exists(start)) ? [start] : []); for (const path of files.filter((item) => item.endsWith(".md") || item.endsWith(".mdc"))) { @@ -226,12 +245,13 @@ async function checkResidualTerms(root, r) { export async function validateProject(root, options = {}) { const r = new Reporter(); + const contentMode = await detectContentMode(root); const loadedConfig = await checkWorkflowConfig(root, r); - await checkStructure(root, r, loadedConfig.config); - await checkFrontmatter(root, r, loadedConfig.config); - await checkTemplatesSchemaAgents(root, r, loadedConfig.config); + await checkStructure(root, r, loadedConfig.config, contentMode); + await checkFrontmatter(root, r, loadedConfig.config, contentMode); + await checkTemplatesSchemaAgents(root, r, loadedConfig.config, contentMode); await checkStateClosure(root, r, loadedConfig.config); - await checkReferences(root, r); + await checkReferences(root, r, contentMode); await checkResidualTerms(root, r); const adapterResult = await checkAdapters(root, { platform: options.platform }); r.messages.push(...adapterResult.messages); diff --git a/src/lib/workflow-config.js b/src/lib/workflow-config.js index 24c0eb6..e4053d2 100644 --- a/src/lib/workflow-config.js +++ b/src/lib/workflow-config.js @@ -3,7 +3,10 @@ import { DEFAULT_WORKFLOW_CONFIG, WORKFLOW_VERSION } from "./constants.js"; import { exists, readText } from "./fs-utils.js"; import { rel } from "./path-utils.js"; -export const WORKFLOW_CONFIG_PATH = join(".apw", "project.yaml"); +export const WORKFLOW_CONFIG_PATH = join(".ai-workflow", "project.yaml"); +// Older installs kept the config under .apw/; it is still honored when the +// primary path is absent. `apw migrate` moves it to the new location. +export const LEGACY_WORKFLOW_CONFIG_PATH = join(".apw", "project.yaml"); const TOP_LEVEL_KEYS = new Set(["protocolVersion", "stages"]); const STAGE_KEYS = new Set(["id", "title", "description"]); @@ -158,14 +161,19 @@ export function validateWorkflowConfig(raw, config) { } export async function loadWorkflowConfig(root) { - const path = join(root, WORKFLOW_CONFIG_PATH); + let path = join(root, WORKFLOW_CONFIG_PATH); if (!(await exists(path))) { - return { - source: "default", - path, - config: cloneDefaultConfig(), - errors: [] - }; + const legacy = join(root, LEGACY_WORKFLOW_CONFIG_PATH); + if (await exists(legacy)) { + path = legacy; + } else { + return { + source: "default", + path, + config: cloneDefaultConfig(), + errors: [] + }; + } } try { diff --git a/test-node/adapters-validate.test.js b/test-node/adapters-validate.test.js index f590352..3767392 100644 --- a/test-node/adapters-validate.test.js +++ b/test-node/adapters-validate.test.js @@ -19,7 +19,7 @@ test("sync detects generated adapter drift", async () => { assert.equal((await initCommand(root, { platform: "codex", force: false, dryRun: false })).code, 0); assert.equal((await syncAdapters(root, { platform: "codex" })).code, 0); - const note = join(root, "adapters", "codex", "README.md"); + const note = join(root, ".ai-workflow", "adapters", "codex", "README.md"); const original = await readFile(note, "utf8"); await writeFile(note, `${original}\nchanged\n`, "utf8"); const check = await checkAdapters(root, { platform: "codex" }); diff --git a/test-node/cli.test.js b/test-node/cli.test.js index 713c51a..ee701c9 100644 --- a/test-node/cli.test.js +++ b/test-node/cli.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { main } from "../src/cli.js"; @@ -31,10 +31,36 @@ test("init creates an installable workflow project", async () => { assert.match(mdc, /^---\n/, "frontmatter must start on the first line"); assert.match(await readFile(join(target, ".cursor", "skills", "prd", "SKILL.md"), "utf8"), /AUTO-GENERATED/); await assert.rejects(readFile(join(target, "adapters", "cursor", ".cursor", "rules", "ai-sdd.mdc"), "utf8"), { code: "ENOENT" }); + // Everything APW-internal is consolidated under .ai-workflow/; the project + // root stays clean and content references the runtime location. + assert.match(await readFile(join(target, ".ai-workflow", "VERSION"), "utf8"), /\d+\.\d+\.\d+/); + assert.match(await readFile(join(target, ".ai-workflow", "runtime", "skills", "prd", "SKILL.md"), "utf8"), /name: prd/); + await assert.rejects(readFile(join(target, "VERSION"), "utf8"), { code: "ENOENT" }); + await assert.rejects(readFile(join(target, "core", "skills", "prd", "SKILL.md"), "utf8"), { code: "ENOENT" }); + assert.match(await readFile(join(target, "AGENTS.md"), "utf8"), /\.ai-workflow\/runtime\/skills/); + assert.doesNotMatch(mdc, /`core\//); assert.equal(await main(["status", target]), 0); assert.equal(await main(["validate", target]), 0); }); +test("migrate upgrades a legacy core/ install to the .ai-workflow layout", async () => { + const root = await tempProject(); + const target = join(root, "legacy-proj"); + assert.equal(await main(["init", target, "--platform", "cursor"]), 0); + + // Reconstruct the pre-0.3 layout: core/ and VERSION at the project root. + await rename(join(target, ".ai-workflow", "runtime"), join(target, "core")); + await rename(join(target, ".ai-workflow", "VERSION"), join(target, "VERSION")); + + assert.equal(await main(["migrate", target]), 0); + await readFile(join(target, "core", "skills", "prd", "SKILL.md"), "utf8"); // dry run must not move + + assert.equal(await main(["migrate", target, "--apply"]), 0); + assert.match(await readFile(join(target, ".ai-workflow", "runtime", "skills", "prd", "SKILL.md"), "utf8"), /name: prd/); + await assert.rejects(readFile(join(target, "VERSION"), "utf8"), { code: "ENOENT" }); + assert.equal(await main(["validate", target]), 0); +}); + test("install preserves user files unless force is set", async () => { const target = await tempProject(); await writeFile(join(target, "AGENTS.md"), "custom\n", "utf8"); diff --git a/test-node/workflow-config.test.js b/test-node/workflow-config.test.js index 875f5e3..58bf2a0 100644 --- a/test-node/workflow-config.test.js +++ b/test-node/workflow-config.test.js @@ -114,9 +114,10 @@ test("validateProject uses stages from .apw/project.yaml", async () => { await writeProjectSkill(root, "beta"); assert.equal((await initCommand(root, { platform: "codex", force: false, dryRun: false })).code, 0); - for (const agent of await readdir(join(root, "core", "agents"))) { + const agentsDir = join(root, ".ai-workflow", "runtime", "agents"); + for (const agent of await readdir(agentsDir)) { await writeFile( - join(root, "core", "agents", agent), + join(agentsDir, agent), "---\nname: custom-agent\ndefault_skills:\n - alpha\n---\n\n# Custom Agent\n", "utf8" ); @@ -145,7 +146,7 @@ test(".apw project Skill takes priority over core Skill", async () => { await writeProjectSkill(root, "init"); assert.equal((await initCommand(root, { platform: "codex", force: false, dryRun: false })).code, 0); await writeFile( - join(root, "core", "skills", "init", "SKILL.md"), + join(root, ".ai-workflow", "runtime", "skills", "init", "SKILL.md"), "---\nname: wrong\nstage: wrong\nversion: 1.0.0\ndescription: wrong\n---\n", "utf8" );