universal-dev-standards 6.10.0 → 6.12.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 (39) hide show
  1. package/bin/uds.js +2 -0
  2. package/bundled/ai/standards/open-work-tracking.ai.yaml +216 -0
  3. package/bundled/core/open-work-tracking.md +333 -0
  4. package/bundled/locales/zh-CN/CHANGELOG.md +29 -3
  5. package/bundled/locales/zh-CN/CLAUDE.md +1 -1
  6. package/bundled/locales/zh-CN/README.md +2 -2
  7. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -1
  9. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +52 -5
  10. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +6 -3
  11. package/bundled/locales/zh-TW/CHANGELOG.md +29 -3
  12. package/bundled/locales/zh-TW/CLAUDE.md +1 -1
  13. package/bundled/locales/zh-TW/README.md +2 -2
  14. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  15. package/bundled/locales/zh-TW/core/open-work-tracking.md +255 -0
  16. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -1
  17. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +52 -5
  18. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +6 -3
  19. package/bundled/locales/zh-TW/integrations/claude-code/README.md +14 -5
  20. package/package.json +1 -1
  21. package/src/commands/check.js +253 -21
  22. package/src/commands/config.js +15 -9
  23. package/src/commands/init.js +24 -3
  24. package/src/commands/update.js +311 -47
  25. package/src/core/manifest.js +39 -1
  26. package/src/flows/init-flow.js +9 -1
  27. package/src/generators/layered-claudemd.js +13 -4
  28. package/src/i18n/messages.js +3 -3
  29. package/src/installers/integration-installer.js +13 -6
  30. package/src/installers/manifest-installer.js +4 -0
  31. package/src/reconciler/actual-state-scanner.js +29 -2
  32. package/src/reconciler/desired-state-calculator.js +51 -2
  33. package/src/reconciler/diff-engine.js +19 -3
  34. package/src/reconciler/plan-executor.js +17 -16
  35. package/src/utils/hasher.js +61 -5
  36. package/src/utils/integration-generator.js +239 -28
  37. package/src/utils/marker-locator.js +140 -0
  38. package/src/utils/reference-sync.js +53 -1
  39. package/standards-registry.json +19 -7
@@ -26,7 +26,7 @@ import { displayLanguageToLocale } from '../utils/locale.js';
26
26
  import { generateReleaseConfig, RELEASE_MODE_LABELS } from '../utils/release-config.js';
27
27
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
28
28
  import { readInstallYaml } from '../utils/config-manager.js';
29
- import { getToolFilePath } from '../utils/integration-generator.js';
29
+ import { resolveIntegrationTargetFile } from '../utils/integration-generator.js';
30
30
  import { withFileTransaction } from '../utils/transaction.js';
31
31
 
