universal-dev-standards 6.1.1 → 6.2.0

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 (78) hide show
  1. package/bundled/locales/zh-CN/CHANGELOG.md +33 -3
  2. package/bundled/locales/zh-CN/README.md +75 -33
  3. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  4. package/bundled/locales/zh-CN/core/behavior-snapshot.md +2 -2
  5. package/bundled/locales/zh-CN/core/data-migration-testing.md +2 -2
  6. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +4 -4
  7. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +9 -3
  8. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +10 -11
  9. package/bundled/locales/zh-CN/docs/USER-MANUAL.md +40 -21
  10. package/bundled/locales/zh-CN/integrations/gemini-cli/README.md +12 -0
  11. package/bundled/locales/zh-CN/skills/ac-coverage/SKILL.md +11 -5
  12. package/bundled/locales/zh-CN/skills/adr-assistant/SKILL.md +2 -2
  13. package/bundled/locales/zh-CN/skills/commands/brainstorm.md +2 -2
  14. package/bundled/locales/zh-CN/skills/commit-standards/SKILL.md +2 -2
  15. package/bundled/locales/zh-CN/skills/contract-test-assistant/SKILL.md +2 -2
  16. package/bundled/locales/zh-CN/skills/deploy-assistant/SKILL.md +2 -2
  17. package/bundled/locales/zh-CN/skills/dev-methodology/SKILL.md +2 -2
  18. package/bundled/locales/zh-CN/skills/dev-workflow-guide/SKILL.md +2 -2
  19. package/bundled/locales/zh-CN/skills/journey-test-assistant/SKILL.md +2 -2
  20. package/bundled/locales/zh-CN/skills/knowledge-graph/SKILL.md +2 -2
  21. package/bundled/locales/zh-CN/skills/knowledge-graph/guide.md +2 -2
  22. package/bundled/locales/zh-CN/skills/migration-assistant/SKILL.md +188 -2
  23. package/bundled/locales/zh-CN/skills/observability-assistant/guide.md +2 -2
  24. package/bundled/locales/zh-CN/skills/orchestrate/SKILL.md +2 -2
  25. package/bundled/locales/zh-CN/skills/plan/SKILL.md +2 -2
  26. package/bundled/locales/zh-CN/skills/push/SKILL.md +2 -2
  27. package/bundled/locales/zh-CN/skills/retrospective-assistant/SKILL.md +2 -2
  28. package/bundled/locales/zh-CN/skills/reverse-engineer/SKILL.md +2 -2
  29. package/bundled/locales/zh-CN/skills/runbook-assistant/guide.md +2 -2
  30. package/bundled/locales/zh-CN/skills/skill-builder/SKILL.md +2 -2
  31. package/bundled/locales/zh-CN/skills/slo-assistant/guide.md +2 -2
  32. package/bundled/locales/zh-CN/skills/spec-derivation/SKILL.md +2 -2
  33. package/bundled/locales/zh-CN/skills/spec-driven-dev/SKILL.md +40 -2
  34. package/bundled/locales/zh-CN/skills/sweep/SKILL.md +2 -2
  35. package/bundled/locales/zh-CN/skills/testing-guide/SKILL.md +2 -2
  36. package/bundled/locales/zh-TW/CHANGELOG.md +34 -3
  37. package/bundled/locales/zh-TW/README.md +75 -33
  38. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  39. package/bundled/locales/zh-TW/core/audit-trail.md +2 -2
  40. package/bundled/locales/zh-TW/core/behavior-snapshot.md +2 -2
  41. package/bundled/locales/zh-TW/core/browser-compatibility-standards.md +18 -7
  42. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +4 -4
  43. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +9 -3
  44. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +10 -11
  45. package/bundled/locales/zh-TW/docs/USER-MANUAL.md +40 -21
  46. package/bundled/locales/zh-TW/integrations/gemini-cli/README.md +12 -0
  47. package/bundled/skills/commands/journey-test.md +80 -6
  48. package/bundled/skills/commands/skill-builder.md +75 -6
  49. package/package.json +3 -3
  50. package/src/commands/check.js +54 -7
  51. package/src/commands/config.js +9 -0
  52. package/src/commands/init.js +15 -1
  53. package/src/commands/release.js +1 -1
  54. package/src/commands/update.js +82 -31
  55. package/src/config/ai-agent-paths.js +47 -7
  56. package/src/core/constants.js +61 -1
  57. package/src/core/manifest.js +56 -0
  58. package/src/flow/flow-parser.js +1 -1
  59. package/src/flow/gate-loader.js +1 -1
  60. package/src/i18n/messages.js +6 -0
  61. package/src/installers/standards-installer.js +10 -20
  62. package/src/reconciler/actual-state-scanner.js +118 -43
  63. package/src/reconciler/desired-state-calculator.js +157 -43
  64. package/src/reconciler/diff-engine.js +7 -2
  65. package/src/reconciler/manifest-migrator.js +5 -2
  66. package/src/reconciler/plan-executor.js +53 -14
  67. package/src/uninstallers/integration-uninstaller.js +7 -1
  68. package/src/utils/config-loader.js +1 -1
  69. package/src/utils/config-manager.js +1 -1
  70. package/src/utils/github.js +5 -1
  71. package/src/utils/hasher.js +7 -4
  72. package/src/utils/integration-generator.js +121 -78
  73. package/src/utils/registry.js +39 -0
  74. package/src/utils/skills-installer.js +49 -34
  75. package/src/utils/skills-source.js +51 -0
  76. package/src/utils/standard-fixer.js +1 -1
  77. package/src/utils/standard-validator.js +1 -1
  78. package/standards-registry.json +21 -11
