universal-dev-standards 6.9.0 → 6.11.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 (63) hide show
  1. package/bin/uds.js +2 -0
  2. package/bundled/core/agent-communication-protocol.md +8 -0
  3. package/bundled/core/branch-completion.md +8 -0
  4. package/bundled/core/change-batching-standards.md +8 -0
  5. package/bundled/core/execution-history.md +8 -0
  6. package/bundled/core/pipeline-integration-standards.md +8 -0
  7. package/bundled/core/workflow-enforcement.md +8 -0
  8. package/bundled/core/workflow-state-protocol.md +8 -0
  9. package/bundled/locales/zh-CN/CHANGELOG.md +49 -3
  10. package/bundled/locales/zh-CN/README.md +1 -1
  11. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  12. package/bundled/locales/zh-CN/core/agent-communication-protocol.md +7 -0
  13. package/bundled/locales/zh-CN/core/branch-completion.md +7 -0
  14. package/bundled/locales/zh-CN/core/change-batching-standards.md +7 -0
  15. package/bundled/locales/zh-CN/core/execution-history.md +7 -0
  16. package/bundled/locales/zh-CN/core/pipeline-integration-standards.md +7 -0
  17. package/bundled/locales/zh-CN/core/workflow-enforcement.md +7 -0
  18. package/bundled/locales/zh-CN/core/workflow-state-protocol.md +7 -0
  19. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +1 -1
  20. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +52 -5
  21. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +3 -1
  22. package/bundled/locales/zh-CN/docs/MIGRATION-v6.md +8 -4
  23. package/bundled/locales/zh-TW/CHANGELOG.md +50 -3
  24. package/bundled/locales/zh-TW/README.md +1 -1
  25. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  26. package/bundled/locales/zh-TW/core/agent-communication-protocol.md +7 -0
  27. package/bundled/locales/zh-TW/core/branch-completion.md +7 -0
  28. package/bundled/locales/zh-TW/core/change-batching-standards.md +7 -0
  29. package/bundled/locales/zh-TW/core/execution-history.md +7 -0
  30. package/bundled/locales/zh-TW/core/pipeline-integration-standards.md +7 -0
  31. package/bundled/locales/zh-TW/core/workflow-enforcement.md +7 -0
  32. package/bundled/locales/zh-TW/core/workflow-state-protocol.md +7 -0
  33. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +1 -1
  34. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +52 -5
  35. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +3 -1
  36. package/bundled/locales/zh-TW/docs/MIGRATION-v6.md +8 -4
  37. package/bundled/locales/zh-TW/integrations/claude-code/README.md +14 -5
  38. package/package.json +7 -6
  39. package/src/commands/check.js +413 -44
  40. package/src/commands/config.js +34 -28
  41. package/src/commands/init.js +37 -7
  42. package/src/commands/spec.js +2 -2
  43. package/src/commands/update.js +560 -74
  44. package/src/core/manifest.js +39 -1
  45. package/src/flows/init-flow.js +9 -1
  46. package/src/generators/layered-claudemd.js +13 -4
  47. package/src/i18n/messages.js +39 -3
  48. package/src/installers/integration-installer.js +17 -10
  49. package/src/installers/manifest-installer.js +4 -0
  50. package/src/installers/skills-installer.js +4 -4
  51. package/src/installers/standards-installer.js +3 -3
  52. package/src/prompts/init.js +33 -4
  53. package/src/reconciler/actual-state-scanner.js +29 -2
  54. package/src/reconciler/desired-state-calculator.js +51 -2
  55. package/src/reconciler/diff-engine.js +76 -5
  56. package/src/reconciler/plan-executor.js +48 -27
  57. package/src/utils/hasher.js +61 -5
  58. package/src/utils/integration-generator.js +431 -92
  59. package/src/utils/marker-locator.js +140 -0
  60. package/src/utils/reference-sync.js +156 -8
  61. package/src/utils/registry.js +57 -0
  62. package/src/utils/spinner.js +31 -0
  63. package/standards-registry.json +8 -8
@@ -1,15 +1,16 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
2
+ import { createSpinner } from '../utils/spinner.js';
3
3
  import { select, confirm as inquirerConfirm, checkbox, Separator } from '@inquirer/prompts';
4
4
  import { execSync } from 'child_process';
5
5
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
6
6
  import { join, basename, dirname, relative } from 'path';
7
7
  import { readManifest, writeManifest, copyStandard, isInitialized, getRepoRoot } from '../utils/copier.js';
8
- import { getRepositoryInfo, getAllStandards, getStandardSource } from '../utils/registry.js';
9
- import { computeFileHash, planStandardsRemovals, refreshIntegrationBlockHashes } from '../utils/hasher.js';
8
+ import { getRepositoryInfo, getAllStandards, getShippableFilenames, getStandardSource } from '../utils/registry.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 } 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
  /**
@@ -274,7 +277,7 @@ function buildInstallCommand(pm, tag) {
274
277
  */