32
32
  /**
@@ -107,7 +107,10 @@ export async function initCommand(options) {
107
107
  // `uds init` re-installs them idempotently.
108
108
  const ownedPaths = [join(projectPath, '.standards')];
109
109
  for (const tool of (config.integrations || config.aiTools || [])) {
110
- const file = getToolFilePath(tool);
110
+ // XSPEC-418 R2: honors --claude-target so a local-target install's owned
111
+ // path is CLAUDE.local.md, not CLAUDE.md — rollback must tear down the
112
+ // file actually written, not the tool's default.
113
+ const file = resolveIntegrationTargetFile(tool, { integrationTargets: config.integrationTargets });
111
114
  if (file) ownedPaths.push(join(projectPath, file));
112
115
  }
113
116
  if (config.generateAgentsMd) ownedPaths.push(join(projectPath, 'AGENTS.md'));
@@ -658,6 +661,23 @@ function buildNonInteractiveConfig(options, detected, projectPath) {
658
661
  ? false // codex/opencode handles AGENTS.md, skip universal output
659
662
  : (options.agentsMd !== undefined ? !!options.agentsMd : true);
660
663
 
664
+ // XSPEC-418 R2: --claude-target <project|local>, default 'project'. An
665
+ // unrecognized value is never substituted quietly (same policy as
666
+ // --content-mode above) — it falls back to 'project' with a warning rather
667
+ // than silently writing into CLAUDE.local.md or vice versa.
668
+ let claudeTargetFlag = options.claudeTarget || 'project';
669
+ if (claudeTargetFlag !== 'project' && claudeTargetFlag !== 'local') {
670
+ console.log(chalk.yellow(
671
+ `⚠ Unknown --claude-target '${claudeTargetFlag}'; using 'project'. Supported: project, local.`
672
+ ));
673
+ claudeTargetFlag = 'project';
674
+ }
675
+ // Only ever set when 'local' — 'project' or unspecified leaves the manifest
676
+ // shape completely unchanged (XSPEC-418 AC-5).
677
+ const integrationTargets = claudeTargetFlag === 'local'
678
+ ? { 'claude-code': 'CLAUDE.local.md' }
679
+ : undefined;
680
+
661
681
  return {
662
682
  languages: options.lang ? [options.lang] : Object.keys(detected.languages).filter(k => detected.languages[k]),
663
683
  frameworks: options.framework ? [options.framework] : Object.keys(detected.frameworks).filter(k => detected.frameworks[k]),
@@ -678,7 +698,8 @@ function buildNonInteractiveConfig(options, detected, projectPath) {
678
698
  generateAgentsMd,
679
699
  releaseMode: options.releaseMode || 'ci-cd',
680
700
  withHooks: !!options.withHooks,
681
- contentLayout: options.contentLayout || 'flat'
701
+ contentLayout: options.contentLayout || 'flat',
702
+ integrationTargets
682
703
  };
683
704
  }
684
705
 
@@ -6,10 +6,11 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
6
6
  import { join, basename, dirname, relative } from 'path';
7
7
  import { readManifest, writeManifest, copyStandard, isInitialized, getRepoRoot } from '../utils/copier.js';
8
8
  import { getRepositoryInfo, getAllStandards, getShippableFilenames, getStandardSource } from '../utils/registry.js';
9
- import { computeFileHash, planStandardsRemovals, refreshIntegrationBlockHashes } from '../utils/hasher.js';
9
+ import { computeFileHash, planStandardsRemovals, refreshIntegrationBlockHashes, pruneIntegrationFileHashes } from '../utils/hasher.js';
10
+ import { AmbiguousMarkerError } from '../utils/marker-locator.js';
10
11
  import {
11
12
  writeIntegrationFile,
12
- getToolFilePath,
13
+ resolveIntegrationTargetFile,
13
14
  writeAgentsMdSummary,
14
15
  resolveContentModeForTool,
15
16
  generateIntegrationContent,
@@ -18,6 +19,7 @@ import {
18
19
  } from '../utils/integration-generator.js';
19
20
  import {
20
21
  calculateCategoriesFromStandards,
22
+ repairIntegrationConfigCategories,
21
23
  arraysEqual,
22
24
  getToolFromPath
23
25
  } from '../utils/reference-sync.js';
@@ -56,13 +58,14 @@ import {
56
58
  } from '../reconciler/index.js';
57
59
  import { restoreSingleFile } from './check.js';
58
60
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
59
- import { resolveIntegrationFile, SUPPORTED_AI_TOOLS } from '../core/constants.js';
61
+ import { resolveIntegrationFile, SUPPORTED_AI_TOOLS, getToolFormat } from '../core/constants.js';
60
62
  import {
61
63
  mergeInstalledNames,
62
64
  recordFileProvenance,
63
65
  forgetFileProvenance,
64
66
  establishProvenance,
65
- isProvenanceEstablished
67
+ isProvenanceEstablished,
68
+ bumpManifestVersion
66
69
  } from '../core/manifest.js';
67
70
 
68
71
  /**
@@ -438,6 +441,15 @@ export async function updateCommand(options) {
438
441
  console.log(chalk.bold(msg.title));
439
442
  console.log(chalk.gray('─'.repeat(50)));
440
443
 
444
+ // Handle --claude-target option (XSPEC-418 R4): switch an EXISTING
445
+ // installation's claude-code integration target between CLAUDE.md and
446
+ // CLAUDE.local.md, without a full reinstall. Standalone, like --sync-refs
447
+ // below — it does not compose with other update modes/scopes.
448
+ if (options.claudeTarget) {
449
+ await switchClaudeTarget(projectPath, manifest, options.claudeTarget, options);
450
+ return;
451
+ }
452
+
441
453
  // Handle --sync-refs option.
442
454
  // `--plan` is honoured here too. This branch is above the mode dispatch
443
455
  // because sync-refs is its own operation rather than a scope of the
@@ -489,7 +501,7 @@ export async function updateCommand(options) {
489
501
  // Handle --plan option (DSR dry-run). Nothing below this line writes.
490
502
  if (options.plan) {
491
503
  if (!scopedToSkills && !scopedToCommands) {
492
- await handlePlan(projectPath, options);
504
+ await handlePlan(projectPath, options, manifest);
493
505
  }
494
506
  if (scopedToSkills) await planSkills(projectPath, manifest, options);
495
507
  if (scopedToCommands) await planCommands(projectPath, manifest, options);
@@ -752,6 +764,16 @@ export async function updateCommand(options) {
752
764
 
753
765
  // Update integrations (unless --standards-only)
754
766
  if (!options.standardsOnly && manifest.integrations && manifest.integrations.length > 0) {
767
+ // XSPEC adopter-report Q1 follow-up: this block writes
768
+ // integrationBlockHashes but never touched manifest.integrationConfigs at
769
+ // all, so a manifest that picked up a broken (empty/unrecognized)
770
+ // categories array from an older buggy `--sync-refs` run stayed broken
771
+ // through every subsequent plain `uds update` — the only path that
772
+ // repaired it was `--sync-refs` itself. Self-heal corruption here too, not
773
+ // just there; an already-valid list is left alone (see
774
+ // repairIntegrationConfigCategories's docblock for why).
775
+ repairIntegrationConfigCategories(manifest);
776
+
755
777
  const intSpinner = createSpinner(msg.syncingIntegrations).start();
756
778
 
757
779
  // Build installed standards list
@@ -773,7 +795,9 @@ export async function updateCommand(options) {
773
795
  const aiTools = manifest.aiTools || [];
774
796
 
775
797
  for (const tool of aiTools) {
776
- const targetFile = getToolFilePath(tool);
798
+ // XSPEC-418 R3: honors manifest.integrationTargets — the tool's file may
799
+ // be CLAUDE.local.md, not the hardcoded default.
800
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
777
801
  if (generatedFiles.has(targetFile)) {
778
802
  continue; // Skip if already generated (AGENTS.md sharing)
779
803
  }
@@ -790,7 +814,9 @@ export async function updateCommand(options) {
790
814
  contentMode: resolved.contentMode,
791
815
  level: resolved.level,
792
816
  // Pass output_language for dynamic commit standards generation
793
- outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english'
817
+ outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english',
818
+ // XSPEC-418 R2/R3: so writeIntegrationFile resolves the actual target.
819
+ integrationTargets: manifest.integrationTargets
794
820
  };
795
821
 
796
822
  const result = writeIntegrationFile(tool, toolConfig, projectPath);
@@ -849,7 +875,11 @@ export async function updateCommand(options) {
849
875
  if (manifest.integrationBlockHashes) {
850
876
  const expectedFiles = new Set();
851
877
  for (const tool of (manifest.aiTools || [])) {
852
- const targetFile = getToolFilePath(tool);
878
+ // XSPEC-418 R3: an expected set built from the hardcoded default file
879
+ // pruned CLAUDE.local.md's own hash the moment a local-target install
880
+ // ran this cleanup — it looked orphaned because nothing here knew the
881
+ // real target had moved.
882
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
853
883
  if (targetFile) expectedFiles.add(targetFile);
854
884
  }
855
885
  // Universal AGENTS.md is tracked when generateAgentsMd is enabled.
@@ -880,8 +910,14 @@ export async function updateCommand(options) {
880
910
  if (!layeredResult.fallback) {
881
911
  console.log(chalk.green(` ✓ Layered CLAUDE.md updated (${layeredResult.generatedFiles.length} files)`));
882
912
  }
883
- } catch {
884
- // Silently skip if generator not available
913
+ } catch (error) {
914
+ // XSPEC adopter-report Q5: an ambiguous marker pair is a real problem
915
+ // the adopter needs to see — refuse that one write and say why,
916
+ // rather than folding it into "generator not available".
917
+ if (error instanceof AmbiguousMarkerError) {
918
+ console.log(chalk.red(` ✗ Layered CLAUDE.md not updated: ${error.message}`));
919
+ }
920
+ // Otherwise: silently skip if generator not available
885
921
  }
886
922
  }
887
923
 
@@ -951,14 +987,14 @@ export async function updateCommand(options) {
951
987
  }
952
988
  }
953
989
 
954
- // Update hashes for integrations
955
- for (const int of results.integrations) {
956
- const fullPath = join(projectPath, int);
957
- const hashInfo = computeFileHash(fullPath);
958
- if (hashInfo) {
959
- manifest.fileHashes[int] = { ...hashInfo, installedAt: now };
960
- }
961
- }
990
+ // XSPEC-418 R6: integration files (results.integrations — CLAUDE.md,
991
+ // CLAUDE.local.md, AGENTS.md, etc.) are tracked by their UDS block in
992
+ // integrationBlockHashes, not by whole-file hash here. A whole-file entry
993
+ // for one of these disagrees the moment the adopter edits anything outside
994
+ // the block — exactly the customization the marker-based update preserves —
995
+ // and made `uds check` report "modified" while its own block-integrity
996
+ // check said the block was intact. See pruneIntegrationFileHashes below for
997
+ // cleanup of any such entry an older CLI already wrote.
962
998
 
963
999
  // Record what UDS wrote this run, then decide what (if anything) may go.
964
1000
  // (XSPEC-384 R1/R2/R3)
@@ -1086,7 +1122,7 @@ export async function updateCommand(options) {
1086
1122
  // date. The manifest is still written so that hash/migration bookkeeping for
1087
1123
  // the files that DID succeed is persisted.
1088
1124
  const updateIncomplete = results.errors.length > 0;
1089
- manifest.version = '3.3.0';
1125
+ bumpManifestVersion(manifest);
1090
1126
  if (!updateIncomplete) {
1091
1127
  manifest.upstream.version = latestVersion;
1092
1128
  manifest.upstream.installed = new Date().toISOString().split('T')[0];
@@ -1119,8 +1155,9 @@ export async function updateCommand(options) {
1119
1155
  allTrackedFiles.push(join('.standards', fileName));
1120
1156
  }
1121
1157
  for (const intEntry of (manifest.integrations || [])) {
1122
- // 兩種形狀都要能解出路徑(XSPEC-343 R1)
1123
- const filePath = resolveIntegrationFile(intEntry) || getToolFilePath(intEntry);
1158
+ // 兩種形狀都要能解出路徑(XSPEC-343 R1),且要尊重 manifest.integrationTargets
1159
+ // 的目標覆寫(XSPEC-418 R2/R3)——resolveIntegrationTargetFile 已同時處理兩者。
1160
+ const filePath = resolveIntegrationTargetFile(intEntry, manifest);
1124
1161
  if (filePath) {
1125
1162
  allTrackedFiles.push(filePath);
1126
1163
  }
@@ -1641,6 +1678,140 @@ async function offerErrorExitGate(projectPath, options) {
1641
1678
  console.log();
1642
1679
  }
1643
1680
 
1681
+ /**
1682
+ * Switch the claude-code integration's target file between CLAUDE.md and
1683
+ * CLAUDE.local.md for an EXISTING installation, without a full reinstall.
1684
+ * // implements XSPEC-418 R4
1685
+ *
1686
+ * The scenario this exists for: an adopter installed with the default target
1687
+ * (CLAUDE.md), then either hand-moved the UDS block to CLAUDE.local.md (it now
1688
+ * exists there with markers already) or wants to move it there for the first
1689
+ * time. Either way `uds update --claude-target local` should leave exactly one
1690
+ * up-to-date block in the new target and none in the old one.
1691
+ *
1692
+ * @param {string} projectPath
1693
+ * @param {Object} manifest - Mutated and written to disk on success
1694
+ * @param {string} target - 'project' | 'local'
1695
+ * @param {Object} options - CLI options (unused today; kept for symmetry with
1696
+ * the other option handlers and so a future confirmation prompt has somewhere
1697
+ * to read --yes from)
1698
+ */
1699
+ async function switchClaudeTarget(projectPath, manifest, target, options) { // eslint-disable-line no-unused-vars
1700
+ if (target !== 'project' && target !== 'local') {
1701
+ console.log(chalk.red(` ✗ --claude-target must be "project" or "local", got "${target}"`));
1702
+ console.log();
1703
+ process.exitCode = 1;
1704
+ return;
1705
+ }
1706
+
1707
+ if (!(manifest.aiTools || []).includes('claude-code')) {
1708
+ console.log(chalk.yellow(' claude-code is not among the configured AI tools — nothing to switch.'));
1709
+ console.log();
1710
+ return;
1711
+ }
1712
+
1713
+ const oldFile = resolveIntegrationTargetFile('claude-code', manifest);
1714
+ const newFile = target === 'local' ? 'CLAUDE.local.md' : 'CLAUDE.md';
1715
+
1716
+ if (oldFile === newFile) {
1717
+ console.log(chalk.gray(` claude-code integration already targets ${newFile}. Nothing to do.`));
1718
+ console.log();
1719
+ return;
1720
+ }
1721
+
1722
+ console.log(chalk.cyan(` Switching claude-code integration target: ${oldFile} → ${newFile}`));
1723
+
1724
+ // 1. Remove the UDS block from the OLD file, preserving any user content —
1725
+ // the same rule uninstallIntegrations already applies. A file left 100%
1726
+ // UDS-generated once the block is gone is deleted outright.
1727
+ const oldPath = join(projectPath, oldFile);
1728
+ if (existsSync(oldPath)) {
1729
+ const format = getToolFormat('claude-code');
1730
+ const content = readFileSync(oldPath, 'utf-8');
1731
+ // XSPEC adopter-report Q5: an ambiguous marker pair means this write
1732
+ // must refuse rather than guess which block to move — report and leave
1733
+ // the old file untouched instead of switching targets on a bad guess.
1734
+ let parts;
1735
+ try {
1736
+ parts = extractMarkedContent(content, format);
1737
+ } catch (error) {
1738
+ if (error instanceof AmbiguousMarkerError) {
1739
+ console.log(chalk.red(` ✗ ${oldFile}: ${error.message}`));
1740
+ console.log(chalk.gray(` Not switching — fix the marker pair in ${oldFile} first.`));
1741
+ process.exitCode = 1;
1742
+ return;
1743
+ }
1744
+ throw error;
1745
+ }
1746
+ if (parts.content) {
1747
+ const userContent = (parts.before.trim() + parts.after.trim()).trim();
1748
+ if (userContent.length > 0) {
1749
+ const cleaned = (parts.before + parts.after).trim() + '\n';
1750
+ writeFileSync(oldPath, cleaned, 'utf-8');
1751
+ console.log(chalk.gray(` ${oldFile}: UDS block removed, your content kept`));
1752
+ } else {
1753
+ unlinkSync(oldPath);
1754
+ console.log(chalk.gray(` ${oldFile}: deleted (was 100% UDS-generated)`));
1755
+ }
1756
+ }
1757
+ // else: no UDS markers found in the old file (already moved by hand) —
1758
+ // nothing UDS-owned to remove.
1759
+ }
1760
+ if (manifest.integrationBlockHashes) delete manifest.integrationBlockHashes[oldFile];
1761
+ if (manifest.fileHashes) delete manifest.fileHashes[oldFile];
1762
+
1763
+ // 2. Point the manifest at the new target. Switching back to 'project'
1764
+ // removes the override entirely rather than writing it as 'CLAUDE.md', so a
1765
+ // round-tripped manifest is shaped exactly as if local had never been chosen
1766
+ // (XSPEC-418 AC-5).
1767
+ if (target === 'local') {
1768
+ manifest.integrationTargets = { ...(manifest.integrationTargets || {}), 'claude-code': 'CLAUDE.local.md' };
1769
+ } else if (manifest.integrationTargets) {
1770
+ delete manifest.integrationTargets['claude-code'];
1771
+ if (Object.keys(manifest.integrationTargets).length === 0) delete manifest.integrationTargets;
1772
+ }
1773
+
1774
+ // 3. Write (or update in place) the new target. writeIntegrationFile already
1775
+ // does a marker-based UPDATE when the file exists and has UDS markers — the
1776
+ // "hand-moved to CLAUDE.local.md already" arm of R4 — and only appends when
1777
+ // it does not, so this one call covers both AC-4 arms.
1778
+ const toolConfig = buildToolIntegrationConfig(manifest, 'claude-code');
1779
+ const result = writeIntegrationFile('claude-code', toolConfig, projectPath);
1780
+ if (!result.success) {
1781
+ console.log(chalk.red(` ✗ Failed to write ${newFile}: ${result.error}`));
1782
+ console.log();
1783
+ process.exitCode = 1;
1784
+ return;
1785
+ }
1786
+
1787
+ if (result.blockHashInfo) {
1788
+ if (!manifest.integrationBlockHashes) manifest.integrationBlockHashes = {};
1789
+ manifest.integrationBlockHashes[result.path] = {
1790
+ ...result.blockHashInfo,
1791
+ installedAt: new Date().toISOString()
1792
+ };
1793
+ }
1794
+ // XSPEC-418 R6: no whole-file fileHashes entry for the new target either —
1795
+ // only its UDS block is tracked, above.
1796
+
1797
+ // 4. manifest.integrations may record the old file path (XSPEC-343 shapes) —
1798
+ // point it at the new one instead of leaving a stale entry check would then
1799
+ // report as missing.
1800
+ manifest.integrations = (manifest.integrations || []).map(entry => (entry === oldFile ? newFile : entry));
1801
+ if (!manifest.integrations.includes(newFile) && !manifest.integrations.includes('claude-code')) {
1802
+ manifest.integrations.push(newFile);
1803
+ }
1804
+
1805
+ // XSPEC-418 R6: also catches a stale whole-file entry for either file left
1806
+ // by an older CLI, not just the one this run might otherwise have added.
1807
+ pruneIntegrationFileHashes(manifest);
1808
+
1809
+ writeManifest(manifest, projectPath);
1810
+
1811
+ console.log(chalk.green(` ✓ claude-code now targets ${newFile}`));
1812
+ console.log();
1813
+ }
1814
+
1644
1815
  /**
1645
1816
  * Regenerate integration files for all configured AI tools
1646
1817
  * Reusable core logic that can be called from both updateIntegrationsOnly and configureCommand
@@ -1665,12 +1836,15 @@ export function regenerateIntegrations(projectPath, manifest) {
1665
1836
  const now = new Date().toISOString();
1666
1837
 
1667
1838
  for (const tool of aiTools) {
1668
- const targetFile = getToolFilePath(tool);
1839
+ // XSPEC-418 R3
1840
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
1669
1841
  if (generatedFiles.has(targetFile)) {
1670
1842
  continue; // Skip if already generated (AGENTS.md sharing)
1671
1843
  }
1672
1844
 
1673
1845
  // Shared with the reconciler so both paths emit the identical block.
1846
+ // buildToolIntegrationConfig already carries manifest.integrationTargets
1847
+ // through to writeIntegrationFile (XSPEC-418 R2).
1674
1848
  const toolConfig = buildToolIntegrationConfig(manifest, tool);
1675
1849
 
1676
1850
  const result = writeIntegrationFile(tool, toolConfig, projectPath);
@@ -1678,16 +1852,11 @@ export function regenerateIntegrations(projectPath, manifest) {
1678
1852
  results.updated.push(result.path);
1679
1853
  generatedFiles.add(targetFile);
1680
1854
 
1681
- // Update file hash
1682
- const fullPath = join(projectPath, result.path);
1683
- const hashInfo = computeFileHash(fullPath);
1684
- if (hashInfo) {
1685
- if (!manifest.fileHashes) {
1686
- manifest.fileHashes = {};
1687
- }
1688
- const normalizedPath = result.path.replace(/\\/g, '/');
1689
- manifest.fileHashes[normalizedPath] = { ...hashInfo, installedAt: now };
1690
- }
1855
+ // XSPEC-418 R6: no whole-file `fileHashes` entry for an integration
1856
+ // file — only the UDS block is tracked, below. A whole-file entry here
1857
+ // is exactly what made `uds check` report "CLAUDE.md (modified)" for
1858
+ // content outside the block that UDS's own marker-based update leaves
1859
+ // alone on purpose.
1691
1860
 
1692
1861
  // Track integration block hash for UDS content integrity
1693
1862
  if (result.blockHashInfo) {
@@ -1726,6 +1895,14 @@ export function regenerateIntegrations(projectPath, manifest) {
1726
1895
  }
1727
1896
  }
1728
1897
 
1898
+ // XSPEC-418 R6: drop any fileHashes entry left over from an older CLI (or
1899
+ // from refreshTrackedFileHash above, which predates this rule and still
1900
+ // refreshes AGENTS.md's whole-file hash if one is already tracked). Called
1901
+ // here rather than relying only on refreshIntegrationBlockHashes downstream
1902
+ // because not every caller of this function calls that afterward (`uds
1903
+ // config`'s AI-tools flow does not).
1904
+ pruneIntegrationFileHashes(manifest);
1905
+
1729
1906
  return {
1730
1907
  success: results.errors.length === 0,
1731
1908
  updated: results.updated,
@@ -1793,9 +1970,11 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1793
1970
  console.log(chalk.bold('=== Integration Plan (dry run — nothing is written) ==='));
1794
1971
  const wouldChange = [];
1795
1972
  const unchanged = [];
1973
+ const ambiguous = [];
1796
1974
  const seen = new Set();
1797
1975
  for (const tool of aiTools) {
1798
- const targetFile = getToolFilePath(tool);
1976
+ // XSPEC-418 R3: the plan must show the actual target, not the default.
1977
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
1799
1978
  if (seen.has(targetFile)) continue;
1800
1979
  seen.add(targetFile);
1801
1980
  const savedMode = manifest.contentMode || 'auto';
@@ -1822,7 +2001,18 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1822
2001
  const full = join(projectPath, targetFile);
1823
2002
  const current = existsSync(full) ? readFileSync(full, 'utf8') : '';
1824
2003
  const format = targetFile.endsWith('.md') ? 'markdown' : 'plaintext';
1825
- const cur = extractMarkedContent(current, format).content || '';
2004
+ // XSPEC adopter-report Q5: an ambiguous marker pair can't be diffed
2005
+ // against — report it explicitly instead of crashing the whole plan.
2006
+ let cur;
2007
+ try {
2008
+ cur = extractMarkedContent(current, format).content || '';
2009
+ } catch (error) {
2010
+ if (error instanceof AmbiguousMarkerError) {
2011
+ ambiguous.push(`${targetFile}: ${error.message}`);
2012
+ continue;
2013
+ }
2014
+ throw error;
2015
+ }
1826
2016
  // 比對正規化過的內容:只關心「受管區塊會不會變」,不關心尾端空白。
1827
2017
  if (cur.trim() === String(next).trim()) {
1828
2018
  unchanged.push(targetFile);
@@ -1831,6 +2021,10 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1831
2021
  wouldChange.push(`${targetFile} (${delta >= 0 ? '+' : ''}${delta} bytes in the UDS block)`);
1832
2022
  }
1833
2023
  }
2024
+ if (ambiguous.length > 0) {
2025
+ console.log(chalk.red(` ✗ Ambiguous UDS markers (${ambiguous.length}) — will not be touched by --apply:`));
2026
+ for (const a of ambiguous) console.log(chalk.gray(` ${a}`));
2027
+ }
1834
2028
  if (wouldChange.length > 0) {
1835
2029
  console.log(chalk.yellow(` ~ Would update (${wouldChange.length}):`));
1836
2030
  for (const f of wouldChange) console.log(chalk.gray(` ${f}`));
@@ -1854,7 +2048,7 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1854
2048
  spinner.succeed(msg.regeneratedIntegrations.replace('{count}', results.updated.length));
1855
2049
 
1856
2050
  // Update manifest
1857
- manifest.version = '3.3.0';
2051
+ bumpManifestVersion(manifest);
1858
2052
  refreshIntegrationBlockHashes(manifest, projectPath);
1859
2053
  writeManifest(manifest, projectPath);
1860
2054
 
@@ -1897,7 +2091,10 @@ export function backfillIntegrationConfigs(manifest) {
1897
2091
  const tools = manifest.aiTools?.length ? manifest.aiTools : [];
1898
2092
  const byFile = new Map();
1899
2093
  for (const tool of tools) {
1900
- const file = resolveIntegrationFile(tool) || getToolFilePath(tool);
2094
+ // XSPEC-418 R3: keyed by the actual target file, or integrationConfigs
2095
+ // would key claude-code's entry as 'CLAUDE.md' while every other record
2096
+ // (integrations, integrationBlockHashes) already uses CLAUDE.local.md.
2097
+ const file = resolveIntegrationTargetFile(tool, manifest);
1901
2098
  if (file && !byFile.has(file)) byFile.set(file, tool);
1902
2099
  }
1903
2100
  // Integrations can be recorded as file names (`CLAUDE.md`) or tool keys.
@@ -1943,6 +2140,14 @@ function resolveToolKeyFromEntry(entry) {
1943
2140
  * tracking it would convert every legitimate edit into a reported fault. This
1944
2141
  * only ever refreshes a record that already exists.
1945
2142
  *
2143
+ * XSPEC-418 R6 narrowed that further: AGENTS.md is also tracked by its UDS
2144
+ * block (`integrationBlockHashes`) once one exists, and every call site of
2145
+ * this function now also calls `pruneIntegrationFileHashes` afterward — so a
2146
+ * `fileHashes` entry this refreshes is removed again in the same run. Kept
2147
+ * (rather than deleted outright) because it still matters for the moment
2148
+ * between this call and the prune, and for any caller added later that
2149
+ * forgets to prune; the prune is what actually enforces R6.
2150
+ *
1946
2151
  * @param {Object} manifest - Project manifest (mutated)
1947
2152
  * @param {string} projectPath - Project root
1948
2153
  * @param {string} relativePath - Path as UDS reports it
@@ -2152,15 +2357,8 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
2152
2357
  generatedAt: now
2153
2358
  };
2154
2359
 
2155
- // Update file hash
2156
- const hashInfo = computeFileHash(fullPath);
2157
- if (hashInfo) {
2158
- if (!manifest.fileHashes) {
2159
- manifest.fileHashes = {};
2160
- }
2161
- const normalizedIntPath = integrationPath.replace(/\\/g, '/');
2162
- manifest.fileHashes[normalizedIntPath] = { ...hashInfo, installedAt: now };
2163
- }
2360
+ // XSPEC-418 R6: no whole-file fileHashes entry — only the UDS block is
2361
+ // tracked, below.
2164
2362
 
2165
2363
  // Track integration block hash for UDS content integrity
2166
2364
  if (result.blockHashInfo) {
@@ -2180,7 +2378,7 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
2180
2378
 
2181
2379
  // Update manifest version and save
2182
2380
  if (updatedCount > 0) {
2183
- manifest.version = '3.3.0';
2381
+ bumpManifestVersion(manifest);
2184
2382
  refreshIntegrationBlockHashes(manifest, projectPath);
2185
2383
  if (plan) {
2186
2384
  console.log(chalk.gray(' (dry run — the manifest was not written)'));
@@ -2217,6 +2415,66 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
2217
2415
  * "Write then restore" leaves a broken tree if it dies halfway, which is worse
2218
2416
  * than having no dry run at all — the same reasoning as the integrations plan.
2219
2417
  */