@@ -17,6 +17,7 @@ import {
17
17
  } from '../core/manifest.js';
18
18
  import { computeFileHash, computeIntegrationBlockHash } from '../utils/hasher.js';
19
19
  import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
20
+ import { resolveToolKey } from '../core/constants.js';
20
21
 
21
22
  /**
22
23
  * Migrate manifest to latest schema and backfill missing hashes.
@@ -144,8 +145,10 @@ export function backfillFileHashes(projectPath, manifest) {
144
145
  }
145
146
 
146
147
  // Backfill integration block hashes
147
- for (const toolName of (updated.integrations || [])) {
148
- const toolConfig = SUPPORTED_AI_TOOLS[toolName];
148
+ for (const entry of (updated.integrations || [])) {
149
+ // 兩種形狀都要能解出工具(XSPEC-343 R1)
150
+ const toolName = resolveToolKey(entry);
151
+ const toolConfig = toolName ? SUPPORTED_AI_TOOLS[toolName] : null;
149
152
  if (!toolConfig) continue;
150
153
  const filePath = join(projectPath, toolConfig.file);
151
154
  if (!updated.integrationBlockHashes[toolConfig.file] && existsSync(filePath)) {
@@ -14,12 +14,13 @@
14
14
  import { existsSync, unlinkSync, mkdirSync, rmSync, copyFileSync, statSync } from 'fs';
15
15
  import { join, dirname } from 'path';
16
16
  import { copyStandard } from '../utils/copier.js';
17
- import { writeIntegrationFile } from '../utils/integration-generator.js';
17
+ import { writeIntegrationFile, buildToolIntegrationConfig } from '../utils/integration-generator.js';
18
18
  import {
19
19
  installSkillsToMultipleAgents,
20
20
  installCommandsToMultipleAgents
21
21
  } from '../utils/skills-installer.js';
22
22
  import { writeManifest } from '../core/manifest.js';
23
+ import { getRepositoryInfo } from '../utils/registry.js';
23
24
  import { displayLanguageToLocale } from '../utils/locale.js';
24
25
  import { computeFileHash } from '../utils/hasher.js';
25
26
  import { createBackup, cleanupBackups } from './backup-manager.js';
@@ -146,6 +147,24 @@ export async function executePlan(projectPath, plan, manifest, options = {}) {
146
147
  }
147
148
  }
148
149
 
150
+ // Record the version we just reconciled to — but only on a clean run, mirroring
151
+ // `uds update`'s rule (a partial failure must stay retryable).
152
+ //
153
+ // Without this the reconciler applied everything and advanced nothing: the repo
154
+ // still recorded 6.1.0, `uds check` still said "behind the latest release", and
155
+ // the weekly staleness scout — which reads exactly `upstream.version` — kept
156
+ // reporting the repo as stale after a fully successful reconcile. (XSPEC-343 R2)
157
+ if (!dryRun && results.every(r => r.success)) {
158
+ const version = getRepositoryInfo()?.standards?.version;
159
+ if (version) {
160
+ updatedManifest.upstream = {
161
+ ...(updatedManifest.upstream || {}),
162
+ version,
163
+ installed: new Date().toISOString().split('T')[0]
164
+ };
165
+ }
166
+ }
167
+
149
168
  // Write updated manifest
150
169
  if (!dryRun) {
151
170
  try {
@@ -304,6 +323,21 @@ function executeMigrateBlock(projectPath, action, manifest) {
304
323
  if (result.blockHashInfo) {
305
324
  manifest.integrationBlockHashes[result.path] = result.blockHashInfo;
306
325
  }
326
+ // `init` also records integration files in the whole-file `fileHashes`, which
327
+ // is what `uds check`'s File Integrity compares. Rewriting the block without
328
+ // refreshing that entry left the file permanently reported as "modified" —
329
+ // a successful reconcile that reads, afterwards, as a damaged install.
330
+ // (XSPEC-343 R2)
331
+ const tracked = manifest.fileHashes?.[result.path];
332
+ if (tracked) {
333
+ const info = computeFileHash(join(projectPath, result.path));
334
+ if (info) {
335
+ manifest.fileHashes[result.path] = {
336
+ ...info,
337
+ installedAt: tracked.installedAt || new Date().toISOString()
338
+ };
339
+ }
340
+ }
307
341
  return { action, success: true };
308
342
  }
309
343
 
@@ -341,10 +375,19 @@ async function executeSkillBatch(projectPath, skillActions, manifest) {
341
375
 
342
376
  if (skillNames.length > 0) {
343
377
  try {
378
+ // The locale argument was omitted here while the command path below
379
+ // passes it, so a reconcile silently reinstalled every skill in English.
380
+ // telemetry-server lost 59 zh-TW skill files to this before it was caught,
381
+ // and its manifest went on recording `skills.locale: zh-TW` throughout.
382
+ // Prefer the recorded skills locale; fall back to the display language,
383
+ // which is what `uds update` uses. (XSPEC-343 R2)
384
+ const skillLocale = manifest.skills?.locale
385
+ || displayLanguageToLocale(manifest?.options?.display_language);
344
386
  const installResult = await installSkillsToMultipleAgents(
345
387
  manifest.skills.installations,
346
388
  skillNames,
347
- projectPath
389
+ projectPath,
390
+ skillLocale
348
391
  );
349
392
 
350
393
  if (installResult.allFileHashes) {
@@ -434,20 +477,16 @@ async function executeCommandBatch(projectPath, commandActions, manifest) {
434
477
 
435
478
  /**
436
479
  * Build integration generation config from manifest.
480
+ *
481
+ * Delegates to the shared builder so the reconciler and `uds update` generate the
482
+ * same block. The private copy that used to live here omitted `categories`,
483
+ * defaulted the output language to English, and read `integrationConfigs` by tool
484
+ * key when the manifest stores it by file name — so reconciling a repo silently
485
+ * dropped sections the normal update path always wrote. (XSPEC-343 R2)
437
486
  */
