From 1335dbc71b6ad42b4458fe0a584d295f88318489 Mon Sep 17 00:00:00 2001 From: magebyte-zero Date: Mon, 26 Oct 2026 10:26:16 +0800 Subject: [PATCH 1/3] fix: repair installer staging and WorkBuddy discovery; add CatPaw import preview --- .npmignore | 1 + CHANGELOG.md | 7 ++ INSTALL.md | 24 +++++- README.md | 11 ++- docs/README_en.md | 11 ++- docs/platform-matrix.md | 2 + scripts/install-cursor.mjs | 25 ++---- scripts/install-zcode.mjs | 25 ++---- scripts/lib/cmd-install-codebuddy.mjs | 24 ++---- scripts/lib/cmd-install-workbuddy.mjs | 55 ++++++++----- scripts/lib/cmd-package-catpaw.mjs | 39 ++++++++++ scripts/lib/install.mjs | 25 ++---- scripts/lib/release-source.mjs | 19 +++++ scripts/spec-superflow.mjs | 2 + templates/catpaw-skill.md | 26 +++++++ tests/lib/cmd-install-workbuddy.test.mjs | 43 ++++++++++ tests/lib/package-catpaw.test.mjs | 63 +++++++++++++++ tests/lib/release-install.test.mjs | 99 ++++++++++++++++++++++++ 18 files changed, 409 insertions(+), 92 deletions(-) create mode 100644 scripts/lib/cmd-package-catpaw.mjs create mode 100644 scripts/lib/release-source.mjs create mode 100644 templates/catpaw-skill.md create mode 100644 tests/lib/package-catpaw.test.mjs create mode 100644 tests/lib/release-install.test.mjs diff --git a/.npmignore b/.npmignore index 4f763e0e..2f143c1f 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,6 @@ /changes/ /.superpowers/ /docs/superpowers/ +/docs/reviews/ /contest-2026/ /scripts/sdd-workspace/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ee2e95..066e0084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,15 @@ The format loosely follows Keep a Changelog. - Separate legacy recovery instructions from default workflow skills; route existing changes by recorded workflow variant and ship the compatibility reference with the bundled runtime. +### Added + +- Add `ssf package-catpaw ` to generate one self-contained Skill folder for the current CatPaw (妙手) desktop import flow (#112). The folder carries the matching CLI and nine workflow skills; package relocation and local runtime behavior are verified, while client and cloud acceptance remain pending. + ### Fixed +- Use the operating system's temporary directory for Qoder and shared release installers, including Cursor and ZCODE, fixing Windows installation failures (#134). Reuse WorkBuddy's isolated clone destination and cleanup on failure across all five installer paths, preserving the fix for #89. +- Register WorkBuddy local installations in `installed_plugins.json` as well as `enabledPlugins`, so the desktop client discovers the installed suite. Preserve other installations and non-user scopes on upgrades, reject damaged registries before replacing the plugin, and carry the CLI version metadata. +- Include `package.json` with the bundled CLI in shared, Cursor and ZCODE installs so version identification and module metadata stay with the matching skills. - Record the change's start commit and branch when `workflow start` enters executing, and resolve the final review range from that anchor, so a repository whose trunk is not named `main`/`master` can record its final review and complete instead of failing with "Final review requires an unambiguous recorded target branch". An isolation context recorded later no longer moves the range origin. - Allow a change that entered executing before the start anchor existed to record it once with `ssf state set review_base `. Both anchor fields are write-once: a recorded anchor cannot be overwritten or cleared, so the reviewed range cannot be narrowed through `ssf state set`. - Accept a relative `` for every `ssf execution` subcommand. `ssf execution review` previously wrote the report snapshot to the directory the relative path pointed at, then validated it against the change directory's realpath, so the documented `ssf execution review changes/ ...` form always failed with an ENOENT on a doubled path. diff --git a/INSTALL.md b/INSTALL.md index 15743ba4..95c84b86 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,6 +38,7 @@ | Gemini CLI | `gemini extensions install` | `gemini extensions update` | `gemini extensions uninstall` | | OpenCode | plugin entry / skills 目录 | `git pull` | 删除 plugin/skills | | WorkBuddy | `ssf install-workbuddy` | 重新运行安装器 | 删除 marketplace 插件并禁用 | +| CatPaw(妙手,预览) | `ssf package-catpaw ` 后在客户端导入 | 重新生成并导入整个文件夹 | 在妙手技能管理中卸载 | | CodeBuddy Code CLI | `ssf install-codebuddy` | 重新运行安装器 | `ssf uninstall-codebuddy` | | Trae IDE / TRAE Work | `.trae/skills` / 上传 zip 或 .skill / marketplace | `git pull` + 重新导入 | UI 卸载或删除技能目录 | | Cline | `ssf install-cline` | 重新运行脚本 | 删除 `.cline/skills/`、`.clinerules/` | @@ -357,6 +358,8 @@ rm -rf your-project/.agents/skills WorkBuddy 把 Skill 作为 marketplace 插件管理。安装器把 spec-superflow 部署为单个插件,包含 9 个 skill、运行时依赖(scripts/docs/templates/dist/hooks)、phase-guard 规则和 `.codebuddy-plugin/plugin.json` 清单,写入 `~/.workbuddy/plugins/marketplaces//plugins/spec-superflow/`。 +安装器同时在 `~/.workbuddy/plugins/installed_plugins.json` 登记本地插件的路径与版本,并在 `settings.json` 启用。仅复制文件和写入 `enabledPlugins` 无法让当前桌面客户端识别插件。安装器不修改官方市场清单,也不表示已上架官方市场。 + 安装器还会分发三份 canonical Markdown command adapter:`/ssf:resume`、`/ssf:switch`、`/ssf:save`。它们仅在 CodeBuddy/WorkBuddy 的 command 机制中提供这些 slash 名称,并调用同一组 CLI guard;不表示所有平台都有完全相同的 slash 命令。 ### 安装(推荐:一键脚本) @@ -387,13 +390,14 @@ ssf install-workbuddy --dry-run ├── rules/phase-guard.md ← phase-guard 规则(WorkBuddy 自动加载) ├── scripts/ docs/ templates/ ← 运行时依赖 ├── dist/ hooks/ +├── package.json ← 同包 CLI 的版本元数据 ``` `~/.workbuddy/settings.json` 中启用键为 `spec-superflow@cb_teams_marketplace`(单个键,非每 skill 一个)。 ### 升级 -重新运行安装命令即可覆盖旧插件并保留已有 `enabledPlugins` 配置。 +重新运行安装命令即可覆盖旧插件,更新安装登记,并保留其他插件的配置与登记。登记文件损坏时会停止安装,保留原插件供排查。 ### 卸载 @@ -401,7 +405,7 @@ ssf install-workbuddy --dry-run rm -rf ~/.workbuddy/plugins/marketplaces/cb_teams_marketplace/plugins/spec-superflow ``` -然后从 `~/.workbuddy/settings.json` 的 `enabledPlugins` 中移除 `spec-superflow@cb_teams_marketplace`。 +然后从 `~/.workbuddy/settings.json` 的 `enabledPlugins` 和 `~/.workbuddy/plugins/installed_plugins.json` 的 `plugins` 中移除 `spec-superflow@cb_teams_marketplace` 的本地安装记录,保留其他插件记录。 ### 验证 @@ -415,6 +419,22 @@ cat ~/.workbuddy/plugins/marketplaces/cb_teams_marketplace/plugins/spec-superflo --- +## CatPaw(妙手,导入包预览) + +针对新版妙手工作台,使用[官方支持的本地技能文件夹导入](https://catpaw.meituan.com/docs#/desktop/skills-market),不写入未经确认的内部目录。此命令随下一版本发布;在当前仓库可先运行: + +```bash +node scripts/spec-superflow.mjs package-catpaw ./catpaw-spec-superflow +``` + +输出目录必须不存在。生成的目录包含根 `SKILL.md`、九个流程技能以及同版本 CLI 和所需资源。在妙手「技能」→ 导入本地技能中选择这个文件夹;应用仅需发现根技能 `spec-superflow`,其余流程按需读取包内文件。若选择 ZIP 导入,先压缩完整文件夹,不要只上传 `SKILL.md`。 + +使用时选择 `spec-superflow` 技能,或明确要求使用 spec-superflow。执行端需要 Node.js 20+ 和 Git。升级时用新版本 CLI 重新生成完整文件夹,再通过妙手导入;不要仅替换技能正文或单独升级全局 `ssf`。 + +当前已验证生成包移动后仍能运行 CLI、读取参考文档及启动 direct 流程;尚未进行妙手客户端导入和模型调用验收,也未验证云端模式。 + +--- + ## CodeBuddy Code CLI CodeBuddy Code CLI 直接从 `~/.codebuddy/skills/` 读取 skill,从 `~/.codebuddy/rules/*.md` 自动加载规则(支持 frontmatter 控制是否总是应用),SessionStart hook 从 `~/.codebuddy/settings.json` 的 `hooks` 字段加载(**不是** `~/.codebuddy/hooks/hooks.json`——CodeBuddy 用户级 `hooks.json` 不会被自动加载)。与 WorkBuddy 的 marketplace 插件模型不同,CodeBuddy CLI 把 skill 直接放进共享 `skills/` 目录,与其他 skill 共存;运行时依赖(scripts/docs/templates/dist/hooks)放在独立的 `~/.codebuddy/spec-superflow/` 目录下作为 `${CLAUDE_PLUGIN_ROOT}`。安装器还会把三个 recovery command adapter 重写为调用已部署的本地 runtime(而非固定版本的 `npx` 包),并把 `phase-guard.md` 设为 `alwaysApply: false` 以避免影响普通项目。 diff --git a/README.md b/README.md index c1885942..4db0d785 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,15 @@ copilot plugin install spec-superflow@spec-superflow gemini extensions install https://github.com/MageByte-Zero/spec-superflow ``` -项目支持 19 个 AI 编程平台。其他平台的安装器、目录和卸载方式见 [INSTALL.md](INSTALL.md),能力差异见 [平台矩阵](docs/platform-matrix.md)。 +### CatPaw(妙手,导入包预览) + +```bash +node scripts/spec-superflow.mjs package-catpaw ./catpaw-spec-superflow +``` + +在妙手「技能」中导入生成的文件夹。包内包含同版本 CLI 和九个流程技能,升级时一起重新导入。此命令随下一版本发布;目前已验证包结构与本地 CLI,尚未完成妙手客户端验收。 + +项目另支持 19 个 AI 编程平台。其他平台的安装器、目录和卸载方式见 [INSTALL.md](INSTALL.md),能力差异见 [平台矩阵](docs/platform-matrix.md)。 ## 命令 @@ -223,6 +231,7 @@ gemini extensions install https://github.com/MageByte-Zero/spec-superflow | `ssf sync ` | 原子同步 delta spec 到发布基线 | | `ssf doctor` | 检查安装、版本和资源一致性 | | `ssf finish ` | 验证并合并已记录的隔离分支 | +| `ssf package-catpaw ` | 生成妙手可导入的完整技能文件夹 | 运行 `ssf --help` 查看完整命令。`workflow recommend/select/accept`、旧 execution plan 和八状态路由只用于恢复 v1 变更。 diff --git a/docs/README_en.md b/docs/README_en.md index 46c3adf6..33396319 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -182,7 +182,15 @@ copilot plugin install spec-superflow@spec-superflow gemini extensions install https://github.com/MageByte-Zero/spec-superflow ``` -The project supports 19 AI coding platforms. See [INSTALL.md](../INSTALL.md) for every installer and uninstall path, and the [platform matrix](platform-matrix.md) for capability differences. +### CatPaw (妙手, import preview) + +```bash +node scripts/spec-superflow.mjs package-catpaw ./catpaw-spec-superflow +``` + +Import the generated folder from CatPaw's Skills page. It contains the matching CLI and all nine workflow skills; reimport the whole folder when upgrading. This command is planned for the next release. Package structure and local CLI behavior have been tested; CatPaw client acceptance is pending. + +The project also supports 19 AI coding platforms. See [INSTALL.md](../INSTALL.md) for every installer and uninstall path, and the [platform matrix](platform-matrix.md) for capability differences. ## Commands @@ -197,6 +205,7 @@ The project supports 19 AI coding platforms. See [INSTALL.md](../INSTALL.md) for | `ssf sync ` | Atomically publish delta specs to the baseline | | `ssf doctor` | Check installation, versions, and assets | | `ssf finish ` | Verify and merge recorded isolation | +| `ssf package-catpaw ` | Create a complete Skill folder for CatPaw import | Run `ssf --help` for the full command list. `workflow recommend/select/accept`, legacy execution plans, and the eight-state router remain only for v1 recovery. diff --git a/docs/platform-matrix.md b/docs/platform-matrix.md index 0be3cd92..b79c8742 100644 --- a/docs/platform-matrix.md +++ b/docs/platform-matrix.md @@ -34,6 +34,8 @@ spec-superflow 共支持 **19 个** AI 编程平台。每个平台按三层接 Codex manifest 通过 `"hooks": {}` 抑制 `hooks/hooks.json` 自动发现,因此 CLI / App 只加载 skills,不会自动注入 SessionStart 上下文;新会话需显式调用 `workflow-start`。 +另提供新版 CatPaw(妙手)的导入包预览:`ssf package-catpaw ` 生成包含同版本 CLI 和九个流程技能的单一 Skill 文件夹,通过客户端导入。已验证本地包与 CLI,妙手客户端和云端模式尚未验收,因此暂不计入上表的完整平台支持。 + ## 路径来源 所有 `skillsDir` / `rulesDir` / `rulesFormat` / `hookFormat` 均与 [comet](https://github.com/rpamis/comet) 的 `src/core/platforms.ts` 交叉核实,并对照各平台官方约定。spec-superflow 的守卫机制是 **phase-guard 规则文件**(平台自动加载),而非 comet 的 PreToolUse 钩子——这是两层架构的根本区别。 diff --git a/scripts/install-cursor.mjs b/scripts/install-cursor.mjs index dcb39513..520ddb07 100644 --- a/scripts/install-cursor.mjs +++ b/scripts/install-cursor.mjs @@ -8,12 +8,13 @@ // // Defaults to the latest GitHub release; use --local to deploy from a local repo. import { existsSync, mkdirSync, readdirSync, statSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; -import { cp, writeFile, mkdtemp } from 'node:fs/promises'; +import { cp, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { rewriteRuntime } from './lib/runtime-rewrite.mjs'; +import { cloneRelease } from './lib/release-source.mjs'; import { createPhaseGuardContent } from './lib/phase-guard-content.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,16 +43,6 @@ async function fetchLatestTag() { return data.tag_name; } -async function cloneRelease(tag) { - const tmpDir = await mkdtemp(join('/tmp', 'spec-superflow-')); - const url = `https://github.com/${GITHUB_REPO}.git`; - console.log(`📥 Cloning ${tag} into ${tmpDir} ...`); - execFileSync('git', ['clone', '--depth', '1', '--branch', tag, url, tmpDir], { - stdio: 'inherit', - }); - return tmpDir; -} - /** * Recursively copy a directory. */ @@ -156,7 +147,7 @@ async function main() { }); let pluginRoot = defaultPluginRoot; - let isTemp = false; + let stagingDir = null; let installedTag = null; if (values.local) { @@ -165,14 +156,13 @@ async function main() { } else { installedTag = values.tag || await fetchLatestTag(); console.log(`⬆️ Installing spec-superflow ${installedTag} ...`); - pluginRoot = await cloneRelease(installedTag); - isTemp = true; + ({ pluginRoot, stagingDir } = await cloneRelease(installedTag)); } const sourceSkills = join(pluginRoot, 'skills'); if (!existsSync(sourceSkills)) { console.error(`❌ skills/ directory not found at ${pluginRoot}`); - if (isTemp) rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) rmSync(stagingDir, { recursive: true, force: true }); process.exit(1); } @@ -195,6 +185,7 @@ async function main() { console.log(` ${dir}/ — skipped (not found)`); } } + await cp(join(pluginRoot, 'package.json'), join(targetPluginDir, 'package.json'), { force: true }); // 2. Copy skills to .cursor/skills/ with CLAUDE_PLUGIN_ROOT replaced const count = await copySkillsWithRoot(sourceSkills, targetSkills, pluginRootAbs); @@ -215,8 +206,8 @@ async function main() { } console.log(`\nNext: open Cursor Agent and try "/workflow-start".`); } finally { - if (isTemp) { - rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) { + rmSync(stagingDir, { recursive: true, force: true }); } } } diff --git a/scripts/install-zcode.mjs b/scripts/install-zcode.mjs index 810dd3f4..319b12e6 100644 --- a/scripts/install-zcode.mjs +++ b/scripts/install-zcode.mjs @@ -8,12 +8,13 @@ // // Defaults to the latest GitHub release; use --local to deploy from a local repo. import { existsSync, mkdirSync, readdirSync, statSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; -import { cp, writeFile, mkdtemp } from 'node:fs/promises'; +import { cp, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { rewriteRuntime } from './lib/runtime-rewrite.mjs'; +import { cloneRelease } from './lib/release-source.mjs'; import { createPhaseGuardContent } from './lib/phase-guard-content.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,16 +43,6 @@ async function fetchLatestTag() { return data.tag_name; } -async function cloneRelease(tag) { - const tmpDir = await mkdtemp(join('/tmp', 'spec-superflow-')); - const url = `https://github.com/${GITHUB_REPO}.git`; - console.log(`📥 Cloning ${tag} into ${tmpDir} ...`); - execFileSync('git', ['clone', '--depth', '1', '--branch', tag, url, tmpDir], { - stdio: 'inherit', - }); - return tmpDir; -} - /** * Recursively copy a directory. */ @@ -156,7 +147,7 @@ async function main() { }); let pluginRoot = defaultPluginRoot; - let isTemp = false; + let stagingDir = null; let installedTag = null; if (values.local) { @@ -165,14 +156,13 @@ async function main() { } else { installedTag = values.tag || await fetchLatestTag(); console.log(`⬆️ Installing spec-superflow ${installedTag} ...`); - pluginRoot = await cloneRelease(installedTag); - isTemp = true; + ({ pluginRoot, stagingDir } = await cloneRelease(installedTag)); } const sourceSkills = join(pluginRoot, 'skills'); if (!existsSync(sourceSkills)) { console.error(`❌ skills/ directory not found at ${pluginRoot}`); - if (isTemp) rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) rmSync(stagingDir, { recursive: true, force: true }); process.exit(1); } @@ -195,6 +185,7 @@ async function main() { console.log(` ${dir}/ — skipped (not found)`); } } + await cp(join(pluginRoot, 'package.json'), join(targetPluginDir, 'package.json'), { force: true }); // 2. Copy skills to .zcode/skills/ with CLAUDE_PLUGIN_ROOT replaced const count = await copySkillsWithRoot(sourceSkills, targetSkills, pluginRootAbs); @@ -215,8 +206,8 @@ async function main() { } console.log(`\nNext: open ZCODE Agent and try "/workflow-start".`); } finally { - if (isTemp) { - rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) { + rmSync(stagingDir, { recursive: true, force: true }); } } } diff --git a/scripts/lib/cmd-install-codebuddy.mjs b/scripts/lib/cmd-install-codebuddy.mjs index d08b021c..4ad0c26f 100644 --- a/scripts/lib/cmd-install-codebuddy.mjs +++ b/scripts/lib/cmd-install-codebuddy.mjs @@ -32,13 +32,14 @@ // recovery command adapters, and merges SessionStart into settings.json. import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { cp, writeFile, mkdtemp } from 'node:fs/promises'; -import { homedir, tmpdir } from 'node:os'; +import { cp, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { rewriteRuntime } from './runtime-rewrite.mjs'; +import { cloneRelease } from './release-source.mjs'; import { createPhaseGuardContent } from './phase-guard-content.mjs'; import { writeShims, applyPathEntry } from './path-shim.mjs'; @@ -337,16 +338,6 @@ async function fetchLatestTag() { return data.tag_name; } -async function cloneRelease(tag) { - const tmpDir = await mkdtemp(join(tmpdir(), 'spec-superflow-')); - const url = `https://github.com/${GITHUB_REPO}.git`; - console.log(`📥 Cloning ${tag} into ${tmpDir} ...`); - execFileSync('git', ['clone', '--depth', '1', '--branch', tag, url, tmpDir], { - stdio: 'inherit', - }); - return tmpDir; -} - function readVersion(pluginRoot) { try { const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf-8')); @@ -527,7 +518,7 @@ export async function run(args) { // Resolve source: --local | --tag | latest release. let pluginRoot = defaultPluginRoot; - let isTemp = false; + let stagingDir = null; let installedTag = null; if (values.local) { @@ -536,8 +527,7 @@ export async function run(args) { } else { installedTag = values.tag || await fetchLatestTag(); console.log(`⬆️ Installing spec-superflow ${installedTag} for CodeBuddy ...`); - pluginRoot = await cloneRelease(installedTag); - isTemp = true; + ({ pluginRoot, stagingDir } = await cloneRelease(installedTag)); } try { @@ -563,8 +553,8 @@ export async function run(args) { } console.log(`\nNext: restart CodeBuddy Code CLI and try "用 workflow-start 开始".`); } finally { - if (isTemp) { - rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) { + rmSync(stagingDir, { recursive: true, force: true }); } } } diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index d8d6a689..254bd885 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -16,14 +16,16 @@ // ${CLAUDE_PLUGIN_ROOT} rewrite, phase-guard rule) but targets the marketplace // plugin structure instead of a project-local `.platform/` directory. -import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { cp, writeFile, mkdtemp } from 'node:fs/promises'; -import { homedir, tmpdir } from 'node:os'; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { cp, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { rewriteRuntime } from './runtime-rewrite.mjs'; +import { cloneRelease } from './release-source.mjs'; import { createPhaseGuardContent } from './phase-guard-content.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -252,22 +254,6 @@ async function fetchLatestTag() { return data.tag_name; } -async function cloneRelease(tag, { clone = execFileSync, logger = console } = {}) { - const stagingDir = await mkdtemp(join(tmpdir(), 'spec-superflow-')); - const pluginRoot = join(stagingDir, 'repo'); - const url = `https://github.com/${GITHUB_REPO}.git`; - logger.log(`📥 Cloning ${tag} into ${pluginRoot} ...`); - try { - clone('git', ['clone', '--depth', '1', '--branch', tag, url, pluginRoot], { - stdio: 'inherit', - }); - return { pluginRoot, stagingDir }; - } catch (error) { - rmSync(stagingDir, { recursive: true, force: true }); - throw error; - } -} - function readVersion(pluginRoot) { try { const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf-8')); @@ -326,6 +312,17 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName, plan, logger = console } = {}) { const installPlan = plan || planInstall({ pluginRoot, homeDir, marketplaceName }); const { skillNames, commandNames, commandAssets, targetPluginDir, targetSkills, targetCommands, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = installPlan; + const settings = readJsonIfExists(settingsPath); + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + throw new Error(`Invalid WorkBuddy settings; preserve and repair ${settingsPath} before installing`); + } + const registryPath = join(installPlan.workbuddyRoot, 'plugins', 'installed_plugins.json'); + const registry = existsSync(registryPath) ? readJsonIfExists(registryPath) : { version: 2, plugins: {} }; + if (!registry?.plugins || typeof registry.plugins !== 'object' || Array.isArray(registry.plugins) + || (registry.plugins[enabledPluginKey] !== undefined && (!Array.isArray(registry.plugins[enabledPluginKey]) + || registry.plugins[enabledPluginKey].some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))))) { + throw new Error(`Invalid WorkBuddy plugin registry; preserve and repair ${registryPath} before installing`); + } // 0. Clean old plugin dir. if (existsSync(targetPluginDir)) { @@ -345,6 +342,8 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName, plan, lo logger.log(` ${dir}/ — skipped (not found)`); } } + const packageJson = join(installPlan.pluginRoot, 'package.json'); + if (existsSync(packageJson)) await cp(packageJson, join(targetPluginDir, 'package.json')); // 2. Copy canonical recovery commands as complete Markdown assets. const commandCount = await copyValidatedCommands(commandAssets, targetCommands, pluginRootAbs); @@ -364,9 +363,25 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName, plan, lo await writeFile(join(manifestDir, 'plugin.json'), JSON.stringify(pluginManifest(skillNames, version), null, 2) + '\n', 'utf-8'); logger.log(` manifest → ${join(manifestDir, 'plugin.json')}`); + // The desktop client discovers installed plugins through this registry. + const entries = registry.plugins[enabledPluginKey] ?? []; + const previous = entries.find(entry => entry.scope === 'user'); + const now = new Date().toISOString(); + registry.plugins[enabledPluginKey] = [...entries.filter(entry => entry.scope !== 'user'), { + ...previous, scope: 'user', installPath: pluginRootAbs, version, + installedAt: previous?.installedAt ?? now, lastUpdated: now, + }]; + const temporaryRegistry = `${registryPath}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryRegistry, JSON.stringify(registry, null, 2) + '\n'); + renameSync(temporaryRegistry, registryPath); + } finally { + rmSync(temporaryRegistry, { force: true }); + } + logger.log(` registry → ${enabledPluginKey} installed`); + // 6. Enable plugin in settings.json. ensureDir(installPlan.workbuddyRoot); - const settings = readJsonIfExists(settingsPath); settings.enabledPlugins = settings.enabledPlugins && typeof settings.enabledPlugins === 'object' ? settings.enabledPlugins : {}; diff --git a/scripts/lib/cmd-package-catpaw.mjs b/scripts/lib/cmd-package-catpaw.mjs new file mode 100644 index 00000000..fadbb206 --- /dev/null +++ b/scripts/lib/cmd-package-catpaw.mjs @@ -0,0 +1,39 @@ +import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { ASSETS } from './cmd-runtime.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +export function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { + const { positionals } = parseArgs({ args, allowPositionals: true }); + if (positionals.length !== 1) { + io.stderr.write('Usage: ssf package-catpaw \n'); + return { exitCode: 2 }; + } + const target = resolve(positionals[0]); + mkdirSync(dirname(target), { recursive: true }); + // Exclusive creation prevents overwriting an existing skill or user directory. + mkdirSync(target); + try { + for (const asset of ['scripts', 'skills', 'templates', 'dist', 'package.json']) { + cpSync(join(ROOT, asset), join(target, asset), { + recursive: true, + filter: source => source !== join(ROOT, 'scripts', 'sdd-workspace'), + }); + } + // Carry runtime references without exporting unrelated local documentation. + for (const asset of ASSETS) { + if (!asset.startsWith('docs/')) continue; + mkdirSync(dirname(join(target, asset)), { recursive: true }); + cpSync(join(ROOT, asset), join(target, asset)); + } + cpSync(join(ROOT, 'templates', 'catpaw-skill.md'), join(target, 'SKILL.md')); + } catch (error) { + rmSync(target, { recursive: true, force: true }); + throw error; + } + io.stdout.write(`CatPaw(妙手)技能包已生成:${target}\n在妙手「技能」中导入此文件夹;升级时重新生成并导入整个包。\n`); + return { exitCode: 0 }; +} diff --git a/scripts/lib/install.mjs b/scripts/lib/install.mjs index d726395e..91be18e0 100644 --- a/scripts/lib/install.mjs +++ b/scripts/lib/install.mjs @@ -15,13 +15,14 @@ // platform), not a PreToolUse hook; no hook configs are written here. import { existsSync, mkdirSync, readdirSync, statSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; -import { cp, writeFile, mkdtemp } from 'node:fs/promises'; +import { cp, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { getPlatform, rulesTargetDir, phaseGuardFileName } from './platforms.mjs'; import { rewriteRuntime } from './runtime-rewrite.mjs'; +import { cloneRelease } from './release-source.mjs'; import { createPhaseGuardContent } from './phase-guard-content.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -49,16 +50,6 @@ async function fetchLatestTag() { return data.tag_name; } -async function cloneRelease(tag) { - const tmpDir = await mkdtemp(join('/tmp', 'spec-superflow-')); - const url = `https://github.com/${GITHUB_REPO}.git`; - console.log(`📥 Cloning ${tag} into ${tmpDir} ...`); - execFileSync('git', ['clone', '--depth', '1', '--branch', tag, url, tmpDir], { - stdio: 'inherit', - }); - return tmpDir; -} - /** Recursively copy a directory; returns the number of top-level entries. */ async function copyDir(src, dst) { if (!existsSync(src)) return 0; @@ -151,7 +142,7 @@ export async function installPlatform(platformId, opts = {}) { const targetRoot = opts.cwd || process.cwd(); let pluginRoot = defaultPluginRoot; - let isTemp = false; + let stagingDir = null; let installedTag = null; if (local) { @@ -160,14 +151,13 @@ export async function installPlatform(platformId, opts = {}) { } else { installedTag = tag || await fetchLatestTag(); console.log(`⬆️ Installing spec-superflow ${installedTag} for ${platform.name} ...`); - pluginRoot = await cloneRelease(installedTag); - isTemp = true; + ({ pluginRoot, stagingDir } = await cloneRelease(installedTag)); } const sourceSkills = join(pluginRoot, 'skills'); if (!existsSync(sourceSkills)) { console.error(`❌ skills/ directory not found at ${pluginRoot}`); - if (isTemp) rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) rmSync(stagingDir, { recursive: true, force: true }); process.exit(1); } @@ -189,6 +179,7 @@ export async function installPlatform(platformId, opts = {}) { console.log(` ${dir}/ — skipped (not found)`); } } + await cp(join(pluginRoot, 'package.json'), join(targetPluginDir, 'package.json'), { force: true }); // 2. Copy skills to /skills/ with portable commands rewritten const count = await copySkillsWithRoot(sourceSkills, targetSkills, pluginRootAbs); @@ -220,8 +211,8 @@ export async function installPlatform(platformId, opts = {}) { console.log(` Note: ${platform.notes}`); } } finally { - if (isTemp) { - rmSync(pluginRoot, { recursive: true, force: true }); + if (stagingDir) { + rmSync(stagingDir, { recursive: true, force: true }); } } } diff --git a/scripts/lib/release-source.mjs b/scripts/lib/release-source.mjs new file mode 100644 index 00000000..e9434dc1 --- /dev/null +++ b/scripts/lib/release-source.mjs @@ -0,0 +1,19 @@ +import { mkdtemp } from 'node:fs/promises'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +export async function cloneRelease(tag, { clone = execFileSync, logger = console } = {}) { + const stagingDir = await mkdtemp(join(tmpdir(), 'spec-superflow-')); + const pluginRoot = join(stagingDir, 'repo'); + logger.log(`📥 Cloning ${tag} into ${pluginRoot} ...`); + try { + clone('git', ['clone', '--depth', '1', '--branch', tag, + 'https://github.com/MageByte-Zero/spec-superflow.git', pluginRoot], { stdio: 'inherit' }); + return { pluginRoot, stagingDir }; + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index a316cb1b..a3e59de5 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -41,6 +41,7 @@ const COMMANDS = { 'install-qoder': () => import('./lib/cmd-install-qoder.mjs'), 'install-zcode': () => import('./lib/cmd-install-zcode.mjs'), 'install-codebuddy': () => import('./lib/cmd-install-codebuddy.mjs'), + 'package-catpaw': () => import('./lib/cmd-package-catpaw.mjs'), 'uninstall-codebuddy': () => import('./lib/cmd-uninstall-codebuddy.mjs'), }; @@ -134,6 +135,7 @@ Commands: install-qoder Deploy to .qoder/ + .qoder/rules/ (Qoder) install-codebuddy Deploy to ~/.codebuddy/skills/ + settings.json (CodeBuddy Code CLI) uninstall-codebuddy Remove spec-superflow from ~/.codebuddy/ (CodeBuddy Code CLI) + package-catpaw Create a self-contained Skill folder for CatPaw (妙手) import Options: --help, -h Show this help message diff --git a/templates/catpaw-skill.md b/templates/catpaw-skill.md new file mode 100644 index 00000000..ad36f081 --- /dev/null +++ b/templates/catpaw-skill.md @@ -0,0 +1,26 @@ +--- +name: spec-superflow +description: 在用户明确要求使用 spec-superflow,或继续已有 spec-superflow 变更时,执行规划、实现、审查和验证流程。 +--- + +# Spec Superflow + +本技能包包含同版本 CLI 与九个流程技能,需要 Node.js 20+ 和 Git。 +将本文件所在的绝对目录作为 ``。在项目工作目录运行命令, +使用 `node "/scripts/spec-superflow.mjs"` 替换文档中的 `SSF`。 +不要调用 PATH 中其他版本的 `ssf`。 + +先读取 [workflow-start](skills/workflow-start/SKILL.md),按其指引开始或恢复变更。 +后续提到的技能名对应本包的 `skills/<技能名>/SKILL.md`,按当前操作读取: + +- 范围不明确:`need-explorer` +- 编写计划:`spec-writer` +- 执行实现:`build-executor` +- 排查失败:`bug-investigator` +- 审查实现:`code-reviewer` +- 合并已有增量规格:`spec-merger` +- 验证与收尾:`release-archivist` +- 仅恢复已有旧版契约:`contract-builder` + +包内技能提到其他技能时,读取上述本地文件;无需在妙手中分别安装九个技能。 +保留包内的相对目录结构,升级时一起替换整个技能包。 diff --git a/tests/lib/cmd-install-workbuddy.test.mjs b/tests/lib/cmd-install-workbuddy.test.mjs index 0bed0de6..7ed185eb 100644 --- a/tests/lib/cmd-install-workbuddy.test.mjs +++ b/tests/lib/cmd-install-workbuddy.test.mjs @@ -281,6 +281,49 @@ describe('cmd-install-workbuddy', () => { assert.equal(existsSync(staleCommand), false); }); + it('registers the local plugin for client discovery and preserves unrelated installations on upgrade', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'registered-home'); + const registryPath = join(homeDir, '.workbuddy', 'plugins', 'installed_plugins.json'); + mkdirSync(dirname(registryPath), { recursive: true }); + const unrelated = [{ scope: 'user', installPath: '/other/plugin', version: '1.0.0' }]; + const project = { scope: 'project', installPath: '/project/plugin', version: '0.8.13' }; + writeFileSync(registryPath, JSON.stringify({ version: 2, + plugins: { 'other@market': unrelated, 'spec-superflow@test': [project] } })); + + const first = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }); + const initial = JSON.parse(readFileSync(registryPath, 'utf8')).plugins[first.enabledPluginKey].find(entry => entry.scope === 'user'); + assert.equal(initial.scope, 'user'); + assert.equal(initial.installPath, first.targetPluginDir); + assert.equal(initial.version, '0.8.14'); + assert.ok(initial.installedAt); + assert.equal(JSON.parse(readFileSync(join(initial.installPath, 'package.json'), 'utf8')).version, initial.version); + + writeFileSync(join(pluginRoot, 'package.json'), JSON.stringify({ version: '0.8.15' })); + await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }); + const saved = JSON.parse(readFileSync(registryPath, 'utf8')); + assert.deepEqual(saved.plugins['other@market'], unrelated); + assert.deepEqual(saved.plugins[first.enabledPluginKey][0], project); + assert.equal(saved.plugins[first.enabledPluginKey].length, 2); + assert.equal(saved.plugins[first.enabledPluginKey][1].version, '0.8.15'); + assert.equal(saved.plugins[first.enabledPluginKey][1].installedAt, initial.installedAt); + }); + + it('preserves the installed plugin when its client registry is corrupt', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'corrupt-home'); + const first = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }); + const registryPath = join(homeDir, '.workbuddy', 'plugins', 'installed_plugins.json'); + const sentinel = join(first.targetPluginDir, 'keep.txt'); + writeFileSync(sentinel, 'keep'); + for (const corrupt of ['{broken', '{"version":2,"plugins":[]}', '{"version":2,"plugins":{"spec-superflow@test":[null]}}']) { + writeFileSync(registryPath, corrupt); + await assert.rejects(installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' })); + assert.equal(readFileSync(sentinel, 'utf8'), 'keep'); + assert.equal(readFileSync(registryPath, 'utf8'), corrupt); + } + }); + it('installs all canonical package assets into a real temporary WorkBuddy home', async () => { const pluginRoot = process.cwd(); const homeDir = join(tempDir, 'real-home'); diff --git a/tests/lib/package-catpaw.test.mjs b/tests/lib/package-catpaw.test.mjs new file mode 100644 index 00000000..bcd5a0ba --- /dev/null +++ b/tests/lib/package-catpaw.test.mjs @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const CLI = join(ROOT, 'scripts', 'spec-superflow.mjs'); +const run = (args, cwd) => spawnSync(process.execPath, args, { cwd, encoding: 'utf8' }); + +test('CatPaw import folder retains the matching CLI and all nine skills after relocation', t => { + const temp = mkdtempSync(join(tmpdir(), 'ssf-catpaw-')); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const output = join(temp, 'spec-superflow'); + const packaged = run([CLI, 'package-catpaw', output], temp); + assert.equal(packaged.status, 0, packaged.stderr); + assert.match(readFileSync(join(output, 'SKILL.md'), 'utf8'), /^---\nname: spec-superflow\n/); + const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; + assert.equal(JSON.parse(readFileSync(join(output, 'package.json'), 'utf8')).version, version); + assert.equal(readdirSync(join(output, 'skills')).length, 9); + assert.equal(existsSync(join(output, 'scripts', 'sdd-workspace')), false); + assert.equal(existsSync(join(output, 'docs', 'reviews')), false); + for (const skill of readdirSync(join(output, 'skills'))) { + assert.equal(readFileSync(join(output, 'skills', skill, 'SKILL.md'), 'utf8'), + readFileSync(join(ROOT, 'skills', skill, 'SKILL.md'), 'utf8')); + } + + const imported = join(temp, 'imported skill'); + renameSync(output, imported); + const runtime = join(imported, 'scripts', 'spec-superflow.mjs'); + assert.equal(run([runtime, '--version'], tmpdir()).stdout.trim(), version); + const asset = run([runtime, 'runtime', 'asset', 'read', 'docs/legacy-workflow.md'], tmpdir()); + assert.equal(asset.status, 0, asset.stderr); + assert.equal(asset.stdout, readFileSync(join(ROOT, 'docs', 'legacy-workflow.md'), 'utf8')); + const change = join(temp, 'changes', 'demo'); + mkdirSync(change, { recursive: true }); + execFileSync('git', ['init', '--quiet', temp]); + execFileSync('git', ['-C', temp, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + 'commit', '--quiet', '--allow-empty', '-m', 'fixture']); + const started = run([runtime, 'workflow', 'start', change, '--path', 'direct', '--scope', 'Verify the imported runtime', '--json'], temp); + assert.equal(started.status, 0, started.stderr); + assert.equal(existsSync(join(change, '.spec-superflow.yaml')), true); +}); + +test('CatPaw packaging refuses to overwrite an existing directory', t => { + const temp = mkdtempSync(join(tmpdir(), 'ssf-catpaw-existing-')); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const output = join(temp, 'spec-superflow'); + mkdirSync(output); + writeFileSync(join(output, 'keep.txt'), 'keep'); + const result = run([CLI, 'package-catpaw', output], temp); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exist|已存在/i); + assert.deepEqual(readdirSync(output), ['keep.txt']); +}); + +test('CatPaw packaging requires an explicit output directory', () => { + const result = run([CLI, 'package-catpaw'], tmpdir()); + assert.equal(result.status, 2); + assert.match(result.stderr, /Usage: ssf package-catpaw/); +}); diff --git a/tests/lib/release-install.test.mjs b/tests/lib/release-install.test.mjs new file mode 100644 index 00000000..c3bb6476 --- /dev/null +++ b/tests/lib/release-install.test.mjs @@ -0,0 +1,99 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +let scratch; +let source; +let preload; + +before(() => { + scratch = mkdtempSync(join(tmpdir(), 'ssf-release-install-')); + source = join(scratch, 'source'); + mkdirSync(source); + for (const asset of ['scripts', 'skills', 'commands', 'docs', 'templates', 'dist', 'hooks', 'package.json']) { + cpSync(join(ROOT, asset), join(source, asset), { recursive: true }); + } + const git = args => execFileSync('git', ['-C', source, ...args], { stdio: 'pipe' }); + git(['init', '--quiet']); + git(['add', '--all']); + git(['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '--quiet', '-m', 'release fixture']); + git(['tag', 'v-test']); + + // Replace only the network boundary; exercise the real installer and Git clone. + preload = join(scratch, 'offline-clone.mjs'); + writeFileSync(preload, ` +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import cp from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { dirname, resolve } from 'node:path'; +const mkdtemp = fsp.mkdtemp; +fsp.mkdtemp = (prefix, ...args) => { + assert.equal(resolve(dirname(prefix)), resolve(process.env.SSF_TEST_TEMP), 'installer must use the OS temp directory'); + return mkdtemp(prefix, ...args); +}; +const exec = cp.execFileSync; +cp.execFileSync = (command, args, options) => { + if (command !== 'git' || args[0] !== 'clone') return exec(command, args, options); + const target = args.at(-1); + assert.equal(fs.existsSync(target), false, 'clone destination must not exist'); + if (process.env.SSF_TEST_CLONE_FAIL === '1') throw new Error('synthetic clone failure'); + const localArgs = [...args]; + localArgs[localArgs.length - 2] = process.env.SSF_TEST_SOURCE; + return exec(command, localArgs, { ...options, stdio: 'pipe' }); +}; +syncBuiltinESMExports(); +`); +}); + +after(() => rmSync(scratch, { recursive: true, force: true })); + +for (const platform of ['qoder', 'cursor', 'zcode', 'workbuddy', 'codebuddy']) { + for (const fail of [false, true]) { + test(`${platform} release install ${fail ? 'cleans up a failed clone' : 'uses OS temp staging and the bundled runtime'}`, () => { + const target = mkdtempSync(join(scratch, `${platform}-`)); + const temp = join(target, 'system temp'); + mkdirSync(temp); + const args = [join(ROOT, 'scripts', 'spec-superflow.mjs'), `install-${platform}`, '--tag', 'v-test']; + if (platform === 'workbuddy') args.push('--home', target); + if (platform === 'codebuddy') args.push('--config-dir', join(target, '.codebuddy'), '--no-path'); + const result = spawnSync(process.execPath, args, { + cwd: target, + encoding: 'utf8', + timeout: 30000, + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${pathToFileURL(preload).href}`, + TMPDIR: temp, TEMP: temp, TMP: temp, + SSF_TEST_TEMP: temp, SSF_TEST_SOURCE: pathToFileURL(source).href, + SSF_TEST_CLONE_FAIL: fail ? '1' : '0', + }, + }); + if (fail) { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /synthetic clone failure/); + } else { + assert.equal(result.status, 0, result.stdout + result.stderr); + const plugin = platform === 'workbuddy' + ? join(target, '.workbuddy', 'plugins', 'marketplaces', 'cb_teams_marketplace', 'plugins', 'spec-superflow') + : join(target, `.${platform}`, 'spec-superflow'); + const skills = platform === 'workbuddy' ? join(plugin, 'skills') : join(target, `.${platform}`, 'skills'); + const skill = readFileSync(join(skills, 'workflow-start', 'SKILL.md'), 'utf8'); + assert.ok(skill.includes(plugin), 'skill must invoke the runtime deployed with it'); + const runtime = join(plugin, 'scripts', 'spec-superflow.mjs'); + assert.ok(existsSync(runtime)); + const version = execFileSync(process.execPath, [runtime, '--version'], { encoding: 'utf8' }); + assert.equal(version.trim(), JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')).version); + const asset = execFileSync(process.execPath, [runtime, 'runtime', 'asset', 'read', 'docs/legacy-workflow.md'], { encoding: 'utf8' }); + assert.equal(asset.trimEnd(), readFileSync(join(source, 'docs', 'legacy-workflow.md'), 'utf8').trimEnd()); + } + assert.deepEqual(readdirSync(temp), [], 'release staging must be removed after success or failure'); + }); + } +} From 508fe3aaf0fb1dc7f9cf47b28d1c58acd7257e40 Mon Sep 17 00:00:00 2001 From: magebyte-zero Date: Mon, 26 Oct 2026 10:29:04 +0800 Subject: [PATCH 2/3] docs: plan v2.1.0 release gates and pending acceptance --- docs/release-checklist.md | 6 +-- docs/releases/v2.1.0-plan.md | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v2.1.0-plan.md diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 87edd719..92c8d631 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -11,14 +11,14 @@ Use this checklist before publishing a new version of `spec-superflow`. - `LICENSE` is present - `ssf version ` covers all manifests (JSON) + documentation (Markdown/shell) - `node scripts/check-version-consistency.mjs` passes (also runs in CI) -- Verify all nine runtime-dependent canonical skills use the exact release version, and local installer output rewrites them to its bundled `scripts/spec-superflow.mjs`. +- Verify all nine runtime-dependent canonical skills invoke the CLI bundled with the same release, and local installer output binds them to its bundled `scripts/spec-superflow.mjs`; do not add independent npm version pins to source skills. - Verify `.github/plugin/marketplace.json` and `.claude-plugin/marketplace.json` versions match ## Workflow Integrity - skill descriptions still match their actual responsibilities - `workflow-start` still acts as the primary entry point -- `contract-builder` still requires explicit approval before execution +- `contract-builder` remains limited to legacy changes and retains their recorded approval obligations; new direct/planned changes do not create a handwritten execution contract. - planning artifacts and execution contract roles remain distinct - self-contained ownership is preserved @@ -32,7 +32,7 @@ Use this checklist before publishing a new version of `spec-superflow`. ## Example Quality -For each example in `docs/examples/`: +Validate examples according to their recorded workflow variant. Existing legacy fixtures in `docs/examples/` retain the following artifacts; new planned examples require proposal.md + tasks.md, with specs/design only when needed: - `README.md` explains the scenario - `proposal.md` defines intent and scope diff --git a/docs/releases/v2.1.0-plan.md b/docs/releases/v2.1.0-plan.md new file mode 100644 index 00000000..6b7b9f29 --- /dev/null +++ b/docs/releases/v2.1.0-plan.md @@ -0,0 +1,85 @@ +# v2.1.0 发布计划 + +更新:2026-10-26。状态:准备阶段,尚未授权发布。 + +当前正式版本为 v2.0.1,目标版本为 v2.1.0。本轮先提交实现和发布计划,不改版本号、不合并 PR、不推标签、不发布 npm/GitHub Release,也不关闭 issue。 + +## 范围 + +| 项目 | v2.1.0 内容 | 当前证据与边界 | +| --- | --- | --- | +| [#134](https://github.com/MageByte-Zero/spec-superflow/issues/134) | Qoder 和共用安装入口使用系统临时目录、独立克隆目标及失败清理;Cursor、ZCODE、CodeBuddy、WorkBuddy 复用同一实现 | 离线真实 Git 克隆与清理回归通过;Windows 以候选源 CI 结果为准 | +| [#89](https://github.com/MageByte-Zero/spec-superflow/issues/89) | 保留 #129 的 WorkBuddy 克隆修复,补齐桌面端安装登记及版本元数据 | macOS WorkBuddy 5.7.7 已识别套件和九个技能;实际模型任务未验收 | +| CLI 与 Skill 一致性 | 安装器复制同包 CLI、Skills 和 package.json,Skill 绑定该安装目录 | 五类安装入口的版本查询与资源读取回归通过;不依赖另一份全局 ssf | +| [#112](https://github.com/MageByte-Zero/spec-superflow/issues/112) | 新版 CatPaw(妙手)提供 package-catpaw 导入包预览 | 包结构、移动后的 CLI/参考文档/direct 启动通过;客户端导入与云端模式未验收,不计入完整平台支持 | +| [#137](https://github.com/MageByte-Zero/spec-superflow/issues/137) | 交付 main 中已合并的 execution CLI 相对目录修复(#132) | v2.0.1 标签仍可复现;main CLI 已通过相对/绝对目录验证 | +| 已合并变更 | 包含 #132/#133 的终评锚点修复和 #138 的旧流程恢复指引拆分 | 以 v2.0.1 至最终发布源的实际差异确认,不把已回退的版本准备提交重复列为功能 | + +实现 PR:[spec-superflow #139](https://github.com/MageByte-Zero/spec-superflow/pull/139)。实现提交为 `1335dbc71b6ad42b4458fe0a584d295f88318489`;PR 后续文档提交及最终合并提交的检查结果需分别核对。 + +## 已完成的本地验证 + +- [x] npm run build。 +- [x] npm test:950 项通过,0 失败。该证据对应实现代码,不能替代后续 2.1.0 候选源的验证。 +- [x] npm run validate、npm run check-versions、git diff --check。 +- [x] version 2.1.0 --dry-run:可更新 21 处版本字段/文本匹配;没有实际写入。 +- [x] npm pack --dry-run:新 CLI 模块和妙手入口包含在包中,本地 docs/reviews/ 排除在外。 +- [x] WorkBuddy 本机安装后,安装登记、插件清单、CLI 元数据版本一致,客户端可见九个技能。 +- [x] 妙手导入文件夹已生成并完成本地运行验证;生成文件夹本身不代表客户端验收通过。 + +## 发布顺序与门槛 + +### 1. 合并实现 + +- [ ] 审查并合并 #139;确认最终 PR head 的 Ubuntu/Windows × Node 20/22、安装冒烟和 Plugin Scanner 全部通过。 +- [ ] 从 Windows CI 确认 release-install.test.mjs 实际执行,覆盖自定义系统临时目录、独立克隆目标、同包版本读取和失败清理。失败必须回到实现修复。 +- [ ] 确认 v2.1.0 将妙手标记为导入包预览;保持客户端、云端模式和模型调用的未验收说明。 + +### 2. 准备 2.1.0 候选源 + +- [ ] 从最新 main 创建发布准备分支,使用 `node scripts/spec-superflow.mjs version 2.1.0` 同步版本。 +- [ ] 将 CHANGELOG 的 Unreleased 整理为 2.1.0,写入实际发布日期;保留新的空 Unreleased 区域。 +- [ ] 核对 README、英文 README、INSTALL、插件清单、package-lock、hooks 和版本提示。九个源 Skill 继续使用同包 CLI,不增加独立 npm 版本 pin。 +- [ ] 只暂存本次版本准备文件。package.json 的 npm version 钩子含 git add -A,使用前必须处理其会纳入无关本地文件的风险。 +- [ ] 对候选源运行 build、完整 test、validate、check-versions、doctor、test:raw-mode 和 npm pack --dry-run;完成所有示例的相应校验。 +- [ ] 用打包候选物在空目录运行 CLI;用本地候选源重装 WorkBuddy/Qoder 等代表入口,确认 Skills、CLI 和版本元数据来自同一个候选源。重新生成匹配 2.1.0 的妙手预览包。 +- [ ] 提交并审查发布准备 PR;其合并不等于已发布。 + +### 3. 确认市场交付 + +- [ ] 对最终合并的发布源记录 Plugin Scanner 证据:源 SHA、2.1.0、数值分数/评级、policy_pass、verify_pass、各级发现数和运行链接。缺失数值分数时不得请求外部市场审查。 +- [ ] 按 [发布清单](../release-checklist.md) 准备 awesome-codex-plugins 的同步 PR,保持差异仅限目标条目;外部提交按维护者授权执行。 +- [ ] 等待同步 PR 合并、生成器完成,并确认远端生成的 Codex manifest.version 等于 2.1.0。 +- [ ] 在干净 Codex 配置中完成 marketplace add、plugin add、plugin list,确认取得 2.1.0 的同包技能和运行时。 +- [ ] 将最终市场验证结果写入发布记录。WorkBuddy 官方市场上架和妙手技能广场上传不包含在本次本地安装支持范围内。 + +发布 CI 在 GitHub Release/npm 之前阻断远端市场版本不一致的情况,不能先推标签再补做市场同步: + +```bash +node scripts/verify-marketplace-release.mjs \ + --manifest-url https://raw.githubusercontent.com/hashgraph-online/awesome-codex-plugins/main/plugins/MageByte-Zero/spec-superflow/.codex-plugin/plugin.json \ + --expected-version 2.1.0 +``` + +### 4. 授权发布与发布后验收 + +- [ ] 维护者明确授权正式发布,确认最终源 SHA、版本和预览边界。 +- [ ] 在该源提交创建并推送 v2.1.0 标签;由 release workflow 创建 GitHub Release 并发布 npm。 +- [ ] 分别确认 GitHub Release、npm 版本、远端市场版本及干净安装结果;其中任一项成功都不代表整体发布完成。 +- [ ] 保存最终源 SHA、CI/Scanner 链接、市场同步 PR、发布链接和安装验证记录。 +- [ ] 仅在维护者授权后更新或关闭相关 issue;不通过本实现 PR 的自动关闭关键词提前结束问题跟踪。 + +## 已知边界与发布决策 + +- recordReview() 底层函数直接接收相对 changeDir 时仍可触发 #137 的路径问题;当前 CLI 已在入口规范化路径。内部调用继续使用绝对目录;若本版扩大到底层相对路径支持,需单独实现和回归,不能把 CLI 验证当作底层函数也已修复。 +- finish 当前默认 npm test。非 Node 项目应显式传入 --test-cmd;已验证该覆盖入口。默认命令自动继承属于另一项行为变更,本计划不隐含纳入。 +- WorkBuddy 安装器会修改本机插件目录、settings.json 和 installed_plugins.json。已有非 user 作用域和其他插件记录必须保留;登记损坏时停止替换。兼容性已在 5.7.7 验证,不声称覆盖所有客户端版本。 +- 妙手只按用户确认的本地导入包范围交付。客户端导入、云端模式、Node/Git 可用性及真实模型任务的后续验收不得记为已通过。 + +## 中止与恢复 + +任一必须检查失败时停止发布,记录失败并修复;不得把失败改写成通过。发布准备前可以修正分支并重跑受影响验证;版本或实现改变后需重新绑定候选源证据。 + +标签尚未推送时保留 PR 和候选物供调整。npm 发布后不覆盖同一版本、不擅自删除标签或 unpublish;若存在发布缺陷,先记录各渠道实际状态,再由维护者决定补丁版本或市场回退。 + +下一步:等待并检查 #139 的最终 CI;在维护者决定合并后,进入 2.1.0 版本同步与候选源验证。 From 8f3f20334e7d9148d76461ad3bb92fbc1e421b41 Mon Sep 17 00:00:00 2001 From: magebyte-zero Date: Mon, 26 Oct 2026 10:36:50 +0800 Subject: [PATCH 3/3] test: accept CRLF in CatPaw package frontmatter --- tests/lib/package-catpaw.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/package-catpaw.test.mjs b/tests/lib/package-catpaw.test.mjs index bcd5a0ba..0c744656 100644 --- a/tests/lib/package-catpaw.test.mjs +++ b/tests/lib/package-catpaw.test.mjs @@ -16,7 +16,7 @@ test('CatPaw import folder retains the matching CLI and all nine skills after re const output = join(temp, 'spec-superflow'); const packaged = run([CLI, 'package-catpaw', output], temp); assert.equal(packaged.status, 0, packaged.stderr); - assert.match(readFileSync(join(output, 'SKILL.md'), 'utf8'), /^---\nname: spec-superflow\n/); + assert.match(readFileSync(join(output, 'SKILL.md'), 'utf8'), /^---\r?\nname: spec-superflow\r?\n/); const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; assert.equal(JSON.parse(readFileSync(join(output, 'package.json'), 'utf8')).version, version); assert.equal(readdirSync(join(output, 'skills')).length, 9);