@jspg-ai/coding-bb 0.0.3-beta.1 → 0.0.3-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/cbb/dev-standards/rules/cbb-ai-behavior.md +118 -116
  2. package/cbb/lib/install/claude-code.js +1 -3
  3. package/cbb/lib/install/codebuddy.js +30 -0
  4. package/cbb/lib/install/init.js +295 -119
  5. package/cbb/lib/install/opencode.js +35 -31
  6. package/cbb/lib/install/qoder.js +1 -3
  7. package/cbb/lib/install/rule-rewrite.js +27 -0
  8. package/cbb/lib/install/trae.js +33 -0
  9. package/cbb/lib/openspec/index.js +337 -554
  10. package/cbb/lib/superpowers/index.js +246 -265
  11. package/cbb/lib/utils/check-update.js +8 -1
  12. package/cbb/lib/utils/gitignore.js +2 -0
  13. package/cbb/lib/utils/settings.js +17 -4
  14. package/cbb/lib/utils/tar.js +92 -0
  15. package/cbb/lib/utils/upstream.js +90 -0
  16. package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +32 -23
  17. package/cbb/worktrees/skills/cbb-worktree-close/scripts/remove-worktrees.js +2 -2
  18. package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +36 -28
  19. package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-worktrees.js +15 -37
  20. package/cbb/worktrees/skills/cbb-worktree-init/scripts/update-gitignore.js +25 -9
  21. package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +108 -31
  22. package/cbb/worktrees/skills/cbb-worktree-push/scripts/make-mr-urls.js +211 -0
  23. package/config/openspec/schemas/spec-driven/schema.yaml +38 -30
  24. package/config/openspec/schemas/spec-driven/templates/design.md +0 -18
  25. package/config/openspec/schemas/spec-driven/templates/proposal.md +4 -3
  26. package/config/upstream-mirrors.json +12 -0
  27. package/config/workspace-agents.sample.md +43 -39
  28. package/openspec/.version +2 -3
  29. package/openspec/commands/apply.md +189 -175
  30. package/openspec/commands/archive.md +237 -216
  31. package/openspec/commands/bulk-archive.md +355 -327
  32. package/openspec/commands/continue.md +116 -105
  33. package/openspec/commands/explore.md +230 -199
  34. package/openspec/commands/ff.md +115 -104
  35. package/openspec/commands/new.md +74 -63
  36. package/openspec/commands/onboard.md +557 -548
  37. package/openspec/commands/propose.md +161 -150
  38. package/openspec/commands/sync.md +277 -249
  39. package/openspec/commands/update.md +92 -80
  40. package/openspec/commands/verify.md +175 -162
  41. package/openspec/skills/openspec-apply-change/SKILL.md +20 -5
  42. package/openspec/skills/openspec-archive-change/SKILL.md +30 -8
  43. package/openspec/skills/openspec-bulk-archive-change/SKILL.md +36 -6
  44. package/openspec/skills/openspec-continue-change/SKILL.md +14 -2
  45. package/openspec/skills/openspec-explore/SKILL.md +21 -9
  46. package/openspec/skills/openspec-ff-change/SKILL.md +14 -2
  47. package/openspec/skills/openspec-new-change/SKILL.md +13 -1
  48. package/openspec/skills/openspec-onboard/SKILL.md +49 -39
  49. package/openspec/skills/openspec-propose/SKILL.md +15 -3
  50. package/openspec/skills/openspec-sync-specs/SKILL.md +31 -2
  51. package/openspec/skills/openspec-update-change/SKILL.md +27 -14
  52. package/openspec/skills/openspec-verify-change/SKILL.md +17 -3
  53. package/package.json +2 -2
  54. package/superpowers/.version +4 -4
  55. package/superpowers/skills/brainstorming/SKILL.md +47 -12
  56. package/superpowers/skills/brainstorming/scripts/frame-template.html +213 -213
  57. package/superpowers/skills/brainstorming/scripts/server.cjs +723 -723
  58. package/superpowers/skills/brainstorming/visual-companion.md +6 -6
  59. package/superpowers/skills/diagnosing-superpowers/SKILL.md +120 -0
  60. package/superpowers/skills/diagnosing-superpowers/prompts/analyst-common.md +38 -0
  61. package/superpowers/skills/diagnosing-superpowers/prompts/cost-and-time.md +28 -0
  62. package/superpowers/skills/diagnosing-superpowers/prompts/plan-adherence.md +29 -0
  63. package/superpowers/skills/diagnosing-superpowers/prompts/quality-evidence.md +26 -0
  64. package/superpowers/skills/diagnosing-superpowers/prompts/repeated-work.md +30 -0
  65. package/superpowers/skills/diagnosing-superpowers/prompts/request-conflicts.md +20 -0
  66. package/superpowers/skills/diagnosing-superpowers/prompts/scrub-audit.md +33 -0
  67. package/superpowers/skills/diagnosing-superpowers/prompts/scrub.md +29 -0
  68. package/superpowers/skills/diagnosing-superpowers/prompts/similar-session.md +38 -0
  69. package/superpowers/skills/diagnosing-superpowers/prompts/skill-timeline.md +30 -0
  70. package/superpowers/skills/diagnosing-superpowers/prompts/stumbles.md +28 -0
  71. package/superpowers/skills/diagnosing-superpowers/references/context-safety.md +22 -0
  72. package/superpowers/skills/diagnosing-superpowers/references/github-issues.md +47 -0
  73. package/superpowers/skills/diagnosing-superpowers/references/redaction-policy.md +34 -0
  74. package/superpowers/skills/diagnosing-superpowers/references/session-discovery.md +31 -0
  75. package/superpowers/skills/diagnosing-superpowers/templates/bundle-README.md +77 -0
  76. package/superpowers/skills/diagnosing-superpowers/templates/case.md +64 -0
  77. package/superpowers/skills/diagnosing-superpowers/templates/issue.md +51 -0
  78. package/superpowers/skills/diagnosing-superpowers/templates/report.md +82 -0
  79. package/superpowers/skills/executing-plans/SKILL.md +350 -41
  80. package/superpowers/skills/executing-plans/scripts/task-done +52 -0
  81. package/superpowers/skills/executing-plans/scripts/task-start +28 -0
  82. package/superpowers/skills/requesting-code-review/SKILL.md +1 -1
  83. package/superpowers/skills/requesting-code-review/code-reviewer.md +17 -0
  84. package/superpowers/skills/subagent-driven-development/SKILL.md +18 -18
  85. package/superpowers/skills/subagent-driven-development/re-review-prompt.md +1 -1
  86. package/superpowers/skills/subagent-driven-development/scripts/review-package +53 -46
  87. package/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +82 -40
  88. package/superpowers/skills/subagent-driven-development/scripts/task-brief +43 -41
  89. package/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +2 -2
  90. package/superpowers/skills/systematic-debugging/root-cause-tracing.md +1 -1
  91. package/superpowers/skills/test-driven-development/SKILL.md +10 -0
  92. package/superpowers/skills/using-superpowers/SKILL.md +2 -0
  93. package/superpowers/skills/using-superpowers/references/claude-code-tools.md +29 -0
  94. package/superpowers/skills/using-superpowers/references/muse-tools.md +35 -0
  95. package/superpowers/skills/writing-plans/SKILL.md +30 -9
  96. package/superpowers/skills/writing-skills/SKILL.md +4 -2
  97. package/superpowers/skills/writing-skills/graphviz-conventions.dot +171 -171
  98. package/cbb/worktrees/commands/worktree-close.md +0 -64
  99. package/cbb/worktrees/commands/worktree-init.md +0 -51
  100. package/cbb/worktrees/commands/worktree-push.md +0 -42