438
487
  function buildIntegrationConfig(manifest, toolName) {
439
488
  return {
440
- tool: toolName,
441
- installedStandards: (manifest.standards || []).map(s =>
442
- s.startsWith('.standards/') ? s : `.standards/${s}`
443
- ),
444
- format: manifest.format || 'ai',
445
- contentMode: manifest.contentMode || 'index',
446
- language: manifest.integrationConfigs?.[toolName]?.language || 'en',
447
- outputLanguage: manifest.integrationConfigs?.[toolName]?.outputLanguage || manifest.integrationConfigs?.[toolName]?.commitLanguage || 'english',
448
- installedSkills: manifest.skills?.names || [],
449
- installedCommands: manifest.commands?.names || [],
450
- methodology: manifest.methodology,
451
- ...(manifest.integrationConfigs?.[toolName] || {})
489
+ ...buildToolIntegrationConfig(manifest, toolName),
490
+ format: manifest.format || 'ai'
452
491
  };
453
492
  }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { extractMarkedContent } from '../utils/integration-generator.js';
4
4
  import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
5
+ import { resolveIntegrationFile } from '../core/constants.js';
5
6
 
6
7
  /**
7
8
  * Get the format for a given integration file name
@@ -36,7 +37,12 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
36
37
  return result;
37
38
  }
38
39
 
39
- for (const fileName of integrations) {
40
+ for (const entry of integrations) {
41
+ // Accept both shapes: this uninstaller was written against file names,
42
+ // while the reconciler reads the same field as tool keys. Normalising the
43
+ // manifest to one shape fixes one consumer and breaks the other, so both
44
+ // resolve through the shared helper instead. (XSPEC-343 R1)
45
+ const fileName = resolveIntegrationFile(entry) || entry;
40
46
  const filePath = join(projectPath, fileName);
41
47
 
42
48
  if (!existsSync(filePath)) {
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import yaml from 'js-yaml';
4
+ import * as yaml from 'js-yaml';
5
5
 
6
6
  export class ConfigLoader {
7
7
  constructor(cwd = process.cwd()) {
@@ -2,7 +2,7 @@ import { ConfigLoader } from './config-loader.js';
2
2
  import { ConfigMerger } from './config-merger.js';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
- import yaml from 'js-yaml';
5
+ import * as yaml from 'js-yaml';
6
6
 
7
7
  export class ConfigManager {
8
8
  constructor(cwd = process.cwd()) {
@@ -4,6 +4,7 @@ import { homedir } from 'os';
4
4
  import { fileURLToPath } from 'url';
5
5
  import https from 'https';
6
6
  import { setTimeout as delay } from 'timers/promises';
7
+ import { getSkillsSourceDir } from './skills-source.js';
7
8
 
8
9
  // Re-export agent-specific functions from ai-agent-paths for unified API
9
10
  export {
@@ -26,7 +27,10 @@ const SKILLS_RAW_BASE = 'https://raw.githubusercontent.com/AsiaOstrich/universal
26
27
  const __filename = fileURLToPath(import.meta.url);
27
28
  const __dirname = dirname(__filename);
28
29
  const CLI_ROOT = join(__dirname, '..', '..');
29
- const SKILLS_LOCAL_DIR = join(CLI_ROOT, '..', 'skills', 'claude-code');
30
+
31
+ // Resolved centrally — this file used to hardcode `<CLI_ROOT>/../skills/claude-code`, a
32
+ // layout that no longer exists in either a checkout or an npm install. See skills-source.js.
33
+ const SKILLS_LOCAL_DIR = getSkillsSourceDir();
30
34
 
31
35
  /**
32
36
  * Status codes that are safe to retry (transient errors)
@@ -2,6 +2,7 @@ import { createHash } from 'crypto';
2
2
  import { readFileSync, statSync, existsSync, readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { UDS_MARKERS } from '../core/constants.js';
5
+ import { resolveIntegrationFile } from '../core/constants.js';
5
6
 
6
7
  /**
7
8
  * Compute SHA-256 hash for a file
@@ -109,10 +110,12 @@ export function getFileStatusSummary(projectPath, manifest) {
109
110
  source: e,
110
111
  target: join('.standards', e.split('/').pop())
111
112
  })),
112
- ...manifest.integrations.map(i => ({
113
- source: i,
114
- target: i
115
- }))
113
+ // `integrations` holds file names in most repos and tool keys in some
114
+ // (XSPEC-343 R1) — resolve so both shapes yield a real path.
115
+ ...manifest.integrations.map(i => {
116
+ const f = resolveIntegrationFile(i) || i;
117
+ return { source: f, target: f };
118
+ })
116
119
  ];
117
120
 
118
121
  for (const file of allFiles) {
@@ -3,6 +3,7 @@ import { dirname, join, basename } from 'path';
3
3
  import { getLanguageRules } from '../prompts/integrations.js';
4
4
  import { computeIntegrationBlockHash } from './hasher.js';
5
5
  import { UDS_MARKERS, SUPPORTED_AI_TOOLS, LEGACY_TOOL_MAPPINGS } from '../core/constants.js';
6
+ import { resolveSelectedOptionSources } from './registry.js';
6
7
  import { getAgentConfig, getAgentTier } from '../config/ai-agent-paths.js';
7
8
 
8
9
  /**
@@ -107,24 +108,13 @@ const STANDARD_TASK_MAPPING = {
107
108
  /**
108
109
  * Standard descriptions for index generation
109
110
  */
