universal-dev-standards 6.1.0 → 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 +46 -4
  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 +47 -4
  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 +77 -20
  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 +12 -6
  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
@@ -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';
@@ -389,6 +389,18 @@ function migrateToV340(manifest) {
389
389
  };
390
390
  }
391
391
 
392
+ // 這裡曾有一個 v3.5.0 遷移,把 `integrations` 正規化為工具鍵。**已撤回。**
393
+ //
394
+ // 撤回的理由,是窮舉讀取端之後才看見的:這個欄位有**兩個陣營**,而且大致均衡。
395
+ // 期待檔名 :hasher.js({source:i,target:i})、check.js ×6(join(projectPath, int)、
396
+ // filter(i => i !== relativePath))
397
+ // 期待工具鍵:desired-state-calculator、manifest-migrator、update.js:856(getToolFilePath)
398
+ // 把資料正規化成任何一種,就是修好一邊、弄壞另一邊。
399
+ // 全套 3,240 個測試在正規化之後仍然全綠——那只代表**檔名陣營那幾條路徑沒有覆蓋**,不是安全。
400
+ //
401
+ // 正解是不動資料,讓每個讀取端經 `resolveToolKey` / `resolveIntegrationFile` 容忍兩種形狀。
402
+ // 真要正規化,得排在**所有**讀取端都容錯之後,作為獨立一步。(XSPEC-343 R1)
403
+
392
404
  /**
393
405
  * Convert a standards array from legacy path format to registry ID format.
394
406
  * Entries that already look like IDs (no "/" or ".") are kept as-is.
@@ -601,4 +613,48 @@ export function areSkillsInstalled(manifest) {
601
613
  */
602
614
  export function areCommandsInstalled(manifest) {
603
615
  return manifest.commands?.installed || false;
616
+ }
617
+
618
+ /**
619
+ * Placeholder stored in `skills.names` when skills come from the Claude Code
620
+ * plugin marketplace rather than being copied into the project.
621
+ */
622
+ export const MARKETPLACE_NAMES_SENTINEL = 'all-via-plugin';
623
+
624
+ /**
625
+ * Merge the names actually installed by a run into a manifest name list.
626
+ *
627
+ * `skills.names` / `commands.names` used to be written by `init` and by no other
628
+ * code path, so they froze on the day a project was set up: machine-setup's said
629
+ * 32 skills across five UDS upgrades while the shipped set grew to 55. Nothing
630
+ * errored — a name list that is merely *incomplete* reads exactly like a complete
631
+ * one, and the reconciler read the gap as "these 40 are no longer wanted".
632
+ * (XSPEC-343 R1/R2)
633
+ *
634
+ * Every code path that installs skills or commands must call this. It lives here
635
+ * rather than in a command module because there are 18 such call sites across
636
+ * update.js and config.js, and a private copy per module is how the writers drift
637
+ * apart again.
638
+ *
639
+ * It only ever adds. A name recorded by an older UDS version that no longer ships
640
+ * (machine-setup still lists `methodology-system`, plus five non-skill directories
641
+ * an old deny-list bug misfiled as skills) stays in the list. Pruning would need
642
+ * to know the name is absent for *every* agent, and over-reporting is harmless
643
+ * now that the reconciler derives desired state from the shipped set instead.
644
+ *
645
+ * @param {string[]} existing - Current manifest list
646
+ * @param {Object} installResult - Result from installSkills/CommandsToMultipleAgents
647
+ * @returns {string[]} Sorted union of existing and newly installed names
648
+ */
649
+ export function mergeInstalledNames(existing, installResult) {
650
+ const merged = new Set(existing || []);
651
+ // Marketplace installs record a sentinel instead of real names; leave it alone.
652
+ if (merged.has(MARKETPLACE_NAMES_SENTINEL)) return [...merged];
653
+
654
+ for (const agentResult of installResult?.installations || []) {
655
+ for (const name of agentResult?.installed || []) {
656
+ merged.add(name);
657
+ }
658
+ }
659
+ return [...merged].sort();
604
660
  }