@@ -1,554 +1,337 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * OpenSpec 工作流管理模块
5
- *
6
- * 负责从 GitHub (Fission-AI/OpenSpec) 拉取官方 workflow 模板,
7
- * 渲染并部署到本项目的 openspec/{commands,skills} 目录。
8
- * 提供 `cbbm update --upstream-openspec` 选项。
9
- *
10
- * 升级范围严格限定:只有 WORKFLOWS 白名单中的 commands/*.md
11
- * skills/<workflow>/SKILL.md 会被覆盖写入。config/openspec/(schemas + config.yaml)、
12
- * cbb/worktrees/ 等项目自有扩展位不会被触碰 —— assertSafeDestination
13
- * 兜底拦截,任何写入命中受保护前缀都会抛错退出。
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
- const https = require('https');
19
- const os = require('os');
20
- const zlib = require('zlib');
21
-
22
- const SHARED_STANDARDS_DIR = path.join(__dirname, '..', '..', '..');
23
-
24
- // 部署目标根目录
25
- const OPENSPEC_DIR = path.join(SHARED_STANDARDS_DIR, 'openspec');
26
- const OPENSPEC_COMMANDS_DIR = path.join(OPENSPEC_DIR, 'commands');
27
- const OPENSPEC_SKILLS_DIR = path.join(OPENSPEC_DIR, 'skills');
28
-
29
- // 版本文件(commit SHA)
30
- const VERSION_FILE = path.join(OPENSPEC_DIR, '.version');
31
-
32
- // GitHub 配置
33
- const GITHUB_REPO = 'Fission-AI/OpenSpec';
34
- const GITHUB_BRANCH = 'main';
35
- // 使用 codeload (不走 API,无速率限制) 与 git ls-remote (拉 SHA,无速率限制)
36
- const GITHUB_TARBALL_URL = `https://codeload.github.com/${GITHUB_REPO}/tar.gz/refs/heads/${GITHUB_BRANCH}`;
37
- const GITHUB_GIT_URL = `https://github.com/${GITHUB_REPO}.git`;
38
-
39
- // 升级白名单:每个工作流的源文件名 命令文件名 + skill 目录名
40
- // 部署 strategy:
41
- // - cmd 不为空 写入 commands/<cmd>.md
42
- // - skill 不为空 → 写入 skills/<skill>/SKILL.md
43
- const WORKFLOWS = [
44
- { name: 'propose', cmd: 'propose', skill: 'openspec-propose' },
45
- { name: 'apply-change', cmd: 'apply', skill: 'openspec-apply-change' },
46
- { name: 'archive-change', cmd: 'archive', skill: 'openspec-archive-change' },
47
- { name: 'verify-change', cmd: 'verify', skill: 'openspec-verify-change' },
48
- { name: 'ff-change', cmd: 'ff', skill: 'openspec-ff-change' },
49
- { name: 'continue-change', cmd: 'continue', skill: 'openspec-continue-change' },
50
- { name: 'new-change', cmd: 'new', skill: 'openspec-new-change' },
51
- { name: 'explore', cmd: 'explore', skill: 'openspec-explore' },
52
- { name: 'update-change', cmd: 'update', skill: 'openspec-update-change' },
53
- { name: 'sync-specs', cmd: 'sync', skill: 'openspec-sync-specs' },
54
- { name: 'bulk-archive-change',cmd: 'bulk-archive', skill: 'openspec-bulk-archive-change' },
55
- { name: 'onboard', cmd: 'onboard', skill: 'openspec-onboard' },
56
- ];
57
-
58
- // ── 受保护路径(项目自有扩展位,绝对禁止覆盖写入)─────────────
59
- //
60
- // AGENTS.md「原生内容保护与扩展位」一致:
61
- // - config/openspec/schemas/ 项目自托管 schema 与模板(spec-driven)
62
- // - cbb/worktrees/ 项目自有扩展(worktree 命令/技能/_shared)
63
- // - config/openspec/config.yaml 项目自托管
64
- //
65
- // 任何写入命中下列任一前缀,assertSafeDestination 立即抛错,
66
- // 避免后续重构意外把 schema.yaml / config.yaml / worktrees/ 冲掉。
67
- const PROTECTED_PATH_PREFIXES = [
68
- path.join(SHARED_STANDARDS_DIR, 'config', 'openspec', 'schemas'),
69
- path.join(SHARED_STANDARDS_DIR, 'cbb', 'worktrees'),
70
- path.join(SHARED_STANDARDS_DIR, 'config', 'openspec', 'config.yaml'),
71
- ];
72
-
73
- function assertSafeDestination(destPath, op) {
74
- const abs = path.resolve(destPath);
75
- for (const prefix of PROTECTED_PATH_PREFIXES) {
76
- if (abs === prefix || abs.startsWith(prefix + path.sep)) {
77
- throw new Error(
78
- `[cbbm update --upstream-openspec] ${op} 试图写入受保护路径: ${abs}\n` +
79
- `命中前缀: ${prefix}\n` +
80
- `该路径属于项目自有扩展位,绝对禁止被 cbbm update --upstream-openspec 覆盖。\n` +
81
- `详见 AGENTS.md「原生内容保护与扩展位」一节。`
82
- );
83
- }
84
- }
85
- }
86
-
87
- // lib/install/init.js 共用输出辅助
88
- const { log } = require('../utils/output');
89
-
90
- // 嵌入的 STORE_SELECTION_GUIDANCE (来自 Fission-AI/OpenSpec store-selection.ts)
91
- // 用于替换官方 TS 模板中的 ${STORE_SELECTION_GUIDANCE} 占位符
92
- const STORE_SELECTION_GUIDANCE_TEXT =
93
- '**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.';
94
-
95
- // ── HTTP 工具 ──────────────────────────────────────────────────
96
-
97
- function httpsGet(url, headers = {}, redirectCount = 0) {
98
- if (redirectCount > 5) throw new Error('Too many redirects');
99
-
100
- return new Promise((resolve, reject) => {
101
- const defaultHeaders = { 'User-Agent': 'cbb-cli' };
102
- https.get(url, { headers: { ...defaultHeaders, ...headers } }, (res) => {
103
- if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
104
- res.resume();
105
- return httpsGet(res.headers.location, headers, redirectCount + 1)
106
- .then(resolve, reject);
107
- }
108
-
109
- const chunks = [];
110
- res.on('data', chunk => chunks.push(chunk));
111
- res.on('end', () => {
112
- resolve({
113
- statusCode: res.statusCode,
114
- headers: res.headers,
115
- body: Buffer.concat(chunks),
116
- });
117
- });
118
- res.on('error', reject);
119
- }).on('error', reject);
120
- });
121
- }
122
-
123
- async function downloadToFile(url, destPath) {
124
- const res = await httpsGet(url);
125
- if (res.statusCode !== 200) {
126
- throw new Error(`HTTP ${res.statusCode}: ${url}`);
127
- }
128
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
129
- fs.writeFileSync(destPath, res.body);
130
- return res;
131
- }
132
-
133
- /**
134
- * HEAD 请求拿 ETag,用于无 API 限速的版本比对。
135
- * codeload 对同一 ref 的 tarball 返回稳定 ETag;上游变更即变化。
136
- */
137
- async function fetchEtag(url) {
138
- return new Promise((resolve, reject) => {
139
- const req = https.request(url, { method: 'HEAD', headers: { 'User-Agent': 'cbb-cli' } }, (res) => {
140
- const etag = (res.headers.etag || '').replace(/^W\//, '').replace(/^"|"$/g, '');
141
- res.resume();
142
- res.on('end', () => resolve({ statusCode: res.statusCode, etag }));
143
- });
144
- req.on('error', reject);
145
- req.end();
146
- });
147
- }
148
-
149
- // ── tar.gz 解压 ──────────────────────────────────────────────────
150
-
151
- /**
152
- * 跨平台 .tar.gz 解压,用 zlib + 手写 tar parser,避开 OS shell tar。
153
- * 支持路径嵌套目录创建,跳过权限/所有者字段。
154
- */
155
- function extractTarGz(tarGzPath, destDir) {
156
- const compressed = fs.readFileSync(tarGzPath);
157
- const data = zlib.gunzipSync(compressed);
158
-
159
- let off = 0;
160
- while (off + 512 <= data.length) {
161
- const header = data.subarray(off, off + 512);
162
- off += 512;
163
-
164
- // 空块 = 连续两个 512-byte 零 = tar 终止
165
- if (header.every(b => b === 0)) break;
166
-
167
- // 文件名在 header[0..100] (NUL 终止)
168
- let nameEnd = 0;
169
- while (nameEnd < 100 && header[nameEnd] !== 0) nameEnd++;
170
- const name = header.subarray(0, nameEnd).toString('utf-8');
171
-
172
- // 文件大小在 header[124..136] (octal ASCII)
173
- const sizeStr = header.subarray(124, 136).toString('utf-8').trim();
174
- const size = parseInt(sizeStr, 8) || 0;
175
-
176
- const target = path.join(destDir, name);
177
- if (name.endsWith('/')) {
178
- fs.mkdirSync(target, { recursive: true });
179
- } else {
180
- fs.mkdirSync(path.dirname(target), { recursive: true });
181
- fs.writeFileSync(target, data.subarray(off, off + size));
182
- }
183
-
184
- // 数据按 512 字节对齐
185
- off += Math.ceil(size / 512) * 512;
186
- }
187
- }
188
-
189
- // ── 版本管理 ──────────────────────────────────────────────────
190
-
191
- function getLocalVersion() {
192
- if (!fs.existsSync(VERSION_FILE)) return null;
193
- try {
194
- const content = fs.readFileSync(VERSION_FILE, 'utf-8').trim();
195
- if (content.startsWith('{')) {
196
- const data = JSON.parse(content);
197
- return data.sha || null;
198
- }
199
- return content || null;
200
- } catch (_) {
201
- return null;
202
- }
203
- }
204
-
205
- function writeVersion(sha) {
206
- const data = {
207
- sha,
208
- branch: GITHUB_BRANCH,
209
- repo: GITHUB_REPO,
210
- updatedAt: new Date().toISOString(),
211
- };
212
- fs.writeFileSync(VERSION_FILE, JSON.stringify(data, null, 2) + '\n');
213
- }
214
-
215
- // ── 渲染:从 *.ts 文件中提取 template literal 并替换占位符 ──
216
-
217
- /**
218
- * 反转义 TS 模板字符串中典型的转义:
219
- * \` → ` (反引号)
220
- * \$ → $ (美元符,常用于 \${\w} 形式防止被当作插值)
221
- * \\ → \ (反斜杠本身)
222
- */
223
- function unescapeTsTemplate(s) {
224
- return s
225
- .replace(/\\`/g, '`')
226
- .replace(/\\\$/g, '$')
227
- .replace(/\\\\/g, '\\');
228
- }
229
-
230
- /**
231
- * 将模板字符串中的 ${STORE_SELECTION_GUIDANCE} 替换为实际内容。
232
- * 上游 store-selection.ts 是常量,我们直接内嵌字符串,避免运行时求值。
233
- */
234
- function substituteStoreGuidance(s) {
235
- return s.split('${STORE_SELECTION_GUIDANCE}').join(STORE_SELECTION_GUIDANCE_TEXT);
236
- }
237
-
238
- /**
239
- * TS 源码中提取 SkillTemplate 函数体内的字段,并渲染成 SKILL.md。
240
- * @param {string} tsContent - 整个 .ts 文件内容
241
- * @param {string} functionName - 例如 'getProposeSkillTemplate' / 'getApplyChangeSkillTemplate'
242
- * @returns {string|null} 渲染出的 SKILL.md 完整内容,若提取失败返回 null
243
- */
244
- function renderSkillFromTs(tsContent, functionName) {
245
- // 匹配 functionName(...): SkillTemplate { return { ... }; }
246
- const fnRegex = new RegExp(
247
- `function\\s+${functionName}\\s*\\(\\)\\s*:\\s*SkillTemplate\\s*\\{[\\s\\S]*?return\\s*\\{([\\s\\S]*?)\\}\\s*;\\s*\\}`,
248
- 'm'
249
- );
250
- const fnMatch = tsContent.match(fnRegex);
251
- if (!fnMatch) return null;
252
- const fields = fnMatch[1];
253
-
254
- // 提取 name (单引号字符串)
255
- const nameMatch = fields.match(/name:\s*'([^']+)'/);
256
- const name = nameMatch ? nameMatch[1] : '';
257
-
258
- // 提取 description (单引号字符串)
259
- const descMatch = fields.match(/description:\s*'([\s\S]*?)',\s*\n\s*instructions/);
260
- const description = descMatch ? descMatch[1].trim() : '';
261
-
262
- // 提取 instructions (backtick 模板字符串,以 `\n license:` 为锚点)
263
- let instMatch = fields.match(/instructions:\s*`([\s\S]*?)`,\s*\n\s*license/);
264
- if (!instMatch) {
265
- // 兜底: instructions 可能是函数调用,如 instructions: getOnboardInstructions(),
266
- const fnCallMatch = fields.match(/instructions:\s*(\w+)\(\)/);
267
- if (fnCallMatch) {
268
- const helperFnName = fnCallMatch[1];
269
- const helperFnRegex = new RegExp(
270
- `function\\s+${helperFnName}\\s*\\([^)]*\\)\\s*:\\s*string\\s*\\{[\\s\\S]*?return\\s*\`([\\s\\S]*?)\`\\s*;\\s*\\}`,
271
- 'm'
272
- );
273
- const helperMatch = tsContent.match(helperFnRegex);
274
- if (helperMatch) {
275
- instMatch = [null, helperMatch[1]];
276
- }
277
- }
278
- }
279
- if (!instMatch) return null;
280
- let instructions = unescapeTsTemplate(substituteStoreGuidance(instMatch[1]));
281
- // 去掉开头/结尾的多余换行
282
- instructions = instructions.replace(/^\n+/, '').replace(/\n+$/, '') + '\n';
283
-
284
- // 提取 license / compatibility / metadata
285
- const licenseMatch = fields.match(/license:\s*'([^']+)'/);
286
- const license = licenseMatch ? licenseMatch[1] : 'MIT';
287
- const compatMatch = fields.match(/compatibility:\s*'([^']+)'/);
288
- const compatibility = compatMatch ? compatMatch[1] : 'Requires openspec CLI.';
289
- const metaMatch = fields.match(/metadata:\s*\{\s*author:\s*'([^']+)',\s*version:\s*'([^']+)'/);
290
- const metaAuthor = metaMatch ? metaMatch[1] : 'openspec';
291
- const metaVersion = metaMatch ? metaMatch[2] : '1.0';
292
-
293
- // 构造 frontmatter (YAML)
294
- const fm = [
295
- '---',
296
- `name: ${name}`,
297
- `description: ${description}`,
298
- `license: ${license}`,
299
- `compatibility: ${compatibility}`,
300
- 'metadata:',
301
- ` author: ${metaAuthor}`,
302
- ` version: "${metaVersion}"`,
303
- ` generatedBy: "1.6.0"`,
304
- '---',
305
- '',
306
- ].join('\n');
307
-
308
- return fm + instructions;
309
- }
310
-
311
- /**
312
- * 从 TS 源码中提取 CommandTemplate 函数体内的 content,并渲染成 .md。
313
- * @param {string} tsContent
314
- * @param {string} functionName - 例如 'getOpsxProposeCommandTemplate'
315
- * @returns {string|null}
316
- */
317
- function renderCommandFromTs(tsContent, functionName) {
318
- const fnRegex = new RegExp(
319
- `function\\s+${functionName}\\s*\\(\\)\\s*:\\s*CommandTemplate\\s*\\{[\\s\\S]*?return\\s*\\{([\\s\\S]*?)\\}\\s*;\\s*\\}`,
320
- 'm'
321
- );
322
- const fnMatch = tsContent.match(fnRegex);
323
- if (!fnMatch) return null;
324
- const fields = fnMatch[1];
325
-
326
- // 提取 description (单引号字符串)
327
- const descMatch = fields.match(/description:\s*'([\s\S]*?)',\s*\n\s*category/);
328
- const description = descMatch ? descMatch[1].trim() : '';
329
-
330
- // 提取 category / tags
331
- const catMatch = fields.match(/category:\s*'([^']+)'/);
332
- const category = catMatch ? catMatch[1] : 'Workflow';
333
- const tagsMatch = fields.match(/tags:\s*\[([^\]]+)\]/);
334
- let tags = '[]';
335
- if (tagsMatch) {
336
- const tagList = tagsMatch[1]
337
- .split(',')
338
- .map(t => t.trim().replace(/^['"]|['"]$/g, ''))
339
- .filter(Boolean);
340
- tags = '[' + tagList.join(', ') + ']';
341
- }
342
-
343
- // 提取 content (backtick 模板字符串;content 是 CommandTemplate 的最末字段,
344
- // 所以以字符串结尾 `$` 作为锚点即可,不带 /m 标志)
345
- let contentMatch = fields.match(/content:\s*`([\s\S]*?)`\s*$/);
346
- if (!contentMatch) {
347
- // 兜底: content 可能是函数调用,如 content: getOnboardInstructions(),
348
- const fnCallMatch = fields.match(/content:\s*(\w+)\(\)/);
349
- if (fnCallMatch) {
350
- const helperFnName = fnCallMatch[1];
351
- const helperFnRegex = new RegExp(
352
- `function\\s+${helperFnName}\\s*\\([^)]*\\)\\s*:\\s*string\\s*\\{[\\s\\S]*?return\\s*\`([\\s\\S]*?)\`\\s*;\\s*\\}`,
353
- 'm'
354
- );
355
- const helperMatch = tsContent.match(helperFnRegex);
356
- if (helperMatch) {
357
- contentMatch = [null, helperMatch[1]];
358
- }
359
- }
360
- }
361
- if (!contentMatch) return null;
362
- let content = unescapeTsTemplate(substituteStoreGuidance(contentMatch[1]));
363
- content = content.replace(/^\n+/, '').replace(/\n+$/, '') + '\n';
364
-
365
- // 派生 name: 'OPSX: <Title>' 来自 description 的若干词
366
- // 实际从 CommandTemplate.name 字段读取
367
- const nameMatch = fields.match(/name:\s*'([^']+)'/);
368
- const name = nameMatch ? nameMatch[1] : `OPSX: ${description.split(/[.,:!?]/)[0].slice(0, 24)}`;
369
-
370
- const fm = [
371
- '---',
372
- `name: "${name}"`,
373
- `description: ${description}`,
374
- `category: ${category}`,
375
- `tags: ${tags}`,
376
- '---',
377
- '',
378
- ].join('\n');
379
-
380
- return fm + content;
381
- }
382
-
383
- /**
384
- * 把 workflow 名映射到它对应的 TS 函数名。
385
- * 例如 'propose' → getOpsxProposeCommandTemplate / getOpsxProposeSkillTemplate
386
- */
387
- function functionNamesForWorkflow(name, kind /* 'cmd' | 'skill' */) {
388
- // 所有官方 cmd 函数都以 getOpsx 开头,skill 以 get 开头,Workflow 名驼峰式接续
389
- // 我们手动列出(因为没法从文件名可靠推导)
390
- const map = {
391
- 'propose': { cmd: 'getOpsxProposeCommandTemplate', skill: 'getOpsxProposeSkillTemplate' },
392
- 'apply-change': { cmd: 'getOpsxApplyCommandTemplate', skill: 'getApplyChangeSkillTemplate' },
393
- 'archive-change': { cmd: 'getOpsxArchiveCommandTemplate', skill: 'getArchiveChangeSkillTemplate' },
394
- 'verify-change': { cmd: 'getOpsxVerifyCommandTemplate', skill: 'getVerifyChangeSkillTemplate' },
395
- 'ff-change': { cmd: 'getOpsxFfCommandTemplate', skill: 'getFfChangeSkillTemplate' },
396
- 'continue-change': { cmd: 'getOpsxContinueCommandTemplate', skill: 'getContinueChangeSkillTemplate' },
397
- 'new-change': { cmd: 'getOpsxNewCommandTemplate', skill: 'getNewChangeSkillTemplate' },
398
- 'explore': { cmd: 'getOpsxExploreCommandTemplate', skill: 'getExploreSkillTemplate' },
399
- 'update-change': { cmd: 'getOpsxUpdateCommandTemplate', skill: 'getUpdateChangeSkillTemplate' },
400
- 'sync-specs': { cmd: 'getOpsxSyncCommandTemplate', skill: 'getSyncSpecsSkillTemplate' },
401
- 'bulk-archive-change':{ cmd: 'getOpsxBulkArchiveCommandTemplate', skill: 'getBulkArchiveChangeSkillTemplate' },
402
- 'onboard': { cmd: 'getOpsxOnboardCommandTemplate', skill: 'getOnboardSkillTemplate' },
403
- };
404
- return map[name] ? map[name][kind] : null;
405
- }
406
-
407
- // ── 下载与部署 ──────────────────────────────────────────────────
408
-
409
- async function fetchLatestOpenSpec() {
410
- log(`正在获取 OpenSpec 版本元数据...`, 'info');
411
- // 1) 先 HEAD 拿 ETag,避免每次都下载完整 tarball
412
- const head = await fetchEtag(GITHUB_TARBALL_URL);
413
- if (head.statusCode !== 200 || !head.etag) {
414
- throw new Error(`无法获取版本信息: HTTP ${head.statusCode}`);
415
- }
416
- const versionToken = head.etag;
417
- const localToken = getLocalVersion();
418
-
419
- if (versionToken === localToken) {
420
- // 即使版本号一致,也要校验全部文件是否已部署(白名单可能新增了条目)
421
- const { valid } = validateDeployed();
422
- if (valid) {
423
- log(`已是最新版本 (${versionToken.slice(0, 12)})`, 'success');
424
- return { version: versionToken, cmdCount: 0, skillCount: 0, skipped: true };
425
- }
426
- log(`版本一致但文件不完整,强制重新部署...`, 'info');
427
- }
428
-
429
- log(`检测到上游更新,正在下载 OpenSpec tarball...`, 'info');
430
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cbb-os-'));
431
- const tarballPath = path.join(tmpDir, 'openspec.tar.gz');
432
-
433
- try {
434
- await downloadToFile(GITHUB_TARBALL_URL, tarballPath);
435
- log('正在解压...', 'info');
436
- // 跨平台解压:用 zlib + 自写 tar parser。无需 shell tar,Windows 也能跑。
437
- extractTarGz(tarballPath, tmpDir);
438
-
439
- // 通过「含 src/core/templates/workflows」特征定位真仓库根。
440
- // codeload 的 tarball 顶层可能是 pax_global_header / 杂项,但
441
- // 真正的源码根目录固定有一个 src/ 子树。这种探测比依赖目录名约定稳。
442
- let extractedDir = null;
443
- for (const entry of fs.readdirSync(tmpDir)) {
444
- const cand = path.join(tmpDir, entry);
445
- let isDir = false;
446
- try { isDir = fs.statSync(cand).isDirectory(); } catch (_) { continue; }
447
- if (!isDir) continue;
448
- const probe = path.join(cand, 'src', 'core', 'templates', 'workflows');
449
- if (fs.existsSync(probe)) {
450
- extractedDir = cand;
451
- break;
452
- }
453
- }
454
- if (!extractedDir) {
455
- throw new Error('解压后未在 tarball 中定位到含 src/core/templates/workflows 的 OpenSpec 根目录');
456
- }
457
-
458
- const workflowsDir = path.join(extractedDir, 'src', 'core', 'templates', 'workflows');
459
- if (!fs.existsSync(workflowsDir)) throw new Error('未找到 workflows 目录');
460
-
461
- let cmdCount = 0;
462
- let skillCount = 0;
463
-
464
- for (const wf of WORKFLOWS) {
465
- const tsFile = path.join(workflowsDir, `${wf.name}.ts`);
466
- if (!fs.existsSync(tsFile)) {
467
- log(`跳过 ${wf.name}: 官方源无对应文件`, 'warn');
468
- continue;
469
- }
470
- const tsContent = fs.readFileSync(tsFile, 'utf-8');
471
-
472
- // 命令
473
- if (wf.cmd) {
474
- const fnName = functionNamesForWorkflow(wf.name, 'cmd');
475
- const rendered = fnName ? renderCommandFromTs(tsContent, fnName) : null;
476
- if (!rendered) {
477
- log(`${wf.name}.ts: 未提取到 CommandTemplate,跳过`, 'warn');
478
- } else {
479
- const dest = path.join(OPENSPEC_COMMANDS_DIR, `${wf.cmd}.md`);
480
- assertSafeDestination(dest, `写入命令 ${wf.name}`);
481
- fs.mkdirSync(path.dirname(dest), { recursive: true });
482
- fs.writeFileSync(dest, rendered);
483
- cmdCount++;
484
- }
485
- }
486
-
487
- // 技能
488
- if (wf.skill) {
489
- const fnName = functionNamesForWorkflow(wf.name, 'skill');
490
- const rendered = fnName ? renderSkillFromTs(tsContent, fnName) : null;
491
- if (!rendered) {
492
- log(`${wf.name}.ts: 未提取到 SkillTemplate,跳过`, 'warn');
493
- } else {
494
- const dest = path.join(OPENSPEC_SKILLS_DIR, wf.skill, 'SKILL.md');
495
- assertSafeDestination(dest, `写入技能 ${wf.name}`);
496
- fs.mkdirSync(path.dirname(dest), { recursive: true });
497
- fs.writeFileSync(dest, rendered);
498
- skillCount++;
499
- }
500
- }
501
- }
502
-
503
- writeVersion(versionToken);
504
- log(
505
- `已更新 OpenSpec 工作流: ${localToken ? localToken.slice(0, 12) : '(无)'} → ${versionToken.slice(0, 12)} ` +
506
- `(${cmdCount} 个命令 + ${skillCount} 个技能)`,
507
- 'success'
508
- );
509
-
510
- return { version: versionToken, cmdCount, skillCount, skipped: false };
511
- } catch (err) {
512
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
513
- throw err;
514
- }
515
- }
516
-
517
- // ── 完整性校验 ──────────────────────────────────────────────────
518
-
519
- /**
520
- * 校验 Safe-Overwrite 区的所有文件均已部署。
521
- * @returns {{valid: boolean, missing: string[]}}
522
- */
523
- function validateDeployed() {
524
- const missing = [];
525
- for (const wf of WORKFLOWS) {
526
- if (wf.cmd) {
527
- const p = path.join(OPENSPEC_COMMANDS_DIR, `${wf.cmd}.md`);
528
- if (!fs.existsSync(p)) missing.push(`commands/${wf.cmd}.md`);
529
- }
530
- if (wf.skill) {
531
- const p = path.join(OPENSPEC_SKILLS_DIR, wf.skill, 'SKILL.md');
532
- if (!fs.existsSync(p)) missing.push(`skills/${wf.skill}/SKILL.md`);
533
- }
534
- }
535
- return { valid: missing.length === 0, missing };
536
- }
537
-
538
- // ── 导出 ────────────────────────────────────────────────────────
539
-
540
- module.exports = {
541
- OPENSPEC_DIR,
542
- OPENSPEC_COMMANDS_DIR,
543
- OPENSPEC_SKILLS_DIR,
544
- VERSION_FILE,
545
- WORKFLOWS,
546
- GITHUB_REPO,
547
- GITHUB_BRANCH,
548
-
549
- PROTECTED_PATH_PREFIXES,
550
- assertSafeDestination,
551
- fetchLatestOpenSpec,
552
- getLocalVersion,
553
- validateDeployed,
554
- };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OpenSpec 工作流管理模块
5
+ *
6
+ * 内容来源 = 指定版本的 OpenSpec CLI npm 包(默认 npmmirror,可配置镜像),
7
+ * 调用该包自带的渲染器产出 canonical 形态的 commands + skills,再由本模块
8
+ * 套上本仓库的 frontmatter 格式写入 openspec/{commands,skills}/。
9
+ *
10
+ * 为什么不再自研 TS 模板解析:上游 1.13.1 起模板引入 PROJECT_ROOT_GUARD、
11
+ * optionalWorkflow 条件块等新语法,正则解析会持续落后。改用 CLI 自带渲染器后,
12
+ * 内容与 CLI 版本严格一致,且完全不依赖 GitHub(用户/维护者本机可达 npmmirror)。
13
+ *
14
+ * 接缝:用 getSkillTemplates()/getCommandContents()(canonical `/opsx:<id>` 形态),
15
+ * 不用 CLI 的 generateCommand()(那会按 CLI 自己的适配器格式化)——
16
+ * 分发格式的唯一决定者仍是本仓库的适配器改写层。
17
+ *
18
+ * 升级范围严格限定:只有 WORKFLOWS 白名单中的 commands/*.md 和
19
+ * skills/<name>/SKILL.md 会被覆盖写入。config/openspec/(schemas + config.yaml)、
20
+ * cbb/worktrees/ 等项目自有扩展位不会被触碰 —— 由 assertSafeDestination
21
+ * 兜底拦截,任何写入命中受保护前缀都会抛错退出。
22
+ *
23
+ * 升级方式:cbbm update --upstream-openspec(按 .version 的 pin 幂等重渲染)
24
+ * 版本检查:cbbm check-upstream
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+ const url = require('url');
31
+
32
+ const { log } = require('../utils/output');
33
+ const { extractTarGz } = require('../utils/tar');
34
+ const { loadMirrors, fetchWithMirrors } = require('../utils/upstream');
35
+
36
+ const SHARED_STANDARDS_DIR = path.join(__dirname, '..', '..', '..');
37
+
38
+ // 部署目标根目录
39
+ const OPENSPEC_DIR = path.join(SHARED_STANDARDS_DIR, 'openspec');
40
+ const OPENSPEC_COMMANDS_DIR = path.join(OPENSPEC_DIR, 'commands');
41
+ const OPENSPEC_SKILLS_DIR = path.join(OPENSPEC_DIR, 'skills');
42
+
43
+ // 版本锁定文件(pin)
44
+ const VERSION_FILE = path.join(OPENSPEC_DIR, '.version');
45
+
46
+ // 上游包信息
47
+ const NPM_PKG = '@fission-ai/openspec';
48
+ const NPM_PKG_BASENAME = 'openspec';
49
+ const GITHUB_REPO = 'Fission-AI/OpenSpec';
50
+
51
+ // 升级白名单:每个工作流的命令文件名 + skill 目录名
52
+ // 用于 validateDeployed(不下载即可校验完整性)与写入目标定位。
53
+ const WORKFLOWS = [
54
+ { id: 'propose', cmd: 'propose', skill: 'openspec-propose' },
55
+ { id: 'apply', cmd: 'apply', skill: 'openspec-apply-change' },
56
+ { id: 'archive', cmd: 'archive', skill: 'openspec-archive-change' },
57
+ { id: 'verify', cmd: 'verify', skill: 'openspec-verify-change' },
58
+ { id: 'ff', cmd: 'ff', skill: 'openspec-ff-change' },
59
+ { id: 'continue', cmd: 'continue', skill: 'openspec-continue-change' },
60
+ { id: 'new', cmd: 'new', skill: 'openspec-new-change' },
61
+ { id: 'explore', cmd: 'explore', skill: 'openspec-explore' },
62
+ { id: 'update', cmd: 'update', skill: 'openspec-update-change' },
63
+ { id: 'sync', cmd: 'sync', skill: 'openspec-sync-specs' },
64
+ { id: 'bulk-archive', cmd: 'bulk-archive', skill: 'openspec-bulk-archive-change' },
65
+ { id: 'onboard', cmd: 'onboard', skill: 'openspec-onboard' },
66
+ ];
67
+
68
+ // ── 受保护路径(项目自有扩展位,绝对禁止覆盖写入)─────────────
69
+ //
70
+ // AGENTS.md「原生内容保护与扩展位」一致:
71
+ // - config/openspec/schemas/ 项目自托管 schema 与模板(spec-driven)
72
+ // - cbb/worktrees/ 项目自有扩展(worktree 命令/技能/_shared)
73
+ // - config/openspec/config.yaml 项目自有
74
+ //
75
+ // 任何写入命中下列任一前缀,assertSafeDestination 立即抛错,
76
+ // 避免后续重构意外把 schema.yaml / config.yaml / worktrees/ 冲掉。
77
+ const PROTECTED_PATH_PREFIXES = [
78
+ path.join(SHARED_STANDARDS_DIR, 'config', 'openspec', 'schemas'),
79
+ path.join(SHARED_STANDARDS_DIR, 'cbb', 'worktrees'),
80
+ path.join(SHARED_STANDARDS_DIR, 'config', 'openspec', 'config.yaml'),
81
+ ];
82
+
83
+ function assertSafeDestination(destPath, op) {
84
+ const abs = path.resolve(destPath);
85
+ for (const prefix of PROTECTED_PATH_PREFIXES) {
86
+ if (abs === prefix || abs.startsWith(prefix + path.sep)) {
87
+ throw new Error(
88
+ `[cbbm update --upstream-openspec] ${op} 试图写入受保护路径: ${abs}\n` +
89
+ `命中前缀: ${prefix}\n` +
90
+ `该路径属于项目自有扩展位,绝对禁止被 cbbm update --upstream-openspec 覆盖。\n` +
91
+ `详见 AGENTS.md「原生内容保护与扩展位」一节。`
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ // ── 版本管理(pin)────────────────────────────────────────────
98
+
99
+ /**
100
+ * 读取本地 pin(.version version 字段)。
101
+ * 兼容旧格式:无 version 时回退 sha(历史 ETag/commit)。
102
+ * @returns {string|null}
103
+ */
104
+ function getLocalVersion() {
105
+ if (!fs.existsSync(VERSION_FILE)) return null;
106
+ try {
107
+ const content = fs.readFileSync(VERSION_FILE, 'utf-8').trim();
108
+ if (content.startsWith('{')) {
109
+ const data = JSON.parse(content);
110
+ return data.version || data.sha || null;
111
+ }
112
+ return content || null;
113
+ } catch (_) {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function writeVersion(version) {
119
+ const data = {
120
+ version,
121
+ repo: GITHUB_REPO,
122
+ renderedAt: new Date().toISOString(),
123
+ };
124
+ fs.writeFileSync(VERSION_FILE, JSON.stringify(data, null, 2) + '\n');
125
+ }
126
+
127
+ // ── 上游拉取 ──────────────────────────────────────────────────
128
+
129
+ /** npm tarball 下载地址(按镜像前缀拼接) */
130
+ function tarballUrl(mirror, version) {
131
+ return `${mirror}/${NPM_PKG}/-/${NPM_PKG_BASENAME}-${version}.tgz`;
132
+ }
133
+
134
+ /** npm registry 元数据地址(用于 check-upstream) */
135
+ function registryUrl(mirror) {
136
+ return `${mirror}/${NPM_PKG}`;
137
+ }
138
+
139
+ /**
140
+ * 下载指定版本的 OpenSpec CLI npm tarball 并解压,返回包根目录。
141
+ * @param {string} version
142
+ * @param {string} tmpDir
143
+ * @returns {Promise<string>} 解压出的 package/ 绝对路径
144
+ */
145
+ async function downloadCliPackage(version, tmpDir) {
146
+ const mirrors = loadMirrors('openspec');
147
+ log(`正在获取 OpenSpec CLI ${version} 包(${mirrors.map(m => m || '直连').join(' → ')})...`, 'info');
148
+ const { buffer, mirror } = await fetchWithMirrors(mirrors, m => tarballUrl(m, version));
149
+ log(`已从 ${mirror || '(直连)'} 下载 ${(buffer.length / 1024).toFixed(0)} KB`, 'info');
150
+
151
+ const pkgDir = path.join(tmpDir, 'pkg');
152
+ fs.mkdirSync(pkgDir, { recursive: true });
153
+ const { files } = extractTarGz(buffer, pkgDir);
154
+
155
+ const extracted = path.join(pkgDir, 'package');
156
+ if (!fs.existsSync(extracted)) {
157
+ throw new Error('解压后未找到 package/ 目录');
158
+ }
159
+ log(`已解压 ${files} 个文件`, 'info');
160
+ return extracted;
161
+ }
162
+
163
+ /**
164
+ * 加载 CLI 自带渲染器。
165
+ * @param {string} pkgDir - 解压出的 package/ 目录
166
+ * @returns {Promise<{getSkillTemplates: Function, getCommandContents: Function, generateSkillContent: Function, version: string}>}
167
+ */
168
+ async function loadCliRenderer(pkgDir) {
169
+ const entry = path.join(pkgDir, 'dist', 'core', 'shared', 'skill-generation.js');
170
+ if (!fs.existsSync(entry)) {
171
+ throw new Error(
172
+ `未找到 CLI 渲染器: ${path.relative(pkgDir, entry)}\n` +
173
+ `该路径属于 @fission-ai/openspec 的内部结构,上游重构可能导致失效。`
174
+ );
175
+ }
176
+ const mod = await import(url.pathToFileURL(entry).href);
177
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
178
+
179
+ for (const fn of ['getSkillTemplates', 'getCommandContents', 'generateSkillContent']) {
180
+ if (typeof mod[fn] !== 'function') {
181
+ throw new Error(`CLI 渲染器缺少导出函数 ${fn}(@fission-ai/openspec@${pkgJson.version})`);
182
+ }
183
+ }
184
+ return { ...mod, version: pkgJson.version };
185
+ }
186
+
187
+ // ── 渲染:套本仓库的 frontmatter 格式 ─────────────────────────
188
+
189
+ /**
190
+ * 生成 YAML 标量:需要引号时用双引号包裹,否则原样输出(与仓库既有风格一致)。
191
+ */
192
+ function yamlScalar(value) {
193
+ const s = String(value == null ? '' : value);
194
+ if (s === '' || /^[\s>|&*!%@`{}[\],#?:-]/.test(s) || /[:#]\s/.test(s) || /["'\\\n\r\t]/.test(s)) {
195
+ return JSON.stringify(s);
196
+ }
197
+ return s;
198
+ }
199
+
200
+ /** 渲染命令 .md(本仓库格式:name / description / category / tags) */
201
+ function renderCommand(commandContent) {
202
+ const tags = Array.isArray(commandContent.tags) ? commandContent.tags : [];
203
+ return [
204
+ '---',
205
+ `name: ${yamlScalar(commandContent.name)}`,
206
+ `description: ${yamlScalar(commandContent.description)}`,
207
+ `category: ${yamlScalar(commandContent.category)}`,
208
+ `tags: [${tags.join(', ')}]`,
209
+ '---',
210
+ commandContent.body.replace(/\n+$/, '') + '\n',
211
+ ].join('\n');
212
+ }
213
+
214
+ // ── 主流程 ────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * 按 pin 拉取并重新渲染 OpenSpec 工作流到本仓库。
218
+ * 幂等:重复执行结果一致。
219
+ * @returns {Promise<{version: string, cmdCount: number, skillCount: number, skipped: boolean}>}
220
+ */
221
+ async function fetchLatestOpenSpec() {
222
+ const pin = getLocalVersion();
223
+ if (!pin) {
224
+ throw new Error(
225
+ `未找到 OpenSpec 版本 pin(${path.relative(SHARED_STANDARDS_DIR, VERSION_FILE)} 缺失或损坏)。\n` +
226
+ `请先写入 { "version": "<x.y.z>" } 再执行。`
227
+ );
228
+ }
229
+
230
+ // pin 一致且文件齐全 → 幂等跳过
231
+ if (validateDeployed().valid) {
232
+ log(`OpenSpec 内容已按 pin ${pin} 就位,跳过重渲染`, 'success');
233
+ return { version: pin, cmdCount: 0, skillCount: 0, skipped: true };
234
+ }
235
+ log(`检测到内容不完整,按 pin ${pin} 重新渲染...`, 'info');
236
+
237
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cbb-os-'));
238
+ try {
239
+ const pkgDir = await downloadCliPackage(pin, tmpDir);
240
+ const cli = await loadCliRenderer(pkgDir);
241
+ if (cli.version !== pin) {
242
+ log(`警告:pin ${pin},但包内版本为 ${cli.version}`, 'warn');
243
+ }
244
+
245
+ const skills = cli.getSkillTemplates();
246
+ const commands = cli.getCommandContents();
247
+ const cmdById = new Map(commands.map(c => [c.id, c]));
248
+ const skillByName = new Map(skills.map(s => [s.dirName, s]));
249
+
250
+ let cmdCount = 0;
251
+ let skillCount = 0;
252
+
253
+ for (const wf of WORKFLOWS) {
254
+ const cmd = cmdById.get(wf.cmd);
255
+ if (!cmd) {
256
+ log(`跳过命令 ${wf.cmd}: 官方渲染器无此 workflow`, 'warn');
257
+ } else {
258
+ const dest = path.join(OPENSPEC_COMMANDS_DIR, `${wf.cmd}.md`);
259
+ assertSafeDestination(dest, `写入命令 ${wf.cmd}`);
260
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
261
+ fs.writeFileSync(dest, renderCommand(cmd));
262
+ cmdCount += 1;
263
+ }
264
+
265
+ const skill = skillByName.get(wf.skill);
266
+ if (!skill) {
267
+ log(`跳过技能 ${wf.skill}: 官方渲染器无此 workflow`, 'warn');
268
+ } else {
269
+ const dest = path.join(OPENSPEC_SKILLS_DIR, wf.skill, 'SKILL.md');
270
+ assertSafeDestination(dest, `写入技能 ${wf.skill}`);
271
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
272
+ fs.writeFileSync(dest, cli.generateSkillContent(skill.template, cli.version));
273
+ skillCount += 1;
274
+ }
275
+ }
276
+
277
+ writeVersion(pin);
278
+ log(`OpenSpec 工作流已渲染: ${pin}(${cmdCount} 个命令 + ${skillCount} 个技能)`, 'success');
279
+ return { version: pin, cmdCount, skillCount, skipped: false };
280
+ } finally {
281
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * 检查上游是否有新版本(不改动任何文件)。
287
+ * @returns {Promise<{current: string|null, latest: string|null, outdated: boolean}>}
288
+ */
289
+ async function checkUpstream() {
290
+ const current = getLocalVersion();
291
+ const mirrors = loadMirrors('openspec');
292
+ const { buffer } = await fetchWithMirrors(mirrors, m => registryUrl(m));
293
+ const data = JSON.parse(buffer.toString('utf-8'));
294
+ const latest = (data['dist-tags'] || {}).latest || null;
295
+ const outdated = Boolean(current && latest && current !== latest);
296
+ return { current, latest, outdated };
297
+ }
298
+
299
+ // ── 完整性校验 ────────────────────────────────────────────────
300
+
301
+ /**
302
+ * 校验 Safe-Overwrite 区的所有文件均已部署(无需下载)。
303
+ * @returns {{valid: boolean, missing: string[]}}
304
+ */
305
+ function validateDeployed() {
306
+ const missing = [];
307
+ for (const wf of WORKFLOWS) {
308
+ if (wf.cmd) {
309
+ const p = path.join(OPENSPEC_COMMANDS_DIR, `${wf.cmd}.md`);
310
+ if (!fs.existsSync(p)) missing.push(`commands/${wf.cmd}.md`);
311
+ }
312
+ if (wf.skill) {
313
+ const p = path.join(OPENSPEC_SKILLS_DIR, wf.skill, 'SKILL.md');
314
+ if (!fs.existsSync(p)) missing.push(`skills/${wf.skill}/SKILL.md`);
315
+ }
316
+ }
317
+ return { valid: missing.length === 0, missing };
318
+ }
319
+
320
+ // ── 导出 ──────────────────────────────────────────────────────
321
+
322
+ module.exports = {
323
+ OPENSPEC_DIR,
324
+ OPENSPEC_COMMANDS_DIR,
325
+ OPENSPEC_SKILLS_DIR,
326
+ VERSION_FILE,
327
+ WORKFLOWS,
328
+ GITHUB_REPO,
329
+ NPM_PKG,
330
+
331
+ PROTECTED_PATH_PREFIXES,
332
+ assertSafeDestination,
333
+ fetchLatestOpenSpec,
334
+ checkUpstream,
335
+ getLocalVersion,
336
+ validateDeployed,
337
+ };