110
- const STANDARD_DESCRIPTIONS = {
111
- 'anti-hallucination.md': 'AI 協作防幻覺規範',
112
- 'commit-message.ai.yaml': '提交訊息格式',
113
- 'checkin-standards.md': '程式碼簽入檢查',
114
- 'logging-standards.md': '日誌記錄標準',
115
- 'error-code-standards.md': '錯誤碼標準',
116
- 'testing.ai.yaml': '測試標準',
117
- 'versioning.md': '語意化版本',
118
- 'changelog-standards.md': '變更日誌標準',
119
- 'code-review-checklist.md': '程式碼審查清單',
120
- 'spec-driven-development.md': '規格驅動開發',
121
- 'test-completeness-dimensions.md': '測試完整性維度',
122
- 'git-workflow.ai.yaml': 'Git 工作流程',
123
- 'developer-memory.ai.yaml': '開發者持久記憶',
124
- 'project-context-memory.ai.yaml': '專案情境記憶',
125
- 'zh-tw.md': '繁體中文本地化',
126
- 'workflow-enforcement.ai.yaml': '工作流程強制執行'
127
- };
111
+ // STANDARD_DESCRIPTIONS 已移除(XSPEC-358 R1 裁決為方案 A:索引區塊不再列名稱清單)。
112
+ // 移除前的實況值得記錄,因為它是一個**綠燈測試釘住生產環境不成立假設**的實例:
113
+ // 該表有 16 筆真實中文描述,鍵**帶副檔名**('anti-hallucination.md')。
114
+ // init 路徑傳入 copyStandard 的 sourcePath → basename **含副檔名** → 查表命中。
115
+ // update 路徑傳入 manifest.standards → **不含副檔名**(71/78 筆)→ 查表全 miss。
116
+ // 於是同一個區塊在 `uds init` 後有描述、在 `uds update` 後全是 `名稱 - 名稱`。
117
+ // 單元測試餵的是完整檔名,所以它一直是綠的;dev-platform 實測 78/78 皆 fallback。
128
118
 