275
278
  async function updateCliAndExit(useBeta = false) {
276
279
  const msg = t().commands.update;
277
- const spinner = ora(msg.updatingCli).start();
280
+ const spinner = createSpinner(msg.updatingCli).start();
278
281
 
279
282
  try {
280
283
  // Command is hardcoded - no user input, safe from injection
@@ -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);
@@ -581,6 +593,23 @@ export async function updateCommand(options) {
581
593
  if (versionComparison > 0) {
582
594
  console.log(chalk.gray(` ${msg.newerVersion.replace('{version}', currentVersion)}`));
583
595
  }
596
+
597
+ // Still forget records of files that are gone and no longer shipped. An
598
+ // adopter who followed MIGRATION-v6 §2 is on the current version by
599
+ // definition — telling them to run `uds update --prune` and then returning
600
+ // before anything is examined is how the complaint became unclearable.
601
+ const retiredOnLatest = pruneRetiredHashes(manifest, projectPath);
602
+ if (retiredOnLatest.length > 0) {
603
+ console.log();
604
+ console.log(chalk.gray(
605
+ ` ${(msg.droppedRetiredHashes || 'Dropped {count} manifest record(s) for files UDS no longer ships and that are already gone:').replace('{count}', retiredOnLatest.length)}`
606
+ ));
607
+ for (const path of retiredOnLatest) {
608
+ console.log(chalk.gray(` - ${path}`));
609
+ }
610
+ writeManifest(manifest, projectPath);
611
+ }
612
+
584
613
  console.log();
585
614
  return;
586
615
  }
@@ -642,7 +671,7 @@ export async function updateCommand(options) {
642
671
 
643
672
  // Perform update
644
673
  console.log();
645
- const spinner = ora(msg.updatingStandards).start();
674
+ const spinner = createSpinner(msg.updatingStandards).start();
646
675
 
647
676
  const results = {
648
677
  updated: [],
@@ -714,7 +743,7 @@ export async function updateCommand(options) {
714
743
  }
715
744
 
716
745
  if (shouldInstallNew) {
717
- const newSpinner = ora(msg.installingNewStandards).start();
746
+ const newSpinner = createSpinner(msg.installingNewStandards).start();
718
747
  let newCount = 0;
719
748
 
720
749
  for (const ns of newStandards) {
@@ -735,7 +764,17 @@ export async function updateCommand(options) {
735
764
 
736
765
  // Update integrations (unless --standards-only)
737
766
  if (!options.standardsOnly && manifest.integrations && manifest.integrations.length > 0) {
738
- const intSpinner = ora(msg.syncingIntegrations).start();
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
+
777
+ const intSpinner = createSpinner(msg.syncingIntegrations).start();
739
778
 
740
779
  // Build installed standards list
741
780
  // Raw, not basename()d. Resolution needs the registry (an ID is not a
@@ -756,7 +795,9 @@ export async function updateCommand(options) {
756
795
  const aiTools = manifest.aiTools || [];
757
796
 
758
797
  for (const tool of aiTools) {
759
- 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);
760
801
  if (generatedFiles.has(targetFile)) {
761
802
  continue; // Skip if already generated (AGENTS.md sharing)
762
803
  }
@@ -773,7 +814,9 @@ export async function updateCommand(options) {
773
814
  contentMode: resolved.contentMode,
774
815
  level: resolved.level,
775
816
  // Pass output_language for dynamic commit standards generation
776
- 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
777
820
  };
778
821
 
779
822
  const result = writeIntegrationFile(tool, toolConfig, projectPath);
@@ -813,6 +856,7 @@ export async function updateCommand(options) {
813
856
  installedAt: new Date().toISOString()
814
857
  };
815
858
  }
859
+ refreshTrackedFileHash(manifest, projectPath, agentsMdResult.path);
816
860
  }
817
861
  }
818
862
 
@@ -831,7 +875,11 @@ export async function updateCommand(options) {
831
875
  if (manifest.integrationBlockHashes) {
832
876
  const expectedFiles = new Set();
833
877
  for (const tool of (manifest.aiTools || [])) {
834
- 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);
835
883
  if (targetFile) expectedFiles.add(targetFile);
836
884
  }
837
885
  // Universal AGENTS.md is tracked when generateAgentsMd is enabled.
@@ -862,8 +910,14 @@ export async function updateCommand(options) {
862
910
  if (!layeredResult.fallback) {
863
911
  console.log(chalk.green(` ✓ Layered CLAUDE.md updated (${layeredResult.generatedFiles.length} files)`));
864
912
  }
865
- } catch {
866
- // 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
867
921
  }
868
922
  }
869
923
 
@@ -933,14 +987,14 @@ export async function updateCommand(options) {
933
987
  }
934
988
  }
935
989
 
936
- // Update hashes for integrations
937
- for (const int of results.integrations) {
938
- const fullPath = join(projectPath, int);
939
- const hashInfo = computeFileHash(fullPath);
940
- if (hashInfo) {
941
- manifest.fileHashes[int] = { ...hashInfo, installedAt: now };
942
- }
943
- }
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.
944
998
 
945
999
  // Record what UDS wrote this run, then decide what (if anything) may go.
946
1000
  // (XSPEC-384 R1/R2/R3)
@@ -989,6 +1043,22 @@ export async function updateCommand(options) {
989
1043
  }
990
1044
  }
991
1045
 
1046
+ // Records of files that are already gone and that UDS no longer ships. Not
1047
+ // part of the removal plan above: that plan walks what is on disk, and these
1048
+ // are precisely the ones that are not. Reported rather than done quietly,
1049
+ // because a manifest shrinking without explanation is the shape this whole
1050
+ // release is about.
1051
+ const retiredHashes = pruneRetiredHashes(manifest, projectPath);
1052
+ if (retiredHashes.length > 0) {
1053
+ console.log();
1054
+ console.log(chalk.gray(
1055
+ ` ${(msg.droppedRetiredHashes || 'Dropped {count} manifest record(s) for files UDS no longer ships and that are already gone:').replace('{count}', retiredHashes.length)}`
1056
+ ));
1057
+ for (const path of retiredHashes) {
1058
+ console.log(chalk.gray(` - ${path}`));
1059
+ }
1060
+ }
1061
+
992
1062
  // Provenance is only a complete account of what we own once a run has
993
1063
  // written the whole installed set. Until then every unrecorded file is
994
1064
  // `unknown` rather than `foreign`, and nothing gets deleted on the strength
@@ -1052,7 +1122,7 @@ export async function updateCommand(options) {
1052
1122
  // date. The manifest is still written so that hash/migration bookkeeping for
1053
1123
  // the files that DID succeed is persisted.
1054
1124
  const updateIncomplete = results.errors.length > 0;
1055
- manifest.version = '3.3.0';
1125
+ bumpManifestVersion(manifest);
1056
1126
  if (!updateIncomplete) {
1057
1127
  manifest.upstream.version = latestVersion;
1058
1128
  manifest.upstream.installed = new Date().toISOString().split('T')[0];
@@ -1085,8 +1155,9 @@ export async function updateCommand(options) {
1085
1155
  allTrackedFiles.push(join('.standards', fileName));
1086
1156
  }
1087
1157
  for (const intEntry of (manifest.integrations || [])) {
1088
- // 兩種形狀都要能解出路徑(XSPEC-343 R1)
1089
- const filePath = resolveIntegrationFile(intEntry) || getToolFilePath(intEntry);
1158
+ // 兩種形狀都要能解出路徑(XSPEC-343 R1),且要尊重 manifest.integrationTargets
1159
+ // 的目標覆寫(XSPEC-418 R2/R3)——resolveIntegrationTargetFile 已同時處理兩者。
1160
+ const filePath = resolveIntegrationTargetFile(intEntry, manifest);
1090
1161
  if (filePath) {
1091
1162
  allTrackedFiles.push(filePath);
1092
1163
  }
@@ -1110,7 +1181,7 @@ export async function updateCommand(options) {
1110
1181
  }
1111
1182
 
1112
1183
  if (shouldRestore) {
1113
- const restoreSpinner = ora((msg.restoringMissing || 'Restoring missing files...')).start();
1184
+ const restoreSpinner = createSpinner((msg.restoringMissing || 'Restoring missing files...')).start();
1114
1185
  const checkMsg = t().commands.check;
1115
1186
  let restoredCount = 0;
1116
1187
 
@@ -1177,7 +1248,7 @@ export async function updateCommand(options) {
1177
1248
 
1178
1249
  // Install Skills if user agreed
1179
1250
  if (installSkills.length > 0) {
1180
- const skillSpinner = ora(msg.installingNewSkills || 'Installing Skills...').start();
1251
+ const skillSpinner = createSpinner(msg.installingNewSkills || 'Installing Skills...').start();
1181
1252
  const skillsLocale = resolveLocale(manifest, projectPath, options);
1182
1253
  const skillResult = await installSkillsToMultipleAgents(installSkills, null, projectPath, skillsLocale);
1183
1254
 
@@ -1225,7 +1296,7 @@ export async function updateCommand(options) {
1225
1296
 
1226
1297
  // Update outdated Skills if user agreed
1227
1298
  if (updateSkills.length > 0) {
1228
- const updateSpinner = ora(msg.updatingSkills || 'Updating Skills...').start();
1299
+ const updateSpinner = createSpinner(msg.updatingSkills || 'Updating Skills...').start();
1229
1300
  const updateLocale = resolveLocale(manifest, projectPath, options);
1230
1301
  const updateResult = await installSkillsToMultipleAgents(updateSkills, null, projectPath, updateLocale);
1231
1302
 
@@ -1263,7 +1334,7 @@ export async function updateCommand(options) {
1263
1334
 
1264
1335
  // Install Commands if user agreed
1265
1336
  if (installCommands.length > 0) {
1266
- const cmdSpinner = ora(msg.installingNewCommands || 'Installing commands...').start();
1337
+ const cmdSpinner = createSpinner(msg.installingNewCommands || 'Installing commands...').start();
1267
1338
  const cmdLocale = resolveLocale(manifest, projectPath, options);
1268
1339
  const cmdResult = await installCommandsToMultipleAgents(installCommands, null, projectPath, cmdLocale);
1269
1340
 
@@ -1294,7 +1365,7 @@ export async function updateCommand(options) {
1294
1365
 
1295
1366
  // Update outdated Commands if user agreed
1296
1367
  if (updateCommands.length > 0) {
1297
- const updateCmdSpinner = ora(msg.updatingCommands || 'Updating Commands...').start();
1368
+ const updateCmdSpinner = createSpinner(msg.updatingCommands || 'Updating Commands...').start();
1298
1369
  const updateCmdLocale = resolveLocale(manifest, projectPath, options);
1299
1370
  const updateCmdResult = await installCommandsToMultipleAgents(updateCommands, null, projectPath, updateCmdLocale);
1300
1371
 
@@ -1370,7 +1441,7 @@ export async function updateCommand(options) {
1370
1441
  let hasChanges = false;
1371
1442
 
1372
1443
  if (missingSkills.length > 0) {
1373
- const spinner = ora(msg.installingNewSkills || 'Installing Skills...').start();
1444
+ const spinner = createSpinner(msg.installingNewSkills || 'Installing Skills...').start();
1374
1445
  const result = await installSkillsToMultipleAgents(missingSkills, null, projectPath, skillsLocale);
1375
1446
  if (!manifest.skills) manifest.skills = {};
1376
1447
  manifest.skills.installed = true;
@@ -1391,7 +1462,7 @@ export async function updateCommand(options) {
1391
1462
  }
1392
1463
 
1393
1464
  if (outdatedSkills.length > 0) {
1394
- const spinner = ora(msg.updatingSkills || 'Updating Skills...').start();
1465
+ const spinner = createSpinner(msg.updatingSkills || 'Updating Skills...').start();
1395
1466
  const result = await installSkillsToMultipleAgents(outdatedSkills, null, projectPath, skillsLocale);
1396
1467
  if (!manifest.skills) manifest.skills = {};
1397
1468
  manifest.skills.version = repoInfo.skills.version;
@@ -1415,7 +1486,7 @@ export async function updateCommand(options) {
1415
1486
  }
1416
1487
 
1417
1488
  if (missingCommands.length > 0) {
1418
- const spinner = ora(msg.installingNewCommands || 'Installing commands...').start();
1489
+ const spinner = createSpinner(msg.installingNewCommands || 'Installing commands...').start();
1419
1490
  const result = await installCommandsToMultipleAgents(missingCommands, null, projectPath, skillsLocale);
1420
1491
  if (!manifest.commands) manifest.commands = {};
1421
1492
  manifest.commands.installed = true;
@@ -1435,7 +1506,7 @@ export async function updateCommand(options) {
1435
1506
  }
1436
1507
 
1437
1508
  if (outdatedCommands.length > 0) {
1438
- const spinner = ora(msg.updatingCommands || 'Updating Commands...').start();
1509
+ const spinner = createSpinner(msg.updatingCommands || 'Updating Commands...').start();
1439
1510
  const result = await installCommandsToMultipleAgents(outdatedCommands, null, projectPath, skillsLocale);
1440
1511
  if (!manifest.commands) manifest.commands = {};
1441
1512
  manifest.commands.version = repoInfo.skills.version;
@@ -1607,6 +1678,140 @@ async function offerErrorExitGate(projectPath, options) {
1607
1678
  console.log();
1608
1679
  }
1609
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
+
1610
1815
  /**
1611
1816
  * Regenerate integration files for all configured AI tools
1612
1817
  * Reusable core logic that can be called from both updateIntegrationsOnly and configureCommand
@@ -1631,12 +1836,15 @@ export function regenerateIntegrations(projectPath, manifest) {
1631
1836
  const now = new Date().toISOString();
1632
1837
 
1633
1838
  for (const tool of aiTools) {
1634
- const targetFile = getToolFilePath(tool);
1839
+ // XSPEC-418 R3
1840
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
1635
1841
  if (generatedFiles.has(targetFile)) {
1636
1842
  continue; // Skip if already generated (AGENTS.md sharing)
1637
1843
  }
1638
1844
 
1639
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).
1640
1848
  const toolConfig = buildToolIntegrationConfig(manifest, tool);
1641
1849
 
1642
1850
  const result = writeIntegrationFile(tool, toolConfig, projectPath);
@@ -1644,16 +1852,11 @@ export function regenerateIntegrations(projectPath, manifest) {
1644
1852
  results.updated.push(result.path);
1645
1853
  generatedFiles.add(targetFile);
1646
1854
 
1647
- // Update file hash
1648
- const fullPath = join(projectPath, result.path);
1649
- const hashInfo = computeFileHash(fullPath);
1650
- if (hashInfo) {
1651
- if (!manifest.fileHashes) {
1652
- manifest.fileHashes = {};
1653
- }
1654
- const normalizedPath = result.path.replace(/\\/g, '/');
1655
- manifest.fileHashes[normalizedPath] = { ...hashInfo, installedAt: now };
1656
- }
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.
1657
1860
 
1658
1861
  // Track integration block hash for UDS content integrity
1659
1862
  if (result.blockHashInfo) {
@@ -1688,9 +1891,18 @@ export function regenerateIntegrations(projectPath, manifest) {
1688
1891
  installedAt: now
1689
1892
  };
1690
1893
  }
1894
+ refreshTrackedFileHash(manifest, projectPath, agentsMdResult.path);
1691
1895
  }
1692
1896
  }
1693
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
+
1694
1906
  return {
1695
1907
  success: results.errors.length === 0,
1696
1908
  updated: results.updated,
@@ -1758,9 +1970,11 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1758
1970
  console.log(chalk.bold('=== Integration Plan (dry run — nothing is written) ==='));
1759
1971
  const wouldChange = [];
1760
1972
  const unchanged = [];
1973
+ const ambiguous = [];
1761
1974
  const seen = new Set();
1762
1975
  for (const tool of aiTools) {
1763
- const targetFile = getToolFilePath(tool);
1976
+ // XSPEC-418 R3: the plan must show the actual target, not the default.
1977
+ const targetFile = resolveIntegrationTargetFile(tool, manifest);
1764
1978
  if (seen.has(targetFile)) continue;
1765
1979
  seen.add(targetFile);
1766
1980
  const savedMode = manifest.contentMode || 'auto';
@@ -1787,7 +2001,18 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1787
2001
  const full = join(projectPath, targetFile);
1788
2002
  const current = existsSync(full) ? readFileSync(full, 'utf8') : '';
1789
2003
  const format = targetFile.endsWith('.md') ? 'markdown' : 'plaintext';
1790
- 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
+ }
1791
2016
  // 比對正規化過的內容:只關心「受管區塊會不會變」,不關心尾端空白。
1792
2017
  if (cur.trim() === String(next).trim()) {
1793
2018
  unchanged.push(targetFile);
@@ -1796,6 +2021,10 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1796
2021
  wouldChange.push(`${targetFile} (${delta >= 0 ? '+' : ''}${delta} bytes in the UDS block)`);
1797
2022
  }
1798
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
+ }
1799
2028
  if (wouldChange.length > 0) {
1800
2029
  console.log(chalk.yellow(` ~ Would update (${wouldChange.length}):`));
1801
2030
  for (const f of wouldChange) console.log(chalk.gray(` ${f}`));
@@ -1811,7 +2040,7 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1811
2040
  return;
1812
2041
  }
1813
2042
 
1814
- const spinner = ora(msg.regeneratingIntegrations).start();
2043
+ const spinner = createSpinner(msg.regeneratingIntegrations).start();
1815
2044
 
1816
2045
  // Use reusable regeneration function
1817
2046
  const results = regenerateIntegrations(projectPath, manifest);
@@ -1819,7 +2048,7 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1819
2048
  spinner.succeed(msg.regeneratedIntegrations.replace('{count}', results.updated.length));
1820
2049
 
1821
2050
  // Update manifest
1822
- manifest.version = '3.3.0';
2051
+ bumpManifestVersion(manifest);
1823
2052
  refreshIntegrationBlockHashes(manifest, projectPath);
1824
2053
  writeManifest(manifest, projectPath);
1825
2054
 
@@ -1840,6 +2069,199 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1840
2069
  process.exit(0);
1841
2070
  }
1842
2071
 
2072
+ /**
2073
+ * Rebuild `manifest.integrationConfigs` from what the manifest already records.
2074
+ *
2075
+ * 🔴 Only `uds init`'s interactive flow and `--sync-refs` itself ever wrote this
2076
+ * key; no update or reconcile path backfills it, so a project created with
2077
+ * `uds init -y` carries `{}` from the first day (reproduced on a clean 6.9.0
2078
+ * project, 2026-09-16). `--sync-refs` then refuses to run — and `uds check` was
2079
+ * recommending exactly that command. Everything the config holds is derivable
2080
+ * from the manifest, so derive it instead of demanding the user re-init.
2081
+ *
2082
+ * Existing entries are never overwritten: they may hold choices (categories,
2083
+ * content mode) that this derivation cannot know.
2084
+ *
2085
+ * @param {Object} manifest - Manifest object (mutated)
2086
+ * @returns {number} How many entries were added
2087
+ */
2088
+ export function backfillIntegrationConfigs(manifest) {
2089
+ if (!manifest.integrationConfigs) manifest.integrationConfigs = {};
2090
+
2091
+ const tools = manifest.aiTools?.length ? manifest.aiTools : [];
2092
+ const byFile = new Map();
2093
+ for (const tool of tools) {
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);
2098
+ if (file && !byFile.has(file)) byFile.set(file, tool);
2099
+ }
2100
+ // Integrations can be recorded as file names (`CLAUDE.md`) or tool keys.
2101
+ for (const entry of manifest.integrations || []) {
2102
+ const file = resolveIntegrationFile(entry) || (String(entry).includes('.') ? String(entry) : null);
2103
+ if (!file) continue;
2104
+ if (!byFile.has(file)) {
2105
+ const tool = getToolFromPath(file) || resolveToolKeyFromEntry(entry);
2106
+ if (tool) byFile.set(file, tool);
2107
+ }
2108
+ }
2109
+
2110
+ let added = 0;
2111
+ for (const [file, tool] of byFile) {
2112
+ if (manifest.integrationConfigs[file]) continue;
2113
+ manifest.integrationConfigs[file] = {
2114
+ ...buildToolIntegrationConfig(manifest, tool),
2115
+ format: manifest.format || 'ai',
2116
+ backfilledAt: new Date().toISOString()
2117
+ };
2118
+ added++;
2119
+ }
2120
+ return added;
2121
+ }
2122
+
2123
+ function resolveToolKeyFromEntry(entry) {
2124
+ const key = String(entry).replace(/\.[^.]+$/, '').toLowerCase();
2125
+ return SUPPORTED_AI_TOOLS?.[key] ? key : null;
2126
+ }
2127
+
2128
+ /**
2129
+ * If the manifest already tracks this file, make its record describe what is on
2130
+ * disk now.
2131
+ *
2132
+ * Every path that writes AGENTS.md recorded `integrationBlockHashes` and none
2133
+ * recorded `fileHashes`, so a project whose manifest tracked the file (an older
2134
+ * install, or one where AGENTS.md was written as a tool's integration file) got
2135
+ * `⚠ AGENTS.md (modified)` from `uds check` the moment UDS itself regenerated
2136
+ * it. UDS wrote the file and did not record what it wrote.
2137
+ *
2138
+ * Conditional on purpose. `uds init` does not enrol AGENTS.md in `fileHashes`,
2139
+ * and adopters are expected to extend that summary — a path that started
2140
+ * tracking it would convert every legitimate edit into a reported fault. This
2141
+ * only ever refreshes a record that already exists.
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
+ *
2151
+ * @param {Object} manifest - Project manifest (mutated)
2152
+ * @param {string} projectPath - Project root
2153
+ * @param {string} relativePath - Path as UDS reports it
2154
+ * @returns {boolean} Whether a record was refreshed
2155
+ */
2156
+ export function refreshTrackedFileHash(manifest, projectPath, relativePath) {
2157
+ if (!manifest?.fileHashes || !relativePath) return false;
2158
+ const normalized = String(relativePath).replace(/\\/g, '/');
2159
+ if (!(normalized in manifest.fileHashes)) return false;
2160
+
2161
+ const hashInfo = computeFileHash(join(projectPath, normalized));
2162
+ if (!hashInfo) return false;
2163
+
2164
+ manifest.fileHashes[normalized] = { ...hashInfo, installedAt: new Date().toISOString() };
2165
+ return true;
2166
+ }
2167
+
2168
+ /**
2169
+ * Forget `fileHashes` records for files that are gone AND that UDS no longer
2170
+ * ships.
2171
+ *
2172
+ * `--prune` deletes files it finds while walking `.standards/`. A file the
2173
+ * adopter already removed by hand is not in that walk, so its record survived
2174
+ * every prune — and the record is what made `uds check` report the completed
2175
+ * migration as seven faults. Following MIGRATION-v6 §2 therefore produced a
2176
+ * complaint that no command could clear.
2177
+ *
2178
+ * This is not a deletion. The file is already absent and no registry entry can
2179
+ * ever resolve to it again, so the record cannot refer to anything. Both facts
2180
+ * are required: a missing file UDS still ships is a real fault and stays; a
2181
+ * descoped standard still on disk is `--prune`'s business, gated on ownership,
2182
+ * and is left alone here.
2183
+ *
2184
+ * @param {Object} manifest - Project manifest (mutated)
2185
+ * @param {string} projectPath - Project root
2186
+ * @param {{shippable?: Set<string>}} [deps] - Injection point for tests
2187
+ * @returns {string[]} Paths whose records were dropped, sorted
2188
+ */
2189
+ export function pruneRetiredHashes(manifest, projectPath, deps = {}) {
2190
+ if (!manifest?.fileHashes) return [];
2191
+
2192
+ let shippable;
2193
+ try {
2194
+ shippable = deps.shippable || getShippableFilenames();
2195
+ } catch {
2196
+ return [];
2197
+ }
2198
+
2199
+ // A shippable set this small means the registry did not load, not that UDS
2200
+ // stopped shipping everything. Without this arm a broken install would empty
2201
+ // the manifest and every downstream check would go quiet at the same time.
2202
+ if (!shippable || shippable.size < 50) return [];
2203
+
2204
+ const dropped = [];
2205
+ for (const recorded of Object.keys(manifest.fileHashes)) {
2206
+ const normalized = recorded.replace(/\\/g, '/');
2207
+ if (!normalized.startsWith('.standards/')) continue;
2208
+ const fileName = normalized.split('/').pop();
2209
+ if (shippable.has(fileName)) continue;
2210
+ if (existsSync(join(projectPath, recorded))) continue;
2211
+ delete manifest.fileHashes[recorded];
2212
+ dropped.push(recorded);
2213
+ }
2214
+ return dropped.sort();
2215
+ }
2216
+
2217
+ /**
2218
+ * Build the config `--sync-refs` regenerates an integration file from.
2219
+ *
2220
+ * The stored `integrationConfigs[file]` is a snapshot taken when the file was
2221
+ * installed. Its `categories` are the thing sync-refs exists to update, and its
2222
+ * `installedStandards` is the thing that must never be trusted: it is not
2223
+ * refreshed by any path, so on a project upgraded across majors it still lists
2224
+ * standards that stopped shipping, and whatever it lists becomes the file's
2225
+ * content. That is how a manifest holding 73 standards produced a CLAUDE.md
2226
+ * announcing 76, which `uds check` then flagged against the same manifest.
2227
+ *
2228
+ * So: manifest-derived fields win, the caller's freshly computed categories win
2229
+ * over both, and anything the manifest knows nothing about (timestamps, fields
2230
+ * a future version adds) is carried through untouched.
2231
+ *
2232
+ * `outputLanguage` is the one place the stored value still gets a say — a
2233
+ * manifest with no `output_language` should not silently reset a project that
2234
+ * chose one before the option was recorded there.
2235
+ *
2236
+ * @param {Object} manifest - Project manifest
2237
+ * @param {string} toolName - Tool key (e.g. 'claude-code')
2238
+ * @param {Object} storedConfig - The existing integrationConfigs entry
2239
+ * @param {string[]} expectedCategories - Categories computed from current standards
2240
+ * @returns {Object} Config for writeIntegrationFile
2241
+ */
2242
+ export function refreshIntegrationConfig(manifest, toolName, storedConfig = {}, expectedCategories = []) {
2243
+ const outputLanguage =
2244
+ manifest.options?.output_language
2245
+ || manifest.options?.commit_language
2246
+ || storedConfig.outputLanguage
2247
+ || storedConfig.commitLanguage
2248
+ || 'english';
2249
+
2250
+ const fresh = buildToolIntegrationConfig(
2251
+ { ...manifest, options: { ...(manifest.options || {}), output_language: outputLanguage } },
2252
+ toolName
2253
+ );
2254
+
2255
+ return {
2256
+ ...storedConfig,
2257
+ ...fresh,
2258
+ tool: toolName,
2259
+ categories: expectedCategories,
2260
+ outputLanguage,
2261
+ format: manifest.format || storedConfig.format || 'ai'
2262
+ };
2263
+ }
2264
+
1843
2265
  /**
1844
2266
  * Sync integration file references based on manifest standards
1845
2267
  * @param {string} projectPath - Project path
@@ -1851,6 +2273,16 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1851
2273
  console.log(chalk.cyan(msg.syncingRefs));
1852
2274
  console.log();
1853
2275
 
2276
+ // Rebuild the config when it is absent rather than sending the user away —
2277
+ // the manifest holds everything it is derived from.
2278
+ if (!manifest.integrationConfigs || Object.keys(manifest.integrationConfigs).length === 0) {
2279
+ const filled = backfillIntegrationConfigs(manifest);
2280
+ if (filled > 0) {
2281
+ console.log(chalk.gray(` ${(msg.integrationConfigsBackfilled || 'Rebuilt integration config for {count} file(s) from the manifest.').replace('{count}', filled)}`));
2282
+ console.log();
2283
+ }
2284
+ }
2285
+
1854
2286
  // Check if integrationConfigs exists
1855
2287
  if (!manifest.integrationConfigs || Object.keys(manifest.integrationConfigs).length === 0) {
1856
2288
  console.log(chalk.yellow(msg.noIntegrationConfigs));
@@ -1903,14 +2335,9 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1903
2335
  continue;
1904
2336
  }
1905
2337
 
1906
- // Regenerate the integration file with updated categories
1907
- const newConfig = {
1908
- ...config,
1909
- tool: toolName,
1910
- categories: expectedCategories,
1911
- // Pass output_language for dynamic commit standards generation
1912
- outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || config.outputLanguage || config.commitLanguage || 'english'
1913
- };
2338
+ // Regenerate the integration file with updated categories, from the
2339
+ // manifest rather than from the snapshot stored beside it.
2340
+ const newConfig = refreshIntegrationConfig(manifest, toolName, config, expectedCategories);
1914
2341
 
1915
2342
  if (plan) {
1916
2343
  console.log(chalk.yellow(` ~ ${integrationPath}: categories would change to ${expectedCategories.join(', ') || '(none)'} (dry run — nothing is written)`));
@@ -1930,15 +2357,8 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1930
2357
  generatedAt: now
1931
2358
  };
1932
2359
 
1933
- // Update file hash
1934
- const hashInfo = computeFileHash(fullPath);
1935
- if (hashInfo) {
1936
- if (!manifest.fileHashes) {
1937
- manifest.fileHashes = {};
1938
- }
1939
- const normalizedIntPath = integrationPath.replace(/\\/g, '/');
1940
- manifest.fileHashes[normalizedIntPath] = { ...hashInfo, installedAt: now };
1941
- }
2360
+ // XSPEC-418 R6: no whole-file fileHashes entry — only the UDS block is
2361
+ // tracked, below.
1942
2362
 
1943
2363
  // Track integration block hash for UDS content integrity
1944
2364
  if (result.blockHashInfo) {
@@ -1958,7 +2378,7 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1958
2378
 
1959
2379
  // Update manifest version and save
1960
2380
  if (updatedCount > 0) {
1961
- manifest.version = '3.3.0';
2381
+ bumpManifestVersion(manifest);
1962
2382
  refreshIntegrationBlockHashes(manifest, projectPath);
1963
2383
  if (plan) {
1964
2384
  console.log(chalk.gray(' (dry run — the manifest was not written)'));
@@ -1995,6 +2415,66 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1995
2415
  * "Write then restore" leaves a broken tree if it dies halfway, which is worse
1996
2416
  * than having no dry run at all — the same reasoning as the integrations plan.
1997
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
+
1998
2478
  async function planSkills(projectPath, manifest, options) {
1999
2479
  const repoInfo = getRepositoryInfo();
2000
2480
  const latestVersion = repoInfo.skills.version;
@@ -2159,7 +2639,7 @@ async function updateSkillsOnly(projectPath, manifest, options) {
2159
2639
  return;
2160
2640
  }
2161
2641
 
2162
- const spinner = ora(msg.installingSkills || 'Installing Skills...').start();
2642
+ const spinner = createSpinner(msg.installingSkills || 'Installing Skills...').start();
2163
2643
 
2164
2644
  const skillsLocaleForUpdate = resolveLocale(manifest, projectPath, options);
2165
2645
  const result = await installSkillsToMultipleAgents(
@@ -2283,7 +2763,7 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2283
2763
  }
2284
2764
  console.log();
2285
2765
 
2286
- const spinner = ora(msg.installingCommands || 'Installing commands...').start();
2766
+ const spinner = createSpinner(msg.installingCommands || 'Installing commands...').start();
2287
2767
 
2288
2768
  const commandsLocale = resolveLocale(manifest, projectPath, options);
2289
2769
  const result = await installCommandsToMultipleAgents(
@@ -2779,8 +3259,8 @@ async function handleRollback(projectPath) {
2779
3259
  /**
2780
3260
  * Handle --plan: show what the reconciler would do without executing.
2781
3261
  */
2782
- async function handlePlan(projectPath, options) {
2783
- const spinner = ora('Calculating reconciliation plan...').start();
3262
+ async function handlePlan(projectPath, options, manifest) {
3263
+ const spinner = createSpinner('Calculating reconciliation plan...').start();
2784
3264
 
2785
3265
  const result = await reconcilerPlan(projectPath, { force: false });
2786
3266
 
@@ -2796,6 +3276,12 @@ async function handlePlan(projectPath, options) {
2796
3276
  console.log(formatPlan(result.plan));
2797
3277
  console.log();
2798
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
+
2799
3285
  if (result.plan.actions.length > 0) {
2800
3286
  // NOT `uds update`. That runs the legacy path, which never executes this
2801
3287
  // plan — it refreshes existing standards and reports success for having done
@@ -2855,7 +3341,7 @@ async function handleReconcile(projectPath, options, { force }) {
2855
3341
  }
2856
3342
 
2857
3343
  // Execute
2858
- const spinner = ora('Applying reconciliation plan...').start();
3344
+ const spinner = createSpinner('Applying reconciliation plan...').start();
2859
3345
 
2860
3346
  const result = await reconcile(projectPath, {
2861
3347
  force,