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
@@ -30,7 +30,7 @@ import {
30
30
  parseReferences,
31
31
  compareStandardsWithReferences
32
32
  } from '../utils/reference-sync.js';
33
- import { extractMarkedContent, getToolFilePath } from '../utils/integration-generator.js';
33
+ import { extractMarkedContent, getToolFilePath, parseStandardsIndexCount } from '../utils/integration-generator.js';
34
34
  import { getToolFormat } from '../core/constants.js';
35
35
  import { checkForUpdates } from '../utils/npm-registry.js';
36
36
  import { writeUpdateCache } from '../utils/update-checker.js';
@@ -39,6 +39,7 @@ import { WorkflowGate } from '../utils/workflow-gate.js';
39
39
  import { t, setLanguage, isLanguageExplicitlySet } from '../i18n/messages.js';
40
40
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
41
41
  import { lintAll as lintI18nAll, partitionFindings as partitionI18nFindings } from '../lint/i18n.js';
42
+ import { resolveIntegrationFile } from '../core/constants.js';
42
43
 
43
44
  /**
44
45
  * Display the summary of file integrity status
@@ -161,7 +162,9 @@ function performFileIntegrityCheck(projectPath, manifest, msg) {
161
162
  }
162
163
 
163
164
  // Check integrations
164
- for (const int of manifest.integrations) {
165
+ for (const intEntry of manifest.integrations) {
166
+ // 兩種形狀都要能解出路徑(XSPEC-343 R1)
167
+ const int = resolveIntegrationFile(intEntry) || intEntry;
165
168
  const filePath = join(projectPath, int);
166
169
  if (existsSync(filePath)) {
167
170
  fileStatus.noHash.push(int);
@@ -727,7 +730,9 @@ function removeFromManifest(manifest, relativePath) {
727
730
  const fileName = basename(relativePath);
728
731
  manifest.standards = manifest.standards.filter(s => !s.endsWith(fileName));
729
732
  manifest.extensions = manifest.extensions.filter(e => typeof e !== 'string' || !e.endsWith(fileName));
730
- manifest.integrations = manifest.integrations.filter(i => i !== relativePath);
733
+ manifest.integrations = manifest.integrations.filter(
734
+ i => (resolveIntegrationFile(i) || i) !== relativePath
735
+ );
731
736
 
732
737
  // Remove from integrationBlockHashes
733
738
  if (manifest.integrationBlockHashes && manifest.integrationBlockHashes[relativePath]) {
@@ -761,7 +766,7 @@ export function getSourcePathFromRelative(manifest, relativePath) {
761
766
  }
762
767
 
763
768
  // Check integrations - these might need special handling
764
- if (manifest.integrations.includes(relativePath)) {
769
+ if (manifest.integrations.some(i => (resolveIntegrationFile(i) || i) === relativePath)) {
765
770
  // Integration files have different source paths
766
771
  const integrationMappings = {
767
772
  '.cursorrules': 'integrations/cursor/.cursorrules',
@@ -831,7 +836,8 @@ async function migrateToHashBasedTracking(projectPath, manifest) {
831
836
  }
832
837
 
833
838
  // Process integrations
834
- for (const int of manifest.integrations) {
839
+ for (const intEntry of manifest.integrations) {
840
+ const int = resolveIntegrationFile(intEntry) || intEntry;
835
841
  const fullPath = join(projectPath, int);
836
842
 
837
843
  const hashInfo = computeFileHash(fullPath);
@@ -1148,6 +1154,27 @@ function checkIntegrationFiles(manifest, projectPath, msg) {
1148
1154
  // Report status - use all installed standards as the total
1149
1155
  const totalTrackable = standardsFiles.length;
1150
1156
 
1157
+ // Index-mode blocks deliberately do NOT enumerate standards (XSPEC-358 R1);
1158
+ // grepping for each name reports a false failure and points at `uds update`,
1159
+ // which regenerates the identical block. Assert the declared count instead.
1160
+ const declaredCount = parseStandardsIndexCount(content);
1161
+ if (declaredCount !== null) {
1162
+ if (declaredCount === totalTrackable) {
1163
+ console.log(chalk.green(` ✓ ${toolFile}:`));
1164
+ console.log(chalk.gray(` ${msg.standardsIndexPresent}`));
1165
+ console.log(chalk.gray(` ${msg.standardsIndexCount
1166
+ ? msg.standardsIndexCount.replace('{count}', declaredCount)
1167
+ : `Index declares ${declaredCount} standards (matches manifest)`}`));
1168
+ } else {
1169
+ console.log(chalk.yellow(` ⚠ ${toolFile}:`));
1170
+ console.log(chalk.yellow(` ${msg.standardsIndexCountMismatch
1171
+ ? msg.standardsIndexCountMismatch.replace('{declared}', declaredCount).replace('{actual}', totalTrackable)
1172
+ : `Index declares ${declaredCount} standards but the manifest has ${totalTrackable}`}`));
1173
+ hasIssues = true;
1174
+ }
1175
+ continue;
1176
+ }
1177
+
1151
1178
  if (hasStandardsIndex && missingStandards.length === 0) {
1152
1179
  console.log(chalk.green(` ✓ ${toolFile}:`));
1153
1180
  console.log(chalk.gray(` ${msg.standardsIndexPresent}`));
@@ -1219,6 +1246,25 @@ function checkAgentsMdSync(manifest, projectPath, msg) {
1219
1246
  const content = readFileSync(agentsMdPath, 'utf-8');
1220
1247
  const installedStandards = (manifest.standards || []).map(s => basename(s));
1221
1248
 
1249
+ // Same as the AI-tool integration check above: an index-mode block declares a
1250
+ // count instead of listing names, so the name grep below cannot apply to it.
1251
+ const declaredCount = parseStandardsIndexCount(content);
1252
+ if (declaredCount !== null) {
1253
+ const actual = installedStandards.length;
1254
+ if (declaredCount === actual) {
1255
+ console.log(chalk.green(` ✓ AGENTS.md ${msg.standardsSynced || 'standards synced'} (${declaredCount})`));
1256
+ } else {
1257
+ console.log(chalk.yellow(` ⚠ AGENTS.md ${msg.standardsOutOfSync || 'standards out of sync'} (${declaredCount} != ${actual})`));
1258
+ console.log(chalk.gray(` ${msg.runUpdateToSync || 'Run "uds update" to sync'}`));
1259
+ }
1260
+ const idxLineCount = content.split('\n').length;
1261
+ if (idxLineCount > 150) {
1262
+ console.log(chalk.yellow(` ⚠ AGENTS.md ${msg.exceedsLineLimit || 'exceeds 150 line limit'} (${idxLineCount} lines)`));
1263
+ }
1264
+ console.log();
1265
+ return;
1266
+ }
1267
+
1222
1268
  // Check standards listed in AGENTS.md vs manifest
1223
1269
  const aiYamlStandards = installedStandards.filter(s => s.endsWith('.ai.yaml'));
1224
1270
  let listedCount = 0;
@@ -1264,7 +1310,8 @@ function checkReferenceSync(manifest, projectPath, msg) {
1264
1310
 
1265
1311
  // Pre-scan: collect results for files that have references
1266
1312
  const results = [];
1267
- for (const integrationPath of manifest.integrations) {
1313
+ for (const integrationEntry of manifest.integrations) {
1314
+ const integrationPath = resolveIntegrationFile(integrationEntry) || integrationEntry;
1268
1315
  const fullPath = join(projectPath, integrationPath);
1269
1316
 
1270
1317
  if (!existsSync(fullPath)) continue;
@@ -1700,7 +1747,7 @@ function getFileStatusCounts(manifest, projectPath) {
1700
1747
  const allFiles = [
1701
1748
  ...manifest.standards.map(s => `.standards/${basename(s)}`),
1702
1749
  ...manifest.extensions.filter(e => typeof e === 'string').map(e => `.standards/${basename(e)}`),
1703
- ...manifest.integrations
1750
+ ...manifest.integrations.map(i => resolveIntegrationFile(i) || i)
1704
1751
  ];
1705
1752
  for (const relativePath of allFiles) {
1706
1753
  const fullPath = join(projectPath, relativePath);
@@ -49,6 +49,7 @@ import {
49
49
  } from '../utils/integration-generator.js';
50
50
  import { getMarketplaceSkillsInfo } from '../utils/github.js';
51
51
  import { regenerateIntegrations } from './update.js';
52
+ import { mergeInstalledNames } from '../core/manifest.js';
52
53
 
53
54
  /**
54
55
  * Get localized message with fallback (for config-specific keys)
@@ -995,6 +996,7 @@ export async function runProjectConfiguration(options) {
995
996
  manifest.skills.installations.push(inst);
996
997
  }
997
998
  }
999
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, skillResult);
998
1000
  if (skillResult.allFileHashes) {
999
1001
  manifest.skillHashes = { ...(manifest.skillHashes || {}), ...skillResult.allFileHashes };
1000
1002
  }
@@ -1031,6 +1033,7 @@ export async function runProjectConfiguration(options) {
1031
1033
  manifest.commands.installations.push(inst);
1032
1034
  }
1033
1035
  }
1036
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, cmdResult);
1034
1037
  if (cmdResult.allFileHashes) {
1035
1038
  manifest.commandHashes = { ...(manifest.commandHashes || {}), ...cmdResult.allFileHashes };
1036
1039
  }
@@ -1234,6 +1237,7 @@ async function handleSkillsConfiguration(manifest, projectPath, msgObj, common,
1234
1237
  } else {
1235
1238
  manifest.skills.installations.push(installations[0]);
1236
1239
  }
1240
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1237
1241
  writeManifest(manifest, projectPath);
1238
1242
  return;
1239
1243
  }
@@ -1368,6 +1372,7 @@ async function handleSkillsConfiguration(manifest, projectPath, msgObj, common,
1368
1372
  // Update manifest - clear declined status for installed tools
1369
1373
  manifest.skills = manifest.skills || {};
1370
1374
  manifest.skills.installations = installations;
1375
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1371
1376
  if (manifest.declinedFeatures?.skills) {
1372
1377
  manifest.declinedFeatures.skills = manifest.declinedFeatures.skills.filter(
1373
1378
  tool => !declinedToolsWithSupport.includes(tool)
@@ -1402,6 +1407,7 @@ async function handleSkillsConfiguration(manifest, projectPath, msgObj, common,
1402
1407
  // Update manifest
1403
1408
  manifest.skills = manifest.skills || {};
1404
1409
  manifest.skills.installations = installations;
1410
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1405
1411
  writeManifest(manifest, projectPath);
1406
1412
  }
1407
1413
 
@@ -1465,6 +1471,7 @@ async function handleCommandsConfiguration(manifest, projectPath, msgObj, common
1465
1471
  } else {
1466
1472
  manifest.commands.installations.push(installations[0]);
1467
1473
  }
1474
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1468
1475
  writeManifest(manifest, projectPath);
1469
1476
  return;
1470
1477
  }
@@ -1566,6 +1573,7 @@ async function handleCommandsConfiguration(manifest, projectPath, msgObj, common
1566
1573
  // Update manifest - clear declined status for installed tools
1567
1574
  manifest.commands = manifest.commands || {};
1568
1575
  manifest.commands.installations = declinedCommandsWithSupport;
1576
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1569
1577
  if (manifest.declinedFeatures?.commands) {
1570
1578
  manifest.declinedFeatures.commands = manifest.declinedFeatures.commands.filter(
1571
1579
  tool => !declinedCommandsWithSupport.includes(tool)
@@ -1601,5 +1609,6 @@ async function handleCommandsConfiguration(manifest, projectPath, msgObj, common
1601
1609
  // Update manifest
1602
1610
  manifest.commands = manifest.commands || {};
1603
1611
  manifest.commands.installations = selectedAgents;
1612
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1604
1613
  writeManifest(manifest, projectPath);
1605
1614
  }
@@ -240,7 +240,21 @@ export async function initCommand(options) {
240
240
  * - Node.js projects: use husky
241
241
  * - Non-Node.js projects: write native .git/hooks/pre-commit
242
242
  */