129
119
  /**
130
120
  * Commit type templates for different output_language options
@@ -2908,6 +2898,11 @@ export function generateComplianceInstructions(installedStandards, mode, format,
2908
2898
  const sections = [];
2909
2899
 
2910
2900
  if (format === 'markdown') {
2901
+ // 三個清單全空時不得留下一個空標題(XSPEC-358 §1)——
2902
+ // 一個沒有內容的章節,讀起來像「這裡本來該有東西而它不見了」。
2903
+ if (mustFollow.length === 0 && shouldFollow.length === 0) {
2904
+ return '';
2905
+ }
2911
2906
  sections.push('## Standards Compliance Instructions');
2912
2907
  sections.push('');
2913
2908
 
@@ -2936,6 +2931,9 @@ export function generateComplianceInstructions(installedStandards, mode, format,
2936
2931
  }
2937
2932
  } else {
2938
2933
  // Plaintext format
2934
+ if (mustFollow.length === 0 && shouldFollow.length === 0) {
2935
+ return '';
2936
+ }
2939
2937
  sections.push('## Standards Compliance Instructions');
2940
2938
  sections.push('');
2941
2939
 
@@ -2975,71 +2973,108 @@ export function generateStandardsIndex(installedStandards, format, language = 'z
2975
2973
  return '';
2976
2974
  }
2977
2975
 
2978
- const coreStandards = [];
2979
- const optionStandards = [];
2980
-
2981
- // Separate core standards from options
2982
- for (const standardPath of installedStandards) {
2983
- const filename = basename(standardPath);
2984
- const isOption = standardPath.includes('/options/') || standardPath.includes('\\options\\');
2985
- const description = STANDARD_DESCRIPTIONS[filename] || filename;
2986
-
2987
- if (isOption) {
2988
- optionStandards.push({ filename, description, path: standardPath });
2989
- } else {
2990
- coreStandards.push({ filename, description, path: standardPath });
2991
- }
2992
- }
2993
-
2994
- const sections = [];
2976
+ const total = installedStandards.length;
2977
+ const optionCount = installedStandards.filter(
2978
+ (p) => p.includes('/options/') || p.includes('\\options\\')
2979
+ ).length;
2980
+ const coreCount = total - optionCount;
2981
+
2982
+ const heading = '## Installed Standards Index';
2983
+ const md = format === 'markdown';
2984
+ const body = language === 'en'
2985
+ ? [
2986
+ `This project has adopted **${total}** UDS standards (${coreCount} core, ${optionCount} options), installed in \`.standards/\`.`,
2987
+ '',
2988
+ 'The authoritative list is the `standards` field of `.standards/manifest.json`. Read a standard\'s own file to see what it requires — and check its first line, which is where a deprecated standard says so.'
2989
+ ]
2990
+ : [
2991
+ `本專案採用 UDS 標準共 **${total}** 條(core ${coreCount}、options ${optionCount}),安裝於 \`.standards/\`。`,
2992
+ '',
2993
+ '權威清單為 `.standards/manifest.json` 的 `standards` 欄位。要知道某標準要求什麼,讀它自己的檔案——並看第一行,已棄用的標準會在那裡寫明。'
2994
+ ];
2995
2995
 
2996
- if (format === 'markdown') {
2997
- sections.push('## Installed Standards Index');
2998
- sections.push('');
2999
- sections.push(language === 'en'
3000
- ? 'This project has adopted UDS standards. All standards are in `.standards/`:'
3001
- : '本專案採用 UDS 標準。所有規範位於 `.standards/`:');
3002
- sections.push('');
2996
+ const plainBody = body.map((l) => l.replace(/`/g, ''));
2997
+ return [heading, '', ...(md ? body : plainBody), ''].join('\n');
2998
+ }
3003
2999
 
3004
- if (coreStandards.length > 0) {
3005
- sections.push(`### Core (${coreStandards.length} standards)`);
3006
- for (const std of coreStandards) {
3007
- sections.push(`- \`${std.filename}\` - ${std.description}`);
3008
- }
3009
- sections.push('');
3010
- }
3000
+ /**
3001
+ * Build the generation config for one AI tool from the manifest.
3002
+ *
3003
+ * Both the normal `uds update` path and the reconciler regenerate integration
3004
+ * blocks, and they used to derive this config independently. The reconciler's
3005
+ * copy omitted `categories` entirely, defaulted `outputLanguage` to English, and
3006
+ * looked up `manifest.integrationConfigs` by **tool key** while the manifest
3007
+ * stores it by **file name** — so the lookup always missed. The result was a
3008
+ * strictly poorer block: reconciling machine-setup silently dropped its
3009
+ * "提交訊息語言" and "Standards Compliance Instructions" sections, which the
3010
+ * normal update path had always written. Two generators, one contract, one of
3011
+ * them quietly lossy. (XSPEC-343 R2)
3012
+ *
3013
+ * `integrationConfigs` is deliberately NOT merged in: its `installedStandards`
3014
+ * is a snapshot frozen at install time, and honouring it would reintroduce
3015
+ * exactly the staleness this whole XSPEC is about.
3016
+ *
3017
+ * @param {Object} manifest - Project manifest
3018
+ * @param {string} tool - Tool key (e.g. 'claude-code')
3019
+ * @returns {Object} Config for generateIntegrationContent / writeIntegrationFile
3020
+ */
3021
+ function withSelectedOptions(manifest) {
3022
+ const standards = manifest.standards || [];
3023
+ const seen = new Set(standards.map((p) => basename(p)));
3024
+ const extra = resolveSelectedOptionSources(manifest.options, manifest.format || 'ai')
3025
+ .filter((p) => !seen.has(basename(p)));
3026
+ return [...standards, ...extra];
3027
+ }
3011
3028
 