@@ -4,7 +4,7 @@
4
4
  * 解析 .flow.yaml 內容為 Flow 物件,包含 schema 驗證和預設值填充。
5
5
  */
6
6
 
7
- import yaml from 'js-yaml';
7
+ import * as yaml from 'js-yaml';
8
8
 
9
9
  const DEFAULT_CONFIG = {
10
10
  enforcement: 'suggest',
@@ -4,7 +4,7 @@
4
4
  * 載入和解析 .gate.yaml 閘門定義。
5
5
  */
6
6
 
7
- import yaml from 'js-yaml';
7
+ import * as yaml from 'js-yaml';
8
8
 
9
9
  /**
10
10
  * 從 YAML 字串載入 gate 定義。
@@ -742,8 +742,8 @@ export const messages = {
742
742
  adoptionStatus: 'Adoption Status:',
743
743
  installed: 'Installed',
744
744
  // Update info
745
- updateAvailable: '⚠ Update available: {current} → {latest}',
746
- runUpdate: 'Run `uds update` to update.',
745
+ updateAvailable: '⚠ Your installed standards are behind the latest release: {current} → {latest}',
746
+ runUpdate: 'Update in two steps — (1) npm update -g universal-dev-standards (2) uds update',
747
747
  // File integrity
748
748
  fileIntegrity: 'File Integrity:',
749
749
  hashNotAvailable: 'ℹ Hash information not available (legacy manifest).',
@@ -831,6 +831,8 @@ export const messages = {
831
831
  fileNotFound: 'File not found',
832
832
  couldNotRead: 'Could not read file',
833
833
  standardsIndexPresent: 'Standards index present',
834
+ standardsIndexCount: 'Index declares {count} standards (matches manifest)',
835
+ standardsIndexCountMismatch: 'Index declares {declared} standards but the manifest has {actual}',
834
836
  standardsReferenced: '{count}/{total} standards referenced',
835
837
  missingStandardsList: 'Missing: {list}',
836
838
  usingMinimalMode: 'Using minimal mode (no standards index)',
@@ -1974,8 +1976,8 @@ export const messages = {
1974
1976
  adoptionStatus: '採用狀態:',
1975
1977
  installed: '已安裝',
1976
1978
  // Update info
1977
- updateAvailable: '⚠ 有可用更新:{current} → {latest}',
1978
- runUpdate: '執行 `uds update` 進行更新。',
1979
+ updateAvailable: '⚠ 你安裝的標準落後最新版:{current} → {latest}',
1980
+ runUpdate: '兩步驟更新 —(1)npm update -g universal-dev-standards (2)uds update',
1979
1981
  // File integrity
1980
1982
  fileIntegrity: '檔案完整性:',
1981
1983
  hashNotAvailable: 'ℹ 無法取得 hash 資訊(舊版 manifest)。',
@@ -2063,6 +2065,8 @@ export const messages = {
2063
2065
  fileNotFound: '找不到檔案',
2064
2066
  couldNotRead: '無法讀取檔案',
2065
2067
  standardsIndexPresent: '標準索引存在',
2068
+ standardsIndexCount: '索引宣告 {count} 條標準(與 manifest 一致)',
2069
+ standardsIndexCountMismatch: '索引宣告 {declared} 條標準,manifest 實際有 {actual} 條',
2066
2070
  standardsReferenced: '{count}/{total} 項標準已參考',
2067
2071
  missingStandardsList: '缺少:{list}',
2068
2072
  usingMinimalMode: '使用最小模式(無標準索引)',
@@ -3221,8 +3225,8 @@ export const messages = {
3221
3225
  adoptionStatus: '采用状态:',
3222
3226
  installed: '已安装',
3223
3227
  // Update info
3224
- updateAvailable: '⚠ 有可用更新:{current} → {latest}',
3225
- runUpdate: '执行 `uds update` 进行更新。',
3228
+ updateAvailable: '⚠ 你安装的标准落后最新版:{current} → {latest}',
3229
+ runUpdate: '两步更新 —(1)npm update -g universal-dev-standards (2)uds update',
3226
3230
  // File integrity
3227
3231
  fileIntegrity: '文件完整性:',
3228
3232
  hashNotAvailable: 'ℹ 哈希信息不可用(旧版 manifest)。',
@@ -3310,6 +3314,8 @@ export const messages = {
3310
3314
  fileNotFound: '文件未找到',
3311
3315
  couldNotRead: '无法读取文件',
3312
3316
  standardsIndexPresent: '标准索引存在',
3317
+ standardsIndexCount: '索引声明 {count} 条标准(与 manifest 一致)',
3318
+ standardsIndexCountMismatch: '索引声明 {declared} 条标准,manifest 实际有 {actual} 条',
3313
3319
  standardsReferenced: '已引用 {count}/{total} 项标准',
3314
3320
  missingStandardsList: '缺失:{list}',
3315
3321
  usingMinimalMode: '使用最小模式(无标准索引)',
@@ -10,6 +10,7 @@ import {
10
10
  import { copyStandard } from '../utils/copier.js';
11
11
  import { t } from '../i18n/messages.js';
12
12
  import { computeFileHash } from '../utils/hasher.js';
13
+ import { MANIFEST_OPTION_BINDINGS } from '../core/constants.js';
13
14
 
14
15
  // Extension file mappings
15
16
  export const EXTENSION_MAPPINGS = {
@@ -89,28 +90,17 @@ export async function installStandards(config, projectPath) {
89
90
  }
90
91
  }
91
92
 
92
- // Copy selected options for this standard
93
+ // Copy selected options for this standard.
94
+ // The manifest-key → (standard, category) mapping lives in MANIFEST_OPTION_BINDINGS
95
+ // so the reconciler computes the same desired set this loop installs. It used to
96
+ // be spelled out inline here and guessed at incorrectly there. (XSPEC-343 R2)
93
97
  if (std.options) {
94
98
  for (const targetFormat of formatsToUse) {
95
- // Git workflow options
96
- if (std.id === 'git-workflow') {
97
- if (config.standardOptions.workflow) {
98
- const copied = await copyOptionFiles(std, 'workflow', config.standardOptions.workflow, targetFormat);
99
- results.standards.push(...copied);
100
- }
101
- if (config.standardOptions.merge_strategy) {
102
- const copied = await copyOptionFiles(std, 'merge_strategy', config.standardOptions.merge_strategy, targetFormat);
103
- results.standards.push(...copied);
104
- }
105
- }
106
- // Commit message options
107
- if (std.id === 'commit-message' && (config.standardOptions.output_language || config.standardOptions.commit_language)) {
108
- const copied = await copyOptionFiles(std, 'output_language', config.standardOptions.output_language || config.standardOptions.commit_language, targetFormat);
109
- results.standards.push(...copied);
110
- }
111
- // Testing options
112
- if (std.id === 'testing' && config.standardOptions.test_levels) {
113
- const copied = await copyOptionFiles(std, 'test_level', config.standardOptions.test_levels, targetFormat);
99
+ for (const binding of MANIFEST_OPTION_BINDINGS) {
100
+ if (binding.standardId !== std.id) continue;
101
+ const key = binding.manifestKeys.find(k => config.standardOptions?.[k] != null);
102
+ if (!key) continue;
103
+ const copied = await copyOptionFiles(std, binding.categoryKey, config.standardOptions[key], targetFormat);
114
104
  results.standards.push(...copied);
115
105
  }
116
106
  }
@@ -12,7 +12,16 @@ import { join, relative } from 'path';
12
12
  import { readManifest } from '../core/manifest.js';
13
13
  import { computeFileHash, computeIntegrationBlockHash } from '../utils/hasher.js';
14
14
  import { SUPPORTED_AI_TOOLS, UDS_MARKERS } from '../core/constants.js';
15
- import { getSkillsDirForAgent, getCommandsDirForAgent } from '../config/ai-agent-paths.js';
15
+ import { getSkillsDirForAgent, getCommandsDirForAgent, getCommandFileExtension } from '../config/ai-agent-paths.js';
16
+ import { getSkillsSourceEntryNames, getAvailableCommandNames } from '../utils/skills-installer.js';
17
+
18
+ /**
19
+ * Files UDS writes into a skills/commands directory for its own bookkeeping.
20
+ * They are not skills or commands and must never be diffed as such — the
21
+ * scanner used to report `.manifest.json` as a stray command, which made the
22
+ * reconciler propose deleting its own installation record. (XSPEC-343 R2)
23
+ */
24
+ const INSTALLER_BOOKKEEPING_FILES = new Set(['.manifest.json']);
16
25
 
17
26
  /**
18
27
  * Scan the actual state of UDS artifacts on disk.
@@ -241,6 +250,57 @@ function scanIntegrations(state, projectPath) {
241
250
  }
242
251
  }
243
252
 
253
+ /**
254
+ * Read a directory, tolerating the read itself failing (race, permissions).
255
+ *
256
+ * Deliberately narrow: only `readdirSync` is wrapped. The scan loops used to sit
257
+ * inside the same `catch {}`, so any programming error in the loop body became
258
+ * "this agent has no skills/commands" — an empty actual state indistinguishable
259
+ * from a genuinely empty directory, which downstream reads as "nothing to delete,
260
+ * install everything".
261
+ */
262
+ function readDirEntries(dirPath) {
263
+ try {
264
+ return readdirSync(dirPath, { withFileTypes: true });
265
+ } catch {
266
+ return [];
267
+ }
268
+ }
269
+
270
+ let _sourceEntries = null;
271
+ let _shippedCommands = null;
272
+
273
+ /** Directory names UDS ships under `skills/`, cached per process. */
274
+ function sourceEntryNames() {
275
+ if (_sourceEntries === null) _sourceEntries = getSkillsSourceEntryNames();
276
+ return _sourceEntries;
277
+ }
278
+
279
+ /** Command names UDS ships, cached per process. */
280
+ function shippedCommandNames() {
281
+ if (_shippedCommands === null) _shippedCommands = new Set(getAvailableCommandNames());
282
+ return _shippedCommands;
283
+ }
284
+
285
+ /**
286
+ * Did UDS put this skill directory here?
287
+ *
288
+ * Two positive signals, either is enough:
289
+ * 1. a directory of the same name exists in UDS's own `skills/` tree — this
290
+ * also covers the non-skill siblings (`_shared`, `agents`, …) that an older
291
+ * CLI copied in by mistake, so they stay cleanable;
292
+ * 2. `manifest.skillHashes` records a file under it — authoritative when
293
+ * present, though in practice it is sparse (dev-platform: 2 entries for 78
294
+ * installed skills), which is exactly why signal 1 has to carry the weight.
295
+ *
296
+ * Anything else is the adopter's own, and is warned about rather than deleted.
297
+ */
298
+ function isUdsProvenance(skillName, manifest, agent, level) {
299
+ if (sourceEntryNames().has(skillName)) return true;
300
+ const prefix = `${agent}/${level}/${skillName}/`;
301
+ return Object.keys(manifest?.skillHashes || {}).some((k) => k.startsWith(prefix));
302
+ }
303
+
244
304
  /**
245
305
  * Scan skill installations.
246
306
  */
@@ -252,27 +312,32 @@ function scanSkills(state, projectPath, manifest) {
252
312
  const skillsDir = getSkillsDirForAgent(agent, level, projectPath);
253
313
  if (!skillsDir || !existsSync(skillsDir)) continue;
254
314
 
255
- try {
256
- const entries = readdirSync(skillsDir, { withFileTypes: true });
257
- for (const entry of entries) {
258
- if (!entry.isDirectory()) continue;
259
- const skillName = entry.name;
260
- const key = `skill:${agent}:${level}:${skillName}`;
261
- const relPath = level === 'project'
262
- ? getRelativePath(projectPath, join(skillsDir, skillName))
263
- : join(skillsDir, skillName);
264
-
265
- state.skills.set(key, {
266
- relativePath: relPath,
267
- hash: null, // Directory-level hashes tracked in manifest.skillHashes
268
- size: null,
269
- category: 'skill',
270
- sourcePath: null,
271
- metadata: { agent, level, skillName, scanned: true }
272
- });
273
- }
274
- } catch {
275
- // Ignore read errors
315
+ for (const entry of readDirEntries(skillsDir)) {
316
+ if (!entry.isDirectory()) continue;
317
+ const skillName = entry.name;
318
+ const key = `skill:${agent}:${level}:${skillName}`;
319
+ const relPath = level === 'project'
320
+ ? getRelativePath(projectPath, join(skillsDir, skillName))
321
+ : join(skillsDir, skillName);
322
+
323
+ state.skills.set(key, {
324
+ relativePath: relPath,
325
+ hash: null, // Directory-level hashes tracked in manifest.skillHashes
326
+ size: null,
327
+ category: 'skill',
328
+ sourcePath: null,
329
+ metadata: {
330
+ agent,
331
+ level,
332
+ skillName,
333
+ scanned: true,
334
+ // Whether UDS is the thing that put this directory here. Everything in
335
+ // the skills folder used to be assumed UDS-managed, so a plan for a repo
336
+ // with hand-written skills proposed deleting them: dev-platform's would
337
+ // have removed fourteen. (XSPEC-343 R2)
338
+ udsManaged: isUdsProvenance(skillName, manifest, agent, level)
339
+ }
340
+ });
276
341
  }
277
342
  }
278
343
  }
@@ -288,27 +353,37 @@ function scanCommands(state, projectPath, manifest) {
288
353
  const cmdsDir = getCommandsDirForAgent(agent, level, projectPath);
289
354
  if (!cmdsDir || !existsSync(cmdsDir)) continue;
290
355
 
291
- try {
292
- const entries = readdirSync(cmdsDir, { withFileTypes: true });
293
- for (const entry of entries) {
294
- // Commands can be files (.md) or directories
295
- const cmdName = entry.name.replace(/\.md$/, '');
296
- const key = `command:${agent}:${level}:${cmdName}`;
297
- const relPath = level === 'project'
298
- ? getRelativePath(projectPath, join(cmdsDir, entry.name))
299
- : join(cmdsDir, entry.name);
300
-
301
- state.commands.set(key, {
302
- relativePath: relPath,
303
- hash: null,
304
- size: null,
305
- category: 'command',
306
- sourcePath: null,
307
- metadata: { agent, level, commandName: cmdName, scanned: true }
308
- });
309
- }
310
- } catch {
311
- // Ignore read errors
356
+ const ext = getCommandFileExtension(agent);
357
+
358
+ for (const entry of readDirEntries(cmdsDir)) {
359
+ if (INSTALLER_BOOKKEEPING_FILES.has(entry.name)) continue;
360
+
361
+ // Commands are files named `<name><ext>` (or, for some agents, directories).
362
+ // Stripping a hard-coded `.md` left every Gemini command as `commit.toml`,
363
+ // which never matches the desired key `commit`, so all of them were
364
+ // proposed for deletion.
365
+ const cmdName = entry.name.endsWith(ext)
366
+ ? entry.name.slice(0, -ext.length)
367
+ : entry.name;
368
+ const key = `command:${agent}:${level}:${cmdName}`;
369
+ const relPath = level === 'project'
370
+ ? getRelativePath(projectPath, join(cmdsDir, entry.name))
371
+ : join(cmdsDir, entry.name);
372
+
373
+ state.commands.set(key, {
374
+ relativePath: relPath,
375
+ hash: null,
376
+ size: null,
377
+ category: 'command',
378
+ sourcePath: null,
379
+ metadata: {
380
+ agent,
381
+ level,
382
+ commandName: cmdName,
383
+ scanned: true,
384
+ udsManaged: shippedCommandNames().has(cmdName)
385
+ }
386
+ });
312
387
  }
313
388
  }
314
389
  }