2418
+ /**
2419
+ * A general `uds update --plan` (no --skills/--commands scope) never called
2420
+ * planSkills/planCommands — those are the only two places version staleness
2421
+ * is computed, and they only run under `--plan --skills`/`--plan --commands`.
2422
+ * An adopter running plain `--plan` saw a clean reconciliation plan and
2423
+ * nothing else, with Skills or Commands a version behind and no hint that a
2424
+ * scoped plan would have said so. This prints a short, best-effort note —
2425
+ * not a full plan — so the general path is never silent about it.
2426
+ *
2427
+ * Skills staleness is read the same way planSkills does: per-installation,
2428
+ * from what is actually on disk (`getInstalledSkillsInfoForAgent`), because
2429
+ * different agents/levels can be out of sync independently. Commands have no
2430
+ * per-installation version on disk (only a file count), so Commands
2431
+ * staleness is read from the one version the manifest itself records
2432
+ * (`manifest.commands.version`, written by every path that installs
2433
+ * commands) against the latest version UDS ships. // implements XSPEC adopter-report Q3
2434
+ *
2435
+ * @param {string} projectPath
2436
+ * @param {Object} manifest
2437
+ */
2438
+ function reportStaleSkillsCommandsHint(projectPath, manifest) {
2439
+ const repoInfo = getRepositoryInfo();
2440
+ const latestVersion = repoInfo.skills.version;
2441
+ const stale = [];
2442
+
2443
+ const skillsInstallations = (manifest.skills?.installations || []).filter((i) => i.level !== 'marketplace');
2444
+ for (const inst of skillsInstallations) {
2445
+ const info = getInstalledSkillsInfoForAgent(inst.agent, inst.level, projectPath);
2446
+ const current = info?.version;
2447
+ if (current && current !== latestVersion) {
2448
+ stale.push({
2449
+ label: `${getAgentDisplayName(inst.agent)} (${inst.level}): Skills v${current} → v${latestVersion}`,
2450
+ flag: '--skills'
2451
+ });
2452
+ }
2453
+ }
2454
+
2455
+ if (manifest.commands?.installed && (manifest.commands?.installations || []).length > 0) {
2456
+ const current = manifest.commands.version;
2457
+ if (current && current !== latestVersion) {
2458
+ stale.push({
2459
+ label: `Commands: v${current} → v${latestVersion}`,
2460
+ flag: '--commands'
2461
+ });
2462
+ }
2463
+ }
2464
+
2465
+ if (stale.length === 0) return;
2466
+
2467
+ console.log(chalk.yellow(' Skills/Commands installed but out of date:'));
2468
+ for (const s of stale) {
2469
+ console.log(chalk.gray(` ~ ${s.label}`));
2470
+ }
2471
+ const flags = [...new Set(stale.map((s) => s.flag))];
2472
+ for (const flag of flags) {
2473
+ console.log(chalk.gray(` Run \`uds update --apply --yes ${flag}\` to update.`));
2474
+ }
2475
+ console.log();
2476
+ }
2477
+
2220
2478
  async function planSkills(projectPath, manifest, options) {
2221
2479
  const repoInfo = getRepositoryInfo();
2222
2480
  const latestVersion = repoInfo.skills.version;
@@ -3001,7 +3259,7 @@ async function handleRollback(projectPath) {
3001
3259
  /**
3002
3260
  * Handle --plan: show what the reconciler would do without executing.
3003
3261
  */
3004
- async function handlePlan(projectPath, options) {
3262
+ async function handlePlan(projectPath, options, manifest) {
3005
3263
  const spinner = createSpinner('Calculating reconciliation plan...').start();
3006
3264
 
3007
3265
  const result = await reconcilerPlan(projectPath, { force: false });
@@ -3018,6 +3276,12 @@ async function handlePlan(projectPath, options) {
3018
3276
  console.log(formatPlan(result.plan));
3019
3277
  console.log();
3020
3278
 
3279
+ // XSPEC adopter-report Q3: the reconciliation plan above never covers
3280
+ // Skills/Commands version staleness — only --plan --skills/--commands did.
3281
+ if (manifest) {
3282
+ reportStaleSkillsCommandsHint(projectPath, manifest);
3283
+ }
3284
+
3021
3285
  if (result.plan.actions.length > 0) {
3022
3286
  // NOT `uds update`. That runs the legacy path, which never executes this
3023
3287
  // plan — it refreshes existing standards and reports success for having done
@@ -773,10 +773,48 @@ export function needsMigration(manifest) {
773
773
  if (!manifest) {
774
774
  return true;
775
775
  }
776
-
776
+
777
777
  return manifest.version !== CURRENT_SCHEMA_VERSION;
778
778
  }
779
779
 
780
+ /**
781
+ * Compare two `x.y.z` version strings numerically (not lexically — '3.10.0'
782
+ * must sort after '3.9.0'). Missing components count as 0.
783
+ * @returns {number} negative if a<b, 0 if equal, positive if a>b
784
+ */
785
+ function compareSemverStrings(a, b) {
786
+ const pa = String(a).split('.').map(Number);
787
+ const pb = String(b).split('.').map(Number);
788
+ for (let i = 0; i < 3; i++) {
789
+ const diff = (pa[i] || 0) - (pb[i] || 0);
790
+ if (diff !== 0) return diff;
791
+ }
792
+ return 0;
793
+ }
794
+
795
+ /**
796
+ * Set `manifest.version` to the CLI's current schema version — never lower.
797
+ *
798
+ * XSPEC adopter-report Q1: five write sites across update.js/check.js/config.js
799
+ * each hardcoded a schema version literal ('3.1.0'/'3.2.0'/'3.3.0') instead of
800
+ * CURRENT_SCHEMA_VERSION. Every one of those literals predates a later schema
801
+ * bump, so running the operation on an already-current manifest (3.4.0)
802
+ * downgraded it back to whatever the literal said. This is the single place
803
+ * that decision is made: always move toward CURRENT_SCHEMA_VERSION, never
804
+ * away from it, even if a caller is ever fixed incompletely again.
805
+ *
806
+ * @param {Object} manifest - Manifest object (mutated in place)
807
+ */
808
+ export function bumpManifestVersion(manifest) {
809
+ const current = manifest.version;
810
+ const isWellFormed = typeof current === 'string' && /^\d+\.\d+\.\d+$/.test(current);
811
+ if (!isWellFormed || compareSemverStrings(current, CURRENT_SCHEMA_VERSION) < 0) {
812
+ manifest.version = CURRENT_SCHEMA_VERSION;
813
+ }
814
+ // else: manifest.version is already at or ahead of CURRENT_SCHEMA_VERSION
815
+ // (e.g. a newer version written by a future CLI) — leave it alone.
816
+ }
817
+
780
818
  /**
781
819
  * Get all AI tools from manifest
782
820
  * @param {Object} manifest - Manifest