3012
- if (optionStandards.length > 0) {
3013
- sections.push('### Options');
3014
- for (const std of optionStandards) {
3015
- sections.push(`- \`options/${std.filename}\` - ${std.description}`);
3016
- }
3017
- sections.push('');
3018
- }
3019
- } else {
3020
- // Plaintext format
3021
- sections.push('## Installed Standards Index');
3022
- sections.push('');
3023
- sections.push(language === 'en'
3024
- ? 'UDS standards installed in .standards/:'
3025
- : 'UDS 標準已安裝於 .standards/:');
3026
- sections.push('');
3029
+ export function buildToolIntegrationConfig(manifest, tool) {
3030
+ const selected = manifest.options?.output_language || manifest.options?.commit_language || 'english';
3031
+ const language = selected === 'bilingual'
3032
+ ? 'bilingual'
3033
+ : selected === 'traditional-chinese' ? 'zh-tw' : 'en';
3034
+ const resolved = resolveContentModeForTool(tool, manifest.contentMode || 'auto');
3027
3035
 
3028
- for (const std of coreStandards) {
3029
- sections.push(`- ${std.filename} - ${std.description}`);
3030
- }
3036
+ return {
3037
+ tool,
3038
+ categories: ['anti-hallucination', 'commit-standards', 'code-review'],
3039
+ language,
3040
+ // Pass manifest entries UNCHANGED. Every consumer here documents this
3041
+ // parameter as "standard file paths" and basenames it for display, while
3042
+ // classifying options by `path.includes('/options/')`. Reducing to basenames
3043
+ // first destroys that signal: options silently count as core, so the index
3044
+ // block reported "(70 core, 0 options)" and the minimal block listed option
3045
+ // files under the wrong heading with a path that does not exist on disk.
3046
+ installedStandards: withSelectedOptions(manifest),
3047
+ contentMode: resolved.contentMode,
3048
+ level: resolved.level,
3049
+ outputLanguage: selected,
3050
+ methodology: manifest.methodology
3051
+ };
3052
+ }
3031
3053
 
3032
- if (optionStandards.length > 0) {
3033
- sections.push('');
3034
- sections.push('Options:');
3035
- for (const std of optionStandards) {
3036
- sections.push(`- options/${std.filename} - ${std.description}`);
3037
- }
3038
- }
3039
- sections.push('');
3040
- }
3054
+ /**
3055
+ * Heading of the index-mode standards block. Not localised — both the English and
3056
+ * the Chinese body sit under this exact string, so it is a stable marker.
3057
+ */
3058
+ export const STANDARDS_INDEX_HEADING = '## Installed Standards Index';
3041
3059
 
3042
- return sections.join('\n');
3060
+ /**
3061
+ * Read the standards count declared by an index-mode block.
3062
+ *
3063
+ * XSPEC-358 R1 replaced the enumerated list with a count plus a pointer to the
3064
+ * manifest. Two checks in `uds check` still asserted the old contract by grepping
3065
+ * the integration file for every standard name, so after the change they reported
3066
+ * `5/70` and `0/7` and told the user to run `uds update` — which regenerates the
3067
+ * same non-enumerating block. An unsatisfiable loop. Checks must assert what the
3068
+ * block claims now, not what it used to list. (XSPEC-343 R2 / XSPEC-358 R1)
3069
+ *
3070
+ * @param {string} content - Full integration file content
3071
+ * @returns {number|null} Declared count, or null if this is not an index block
3072
+ */
3073
+ export function parseStandardsIndexCount(content) {
3074
+ const at = content.indexOf(STANDARDS_INDEX_HEADING);
3075
+ if (at === -1) return null;
3076
+ const match = content.slice(at).match(/\*\*(\d+)\*\*/);
3077
+ return match ? Number(match[1]) : null;
3043
3078
  }