243
- export async function setupHuskyHook(projectPath) {
243
+ export async function setupHuskyHook(projectPath, { allowInTest = false } = {}) {
244
+ // 2026-07-30:本函式在跑測試時改寫了 universal-dev-standards 自己
245
+ // ——`npm install --save-dev husky`、改 package.json、建 .husky/pre-commit。
246
+ //
247
+ // 路徑是:測試呼叫 `initCommand({...})`(三個 init 測試檔都沒帶路徑、
248
+ // 也沒 mock child_process)→ `init.js` 取 `process.cwd()` → 走到這裡。
249
+ // 而下面那個 `.git` 是唯一的守衛,它只問「cwd 是不是一個 repo」:
250
+ // 從 `cli/` 跑 → cwd 沒有 .git → 提早 return,無害;
251
+ // 從 repo root 跑 → cwd **有** .git → 照跑,動到開發者自己的 repo。
252
+ // 同一批測試、不同 cwd,一個無害一個會寫檔。
253
+ //
254
+ // 守衛用顯式 opt-in 而非「偵測到測試就一律跳過」:`init.husky.test.js` 有十幾條
255
+ // 測試是**刻意**在暫存目錄裡驗這個函式的行為,一律跳過會把它們變成永遠通過的空測試。
256
+ if (process.env.VITEST && !allowInTest) return;
257
+
244
258
  const hasGit = existsSync(join(projectPath, '.git'));
245
259
  if (!hasGit) return;
246
260
 
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import chalk from 'chalk';
13
- import yaml from 'js-yaml';
13
+ import * as yaml from 'js-yaml';
14
14
  import { existsSync, readFileSync, writeFileSync } from 'fs';
15
15
  import { join } from 'path';
16
16
  import { execSync } from 'child_process';
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { select, confirm as inquirerConfirm, checkbox, Separator } from '@inquirer/prompts';
4
4
  import { execSync } from 'child_process';
5
- import { existsSync, unlinkSync } from 'fs';
5
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
6
6
  import { join, basename } from 'path';
7
7
  import { readManifest, writeManifest, copyStandard, isInitialized } from '../utils/copier.js';
8
8
  import { getRepositoryInfo, getAllStandards, getStandardSource } from '../utils/registry.js';
@@ -11,7 +11,10 @@ import {
11
11
  writeIntegrationFile,
12
12
  getToolFilePath,
13
13
  writeAgentsMdSummary,
14
- resolveContentModeForTool
14
+ resolveContentModeForTool,
15
+ generateIntegrationContent,
16
+ extractMarkedContent,
17
+ buildToolIntegrationConfig
15
18
  } from '../utils/integration-generator.js';
16
19
  import {
17
20
  calculateCategoriesFromStandards,
@@ -51,6 +54,8 @@ import {
51
54
  } from '../reconciler/index.js';
52
55
  import { restoreSingleFile } from './check.js';
53
56
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
57
+ import { resolveIntegrationFile } from '../core/constants.js';
58
+ import { mergeInstalledNames } from '../core/manifest.js';
54
59
 
55
60
  /**
56
61
  * Determine the correct target directory for a standard file.
@@ -335,8 +340,12 @@ export async function updateCommand(options) {
335
340
  }
336
341
 
337
342
  // Handle --integrations-only option
343
+ // ⚠️ 這個分支在 --plan 檢查(見下)**之前**,所以必須自己處理 --plan——
344
+ // 否則 `--plan --integrations-only` 會靜默寫檔。2026-07-30 實測踩到:
345
+ // 以該組合驗證 XSPEC-358 的區塊變更時,它改了 machine-setup 的
346
+ // CLAUDE.md 與 .standards/manifest.json,而旗標文件寫的是 "without executing"。
338
347
  if (options.integrationsOnly) {
339
- await updateIntegrationsOnly(projectPath, manifest);
348
+ await updateIntegrationsOnly(projectPath, manifest, options);
340
349
  return;
341
350
  }
342
351
 
@@ -847,8 +856,9 @@ export async function updateCommand(options) {
847
856
  const fileName = basename(ext);
848
857
  allTrackedFiles.push(join('.standards', fileName));
849
858
  }
850
- for (const int of (manifest.integrations || [])) {
851
- const filePath = getToolFilePath(int);
859
+ for (const intEntry of (manifest.integrations || [])) {
860
+ // 兩種形狀都要能解出路徑(XSPEC-343 R1)
861
+ const filePath = resolveIntegrationFile(intEntry) || getToolFilePath(intEntry);
852
862
  if (filePath) {
853
863
  allTrackedFiles.push(filePath);
854
864
  }
@@ -951,6 +961,7 @@ export async function updateCommand(options) {
951
961
  ...(manifest.skills.installations || []),
952
962
  ...installSkills
953
963
  ];
964
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, skillResult);
954
965
 
955
966
  // Derive location from installations if not set
956
967
  if (!manifest.skills.location) {
@@ -987,6 +998,7 @@ export async function updateCommand(options) {
987
998
  // Update manifest version
988
999
  if (!manifest.skills) manifest.skills = {};
989
1000
  manifest.skills.version = repoInfo.skills.version;
1001
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, updateResult);
990
1002
 
991
1003
  // Derive location from installations if not set
992
1004
  if (!manifest.skills.location && manifest.skills.installations?.length > 0) {
@@ -1028,6 +1040,7 @@ export async function updateCommand(options) {
1028
1040
  ...(manifest.commands.installations || []),
1029
1041
  ...installCommands
1030
1042
  ];
1043
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, cmdResult);
1031
1044
 
1032
1045
  // Update command hashes for integrity tracking
1033
1046
  if (cmdResult.allFileHashes) {
@@ -1053,6 +1066,7 @@ export async function updateCommand(options) {
1053
1066
  // Update manifest version
1054
1067
  if (!manifest.commands) manifest.commands = {};
1055
1068
  manifest.commands.version = repoInfo.skills.version;
1069
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, updateCmdResult);
1056
1070
 
1057
1071
  // Update command hashes for integrity tracking
1058
1072
  if (updateCmdResult.allFileHashes) {
@@ -1127,6 +1141,7 @@ export async function updateCommand(options) {
1127
1141
  manifest.skills.installed = true;
1128
1142
  manifest.skills.version = repoInfo.skills.version;
1129
1143
  manifest.skills.installations = [...(manifest.skills.installations || []), ...missingSkills];
1144
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1130
1145
  if (result.allFileHashes) {
1131
1146
  if (!manifest.skillHashes) manifest.skillHashes = {};
1132
1147
  Object.assign(manifest.skillHashes, result.allFileHashes);
@@ -1144,6 +1159,7 @@ export async function updateCommand(options) {
1144
1159
  const result = await installSkillsToMultipleAgents(outdatedSkills, null, projectPath, skillsLocale);
1145
1160
  if (!manifest.skills) manifest.skills = {};
1146
1161
  manifest.skills.version = repoInfo.skills.version;
1162
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1147
1163
  if (!manifest.skills.location && manifest.skills.installations?.length > 0) {
1148
1164
  const levels = manifest.skills.installations.map(s => s.level).filter(Boolean);
1149
1165
  const uniqueLevels = [...new Set(levels)];
@@ -1168,6 +1184,7 @@ export async function updateCommand(options) {
1168
1184
  manifest.commands.installed = true;
1169
1185
  manifest.commands.version = repoInfo.skills.version;
1170
1186
  manifest.commands.installations = [...(manifest.commands.installations || []), ...missingCommands];
1187
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1171
1188
  if (result.allFileHashes) {
1172
1189
  if (!manifest.commandHashes) manifest.commandHashes = {};
1173
1190
  replaceCommandHashesForUpdatedAgents(manifest.commandHashes, result.allFileHashes);
@@ -1185,6 +1202,7 @@ export async function updateCommand(options) {
1185
1202
  const result = await installCommandsToMultipleAgents(outdatedCommands, null, projectPath, skillsLocale);
1186
1203
  if (!manifest.commands) manifest.commands = {};
1187
1204
  manifest.commands.version = repoInfo.skills.version;
1205
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1188
1206
  if (result.allFileHashes) {
1189
1207
  if (!manifest.commandHashes) manifest.commandHashes = {};
1190
1208
  replaceCommandHashesForUpdatedAgents(manifest.commandHashes, result.allFileHashes);
@@ -1319,17 +1337,6 @@ export function regenerateIntegrations(projectPath, manifest) {
1319
1337
  return { success: true, updated: [], errors: [] };
1320
1338
  }
1321
1339
 
1322
- // Build installed standards list
1323
- const installedStandardsList = manifest.standards?.map(s => basename(s)) || [];
1324
-
1325
- // Determine language setting
1326
- let commonLanguage = 'en';
1327
- if ((manifest.options?.output_language || manifest.options?.commit_language) === 'bilingual') {
1328
- commonLanguage = 'bilingual';
1329
- } else if ((manifest.options?.output_language || manifest.options?.commit_language) === 'traditional-chinese') {
1330
- commonLanguage = 'zh-tw';
1331
- }
1332
-
1333
1340
  const results = {
1334
1341
  updated: [],
1335
1342
  errors: []
@@ -1345,19 +1352,8 @@ export function regenerateIntegrations(projectPath, manifest) {
1345
1352
  continue; // Skip if already generated (AGENTS.md sharing)
1346
1353
  }
1347
1354
 
1348
- // Resolve contentMode per tool based on tier
1349
- const savedMode = manifest.contentMode || 'auto';
1350
- const resolvedMode = resolveContentModeForTool(tool, savedMode);
1351
- const toolConfig = {
1352
- tool,
1353
- categories: ['anti-hallucination', 'commit-standards', 'code-review'],
1354
- language: commonLanguage,
1355
- installedStandards: installedStandardsList,
1356
- contentMode: resolvedMode.contentMode,
1357
- level: resolvedMode.level,
1358
- // Pass output_language for dynamic commit standards generation
1359
- outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english'
1360
- };
1355
+ // Shared with the reconciler so both paths emit the identical block.
1356
+ const toolConfig = buildToolIntegrationConfig(manifest, tool);
1361
1357
 
1362
1358
  const result = writeIntegrationFile(tool, toolConfig, projectPath);
1363
1359
  if (result.success) {
@@ -1392,7 +1388,7 @@ export function regenerateIntegrations(projectPath, manifest) {
1392
1388
  // Regenerate universal AGENTS.md if enabled and not already covered
1393
1389
  if (manifest.generateAgentsMd && !generatedFiles.has('AGENTS.md')) {
1394
1390
  const summaryConfig = {
1395
- installedStandards: installedStandardsList,
1391
+ installedStandards: (manifest.standards || []).map(s => basename(s)),
1396
1392
  language: manifest.options?.display_language || 'en',
1397
1393
  outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english',
1398
1394
  standardOptions: manifest.options || {}
@@ -1422,7 +1418,7 @@ export function regenerateIntegrations(projectPath, manifest) {
1422
1418
  * @param {string} projectPath - Project path
1423
1419
  * @param {Object} manifest - Manifest object
1424
1420
  */
1425
- async function updateIntegrationsOnly(projectPath, manifest) {
1421
+ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1426
1422
  const msg = t().commands.update;
1427
1423
 
1428
1424
  console.log(chalk.cyan(msg.updatingIntegrationsOnly));
@@ -1436,6 +1432,59 @@ async function updateIntegrationsOnly(projectPath, manifest) {
1436
1432
  return;
1437
1433
  }
1438
1434
 
1435
+ // --plan:只比對、不寫任何檔(連 manifest 也不寫)。
1436
+ // 這一段刻意不呼叫 regenerateIntegrations——那個函式會寫檔,
1437
+ // 「先寫再還原」的作法在中途掛掉時會留下壞狀態,比沒有 dry-run 更糟。
1438
+ if (options.plan) {
1439
+ console.log(chalk.bold('=== Integration Plan (dry run — nothing is written) ==='));
1440
+ const wouldChange = [];
1441
+ const unchanged = [];
1442
+ const seen = new Set();
1443
+ for (const tool of aiTools) {
1444
+ const targetFile = getToolFilePath(tool);
1445
+ if (seen.has(targetFile)) continue;
1446
+ seen.add(targetFile);
1447
+ const savedMode = manifest.contentMode || 'auto';
1448
+ const resolvedMode = resolveContentModeForTool(tool, savedMode);
1449
+ const next = generateIntegrationContent({
1450
+ tool,
1451
+ categories: ['anti-hallucination', 'commit-standards', 'code-review'],
1452
+ language: (manifest.options?.output_language || manifest.options?.commit_language) === 'bilingual'
1453
+ ? 'bilingual'
1454
+ : (manifest.options?.output_language || manifest.options?.commit_language) === 'traditional-chinese'
1455
+ ? 'zh-tw' : 'en',
1456
+ installedStandards: manifest.standards?.map((x) => basename(x)) || [],
1457
+ contentMode: resolvedMode.contentMode,
1458
+ level: resolvedMode.level,
1459
+ outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english'
1460
+ });
1461
+ const full = join(projectPath, targetFile);
1462
+ const current = existsSync(full) ? readFileSync(full, 'utf8') : '';
1463
+ const format = targetFile.endsWith('.md') ? 'markdown' : 'plaintext';
1464
+ const cur = extractMarkedContent(current, format).content || '';
1465
+ // 比對正規化過的內容:只關心「受管區塊會不會變」,不關心尾端空白。
1466
+ if (cur.trim() === String(next).trim()) {
1467
+ unchanged.push(targetFile);
1468
+ } else {
1469
+ const delta = String(next).trim().length - cur.trim().length;
1470
+ wouldChange.push(`${targetFile} (${delta >= 0 ? '+' : ''}${delta} bytes in the UDS block)`);
1471
+ }
1472
+ }
1473
+ if (wouldChange.length > 0) {
1474
+ console.log(chalk.yellow(` ~ Would update (${wouldChange.length}):`));
1475
+ for (const f of wouldChange) console.log(chalk.gray(` ${f}`));
1476
+ }
1477
+ if (unchanged.length > 0) {
1478
+ console.log(chalk.gray(` = Unchanged (${unchanged.length}): ${unchanged.join(', ')}`));
1479
+ }
1480
+ console.log();
1481
+ console.log(chalk.gray(' Also NOT written in plan mode: .standards/manifest.json'));
1482
+ console.log(chalk.gray(' (a real run rewrites it — version, fileHashes, integrationBlockHashes,'));
1483
+ console.log(chalk.gray(' and it normalises any standards entries stored as full paths)'));
1484
+ console.log();
1485
+ return;
1486
+ }
1487
+
1439
1488
  const spinner = ora(msg.regeneratingIntegrations).start();
1440
1489
 
1441
1490
  // Use reusable regeneration function
@@ -1698,6 +1747,7 @@ async function updateSkillsOnly(projectPath, manifest, options) {
1698
1747
  // Update manifest
1699
1748
  manifest.skills.version = latestVersion;
1700
1749
  manifest.skills.installations = skillsInstallations;
1750
+ manifest.skills.names = mergeInstalledNames(manifest.skills.names, result);
1701
1751
 
1702
1752
  // Update skill hashes for integrity tracking
1703
1753
  if (result.allFileHashes) {
@@ -1804,6 +1854,7 @@ async function updateCommandsOnly(projectPath, manifest, options) {
1804
1854
  }
1805
1855
  return inst;
1806
1856
  });
1857
+ manifest.commands.names = mergeInstalledNames(manifest.commands.names, result);
1807
1858
 
1808
1859
  // Update command hashes for integrity tracking
1809
1860
  if (result.allFileHashes) {
@@ -147,11 +147,22 @@ export const AI_AGENT_PATHS = {
147
147
  'codex': {
148
148
  name: 'OpenAI Codex',
149
149
  tier: 'partial',
150
+ // Verified against the official docs 2026-07-23 (developers.openai.com/codex/skills):
151
+ // Codex discovers skills in $CWD/.agents/skills, parent directories, $REPO_ROOT/.agents/skills
152
+ // and $HOME/.agents/skills. Note the plural `.agents/`.
153
+ //
154
+ // These were previously '.codex/skills/' and '~/.codex/skills', which Codex never reads —
155
+ // a directory UDS invented. Skills installed there were invisible, which is why a
156
+ // behavioural probe against Codex failed while Codex itself was behaving correctly
157
+ // (see integrations/verification/codex/2026-07-23.md).
150
158
  skills: {
151
- project: '.codex/skills/',
152
- user: join(homedir(), '.codex', 'skills')
159
+ project: '.agents/skills/',
160
+ user: join(homedir(), '.agents', 'skills')
153
161
  },
154
162
  commands: null, // Uses system commands
163
+ // NOT verified. The official skills docs describe `agents/openai.yaml` as a file *inside*
164
+ // a skill directory, not a separate top-level agents location. Left as-is rather than
165
+ // guessed at: an unverified path fails silently, which is the failure mode being fixed above.
155
166
  agents: {
156
167
  project: '.codex/agents/',
157
168
  user: join(homedir(), '.codex', 'agents')
@@ -241,16 +252,27 @@ export const AI_AGENT_PATHS = {
241
252
  'antigravity': {
242
253
  name: 'Google Antigravity',
243
254
  tier: 'minimal',
244
- skills: {
245
- project: '.agent/skills/',
246
- user: join(homedir(), '.gemini', 'antigravity', 'skills')
247
- },
255
+ // Skills install path is UNVERIFIED against a real Antigravity CLI, so it is null:
256
+ // `supportsSkills && skills` is the install guard everywhere (init.js, init-flow.js,
257
+ // update.js, config.js), and a null `skills` makes it decline rather than write to a
258
+ // path the tool may never read.
259
+ //
260
+ // Two candidates conflict, and neither has been tested:
261
+ // a) ~/.gemini/antigravity-cli/plugins/<name>/skills/ -- official plugin docs
262
+ // b) .agent/skills/ + ~/.gemini/antigravity/skills -- UDS's own 2026-02 spec,
263
+ // written while Gemini CLI was still the product; Antigravity replaced it on
264
+ // 2026-06-18, so (b) inherits an assumption that may no longer hold.
265
+ //
266
+ // Installing to the wrong path fails SILENTLY -- the user sees a successful init and
267
+ // an assistant that never picks the skills up. Declining is the safer default until
268
+ // one candidate is confirmed. Tracked as XSPEC-355 OQ6.
269
+ skills: null,
248
270
  commands: null,
249
271
  agents: null,
250
272
  workflows: null,
251
273
  supportsMarketplace: false,
252
274
  fallbackSkillsPath: null,
253
- supportsSkills: true, // Skills since Nov 2025
275
+ supportsSkills: true, // The tool does support skills; only our path for it is unverified.
254
276
  supportsTask: false,
255
277
  supportsAgents: false
256
278
  }
@@ -313,6 +335,24 @@ export function getCommandsDirForAgent(agent, level = 'project', projectPath = n
313
335
  return null;
314
336
  }
315
337
 
338
+ /**
339
+ * Get the on-disk file extension used for an agent's slash commands.
340
+ *
341
+ * The command *name* (`commit`) and the installed *file* (`commit.toml` for
342
+ * Gemini CLI, `commit.md` for everyone else) are not the same string. Any code
343
+ * that maps one to the other must go through here — a hard-coded `.md` strip
344
+ * silently drops every Gemini command on the floor, and because the mismatch
345
+ * produces a well-formed name that simply never matches, nothing errors.
346
+ * (XSPEC-343 R2: 30 of machine-setup's 86 proposed deletions were exactly this.)
347
+ *
348
+ * @param {string} agent - Agent identifier
349
+ * @returns {string} Extension including the leading dot
350
+ */
351
+ export function getCommandFileExtension(agent) {
352
+ const config = AI_AGENT_PATHS[agent];
353
+ return config?.commandFormat === 'toml' ? '.toml' : '.md';
354
+ }
355
+
316
356
  /**
317
357
  * Get all agents that support skills installation
318
358
  * @returns {string[]} Array of agent identifiers
@@ -523,4 +523,64 @@ export default {
523
523
  doesToolSupport,
524
524
  getToolFormat,
525
525
  getToolFileName
526
- };
526
+ };
527
+
528
+ /**
529
+ * `manifest.integrations` has been written in two shapes by two code paths:
530
+ * tool keys (`claude-code`) and file paths (`CLAUDE.md`, or even absolute).
531
+ * Three consumers read it with two different assumptions —
532
+ * desired-state-calculator expects tool keys, integration-uninstaller expects
533
+ * file names — so normalising to one shape fixes one and breaks the other.
534
+ *
535
+ * These two resolvers let every consumer accept either shape. Measured
536
+ * 2026-07-30: 20 of 21 adopter repos stored file names. (XSPEC-343 R1)
537
+ */
538
+
539
+ /** Entry (tool key OR file path) → tool key, or null. */
540
+ export function resolveToolKey(entry) {
541
+ if (typeof entry !== 'string' || entry.length === 0) return null;
542
+ if (SUPPORTED_AI_TOOLS[entry]) return entry;
543
+ const norm = entry.replace(/\\/g, '/');
544
+ for (const [key, cfg] of Object.entries(SUPPORTED_AI_TOOLS)) {
545
+ if (!cfg?.file) continue;
546
+ // Exact or path-suffix only. A bare basename match would let `MY-CLAUDE.md`
547
+ // or `CLAUDE.md.bak` pass as the managed integration file.
548
+ if (norm === cfg.file || norm.endsWith(`/${cfg.file}`)) return key;
549
+ }
550
+ return null;
551
+ }
552
+
553
+ /** Entry (tool key OR file path) → repo-relative integration file, or null. */
554
+ export function resolveIntegrationFile(entry) {
555
+ const key = resolveToolKey(entry);
556
+ return key ? SUPPORTED_AI_TOOLS[key].file : null;
557
+ }
558
+
559
+ /**
560
+ * How the flat keys in `manifest.options` bind to registry option categories.
561
+ *
562
+ * The manifest stores selections flat — `{ workflow: 'github-flow',
563
+ * test_levels: [...] }` — while the registry nests them under a standard id and
564
+ * a category key, and the two names are not always the same word (`test_levels`
565
+ * vs `test_level`). The installer knew this mapping inline; the reconciler's
566
+ * desired-state calculator instead iterated `manifest.options` as if it were
567
+ * keyed by *standard id*, looked up a standard called `workflow`, found nothing,
568
+ * and skipped. It produced an empty desired option set for every adopter repo,
569
+ * so every installed option file diffed as "no longer in desired state" —
570
+ * including the ones the manifest explicitly selected. (XSPEC-343 R2)
571
+ *
572
+ * Keys with no entry here (`display_language`, `release_mode`, `coverage_model`)
573
+ * are behaviour settings that install no file.
574
+ */
575
+ export const MANIFEST_OPTION_BINDINGS = [
576
+ { manifestKeys: ['workflow'], standardId: 'git-workflow', categoryKey: 'workflow' },
577
+ { manifestKeys: ['merge_strategy'], standardId: 'git-workflow', categoryKey: 'merge_strategy' },
578
+ { manifestKeys: ['output_language', 'commit_language'], standardId: 'commit-message', categoryKey: 'output_language' },
579
+ { manifestKeys: ['test_levels'], standardId: 'testing', categoryKey: 'test_level' }
580
+ ];
581
+
582
+ /**
583
+ * Option files are installed flat, not nested by standard/category.
584
+ * (`installers/standards-installer.js` copies into `.standards/options`.)
585
+ */
586
+ export const OPTIONS_INSTALL_DIR = '.standards/options';