3044
3079
 
3045
3080
  /**
@@ -3053,7 +3088,15 @@ export function wrapWithMarkers(content, format) {
3053
3088
  const warning = format === 'plaintext'
3054
3089
  ? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds install\' or \'npx uds update\' to modify.'
3055
3090
  : '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds install\' or \'npx uds update\' to modify. -->';
3056
- return `${markers.start}\n${warning}\n${content}\n${markers.end}`;
3091
+ // 冪等:warning 位於 markers **內部**,而 extractMarkedContent 取出的內容也含它,
3092
+ // 於是重新包裝會疊出第二份(dev-platform CLAUDE.md 實測 178/179 兩行完全相同)。
3093
+ // 這裡先剝掉內容開頭既有的 warning,不論上游哪條路徑造成都能修掉。
3094
+ // ⚠️ 未隔離出上游那條路徑——這是防禦性修法,如實記錄其範圍。
3095
+ const stripped = content.replace(
3096
+ /^(?:<!--\s*WARNING: This block is managed by UDS[\s\S]*?-->|#\s*WARNING: This block is managed by UDS[^\n]*)\n?/,
3097
+ ''
3098
+ );
3099
+ return `${markers.start}\n${warning}\n${stripped}\n${markers.end}`;
3057
3100
  }
3058
3101
 
3059
3102
  /**
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, join } from 'path';
4
+ import { MANIFEST_OPTION_BINDINGS } from '../core/constants.js';
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = dirname(__filename);
@@ -205,3 +206,41 @@ export function isMultiSelectOption(standard, categoryKey) {
205
206
  }
206
207
  return standard.options[categoryKey].multiSelect === true;
207
208
  }
209
+
210
+ /**
211
+ * Resolve the option source paths a manifest's `options` selection installs.
212
+ *
213
+ * `manifest.options` is the authoritative record of what the project chose;
214
+ * `manifest.standards` records option files inconsistently — most repos list
215
+ * them, machine-setup lists none — so anything counting options from
216
+ * `manifest.standards` alone reported "0 options" for a project that has seven
217
+ * of them on disk. (XSPEC-343 R2)
218
+ *
219
+ * @param {Object} manifestOptions - `manifest.options`
220
+ * @param {string} [format='ai'] - Source format
221
+ * @returns {string[]} Registry-relative source paths, e.g. ai/options/testing/unit-testing.ai.yaml
222
+ */
223
+ export function resolveSelectedOptionSources(manifestOptions, format = 'ai') {
224
+ if (!manifestOptions) return [];
225
+ const all = getAllStandards();
226
+ const paths = [];
227
+
228
+ for (const binding of MANIFEST_OPTION_BINDINGS) {
229
+ const key = binding.manifestKeys.find((k) => manifestOptions[k] != null);
230
+ if (!key) continue;
231
+
232
+ const standard = all.find((s) => s.id === binding.standardId);
233
+ if (!standard) continue;
234
+
235
+ const selection = manifestOptions[key];
236
+ for (const optionId of Array.isArray(selection) ? selection : [selection]) {
237
+ if (typeof optionId !== 'string') continue;
238
+ const option = findOption(standard, binding.categoryKey, optionId);
239
+ if (!option) continue;
240
+ const source = getOptionSource(option, format);
241
+ if (source) paths.push(source);
242
+ }
243
+ }
244
+
245
+ return paths;
246
+ }
@@ -15,10 +15,12 @@ import {
15
15
  getSkillsDirForAgent,
16
16
  getCommandsDirForAgent,
17
17
  getSkillsSupportedAgents,
18
- getCommandsSupportedAgents
18
+ getCommandsSupportedAgents,
19
+ getCommandFileExtension
19
20
  } from '../config/ai-agent-paths.js';
20
21
  import { computeDirectoryHashes, computeFileHash } from './hasher.js';
21
22
  import { isLocalizedLocale } from './locale.js';
23
+ import { getSkillsSourceDir } from './skills-source.js';
22
24
 
23
25
  // Get the CLI package root directory
24
26
  const __filename = fileURLToPath(import.meta.url);
@@ -26,19 +28,8 @@ const __dirname = dirname(__filename);
26
28
  const CLI_ROOT = join(__dirname, '..', '..');
27
29
  const BUNDLED_DIR = join(CLI_ROOT, 'bundled');
28
30
 
29
- /**
30
- * Get the Skills source directory.
31
- * Prioritizes bundled directory (npm install), falls back to development path.
32
- * @returns {string} Path to skills source directory
33
- */
34
- function getSkillsSourceDir() {
35
- const bundledPath = join(BUNDLED_DIR, 'skills');
36
- if (existsSync(bundledPath)) {
37
- return bundledPath;
38
- }
39
- // Development environment fallback
40
- return join(CLI_ROOT, '..', 'skills');
41
- }
31
+ // getSkillsSourceDir now lives in skills-source.js so github.js resolves the same path.
32
+ // Its previous private copy here was the half that worked; the copy in github.js was not.
42
33
 
43
34
  /**
44
35
  * Get the localized Skills source directory for a given locale.
@@ -108,23 +99,56 @@ export function getAvailableSkillNames() {
108
99
  return [];
109
100
  }
110
101
 
111
- const NON_SKILL_ITEMS = [
112
- 'README.md', 'CONTRIBUTING.template.md',
113
- 'commands', '.manifest.json', '.DS_Store'
114
- ];
115
-
116
102
  try {
117
103
  return readdirSync(SKILLS_LOCAL_DIR)
118
104
  .filter(item => {
119
- if (NON_SKILL_ITEMS.includes(item)) return false;
120
105
  const itemPath = join(SKILLS_LOCAL_DIR, item);
121
- return statSync(itemPath).isDirectory();
106
+ if (!statSync(itemPath).isDirectory()) return false;
107
+ // A skill is a directory containing SKILL.md. Defining it positively rather than
108
+ // by a deny-list matters: `skills/` also holds agents/, workflows/, ai/, tools/
109
+ // and _shared/, which belong to other installers. The old deny-list named only
110
+ // some of them, so the rest were treated as skills whose SKILL.md "failed to
111
+ // install" — 5 phantom failures that failed the whole install transaction.
112
+ // A new sibling directory now costs nothing; under a deny-list it would break
113
+ // installation until someone remembered to add it.
114
+ return existsSync(join(itemPath, 'SKILL.md'));
122
115
  });
123
116
  } catch {
124
117
  return [];
125
118
  }
126
119
  }
127
120
 
121
+ /**
122
+ * Every directory name in the skills source tree, including the ones that are not
123
+ * skills (`_shared`, `agents`, `ai`, `commands`, `tools`, `workflows`).
124
+ *
125
+ * Used as a provenance test: if a directory in an adopter's skills folder has a
126
+ * counterpart here, UDS put it there. If it does not, UDS did not — it is either
127
+ * an adopter's own skill or an artefact of a UDS version too old to reason about,
128
+ * and deleting it is not this tool's call to make.
129
+ *
130
+ * The distinction matters because an older CLI copied the non-skill siblings in
131
+ * by mistake, so a strict "is it a skill we ship" test would leave those behind
132
+ * while a naive "is it in the skills folder" test deletes hand-written skills.
133
+ * dev-platform has fourteen of those. (XSPEC-343 R2)
134
+ *
135
+ * @returns {Set<string>} Directory names present in the skills source tree
136
+ */
137
+ export function getSkillsSourceEntryNames() {
138
+ if (!existsSync(SKILLS_LOCAL_DIR)) {
139
+ return new Set();
140
+ }
141
+ try {
142
+ return new Set(
143
+ readdirSync(SKILLS_LOCAL_DIR, { withFileTypes: true })
144
+ .filter((e) => e.isDirectory())
145
+ .map((e) => e.name)
146
+ );
147
+ } catch {
148
+ return new Set();
149
+ }
150
+ }
151
+
128
152
  /**
129
153
  * Get list of available command names from local directory
130
154
  * @returns {string[]} Array of command names (without .md extension)
@@ -550,19 +574,10 @@ export async function installCommandsForAgent(agent, level = 'project', commandN
550
574
  return results;
551
575
  }
552
576
 
553
- /**
554
- * Get the appropriate file extension for commands based on agent
555
- * @param {string} agent - Agent identifier
556
- * @returns {string} File extension (including the dot)
557
- */
558
- function getCommandFileExtension(agent) {
559
- // Gemini CLI uses TOML format
560
- if (agent === 'gemini-cli') {
561
- return '.toml';
562
- }
563
- // Most agents use markdown
564
- return '.md';
565
- }
577
+ // getCommandFileExtension moved to config/ai-agent-paths.js — the reconciler's
578
+ // scanner needs the same mapping, and a second private copy is how the writer
579
+ // and the reader drift apart. It now reads `commandFormat` from the agent config
580
+ // instead of hard-coding one agent id. (XSPEC-343 R2)
566
581
 
567
582
  /**
568
583
  * Install a single command to target directory