universal-dev-standards 6.8.0 → 6.10.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 (225) hide show
  1. package/bin/uds.js +12 -2
  2. package/bundled/ai/standards/acceptance-criteria-traceability.ai.yaml +14 -2
  3. package/bundled/ai/standards/adr-standards.ai.yaml +14 -2
  4. package/bundled/ai/standards/code-review.ai.yaml +13 -3
  5. package/bundled/ai/standards/commit-message.ai.yaml +8 -4
  6. package/bundled/ai/standards/deferred-item-exit.ai.yaml +225 -0
  7. package/bundled/ai/standards/feature-discovery-standards.ai.yaml +14 -2
  8. package/bundled/ai/standards/governance-layer.ai.yaml +128 -2
  9. package/bundled/ai/standards/logging.ai.yaml +2 -2
  10. package/bundled/ai/standards/retrospective-standards.ai.yaml +14 -2
  11. package/bundled/ai/standards/reverse-engineering-standards.ai.yaml +73 -2
  12. package/bundled/ai/standards/security-standards.ai.yaml +2 -2
  13. package/bundled/ai/standards/spec-driven-development.ai.yaml +14 -2
  14. package/bundled/ai/standards/tech-debt-standards.ai.yaml +87 -3
  15. package/bundled/ai/standards/turn-completion-integrity.ai.yaml +131 -0
  16. package/bundled/core/acceptance-criteria-traceability.md +5 -2
  17. package/bundled/core/adr-standards.md +26 -2
  18. package/bundled/core/agent-communication-protocol.md +8 -0
  19. package/bundled/core/branch-completion.md +8 -0
  20. package/bundled/core/change-batching-standards.md +8 -0
  21. package/bundled/core/code-review-checklist.md +5 -2
  22. package/bundled/core/context-aware-loading.md +1 -1
  23. package/bundled/core/deferred-item-exit.md +254 -0
  24. package/bundled/core/execution-history.md +8 -0
  25. package/bundled/core/feature-discovery-standards.md +5 -1
  26. package/bundled/core/governance-layer.md +114 -2
  27. package/bundled/core/pipeline-integration-standards.md +8 -0
  28. package/bundled/core/retrospective-standards.md +4 -2
  29. package/bundled/core/reverse-engineering-standards.md +81 -2
  30. package/bundled/core/spec-driven-development.md +8 -2
  31. package/bundled/core/tech-debt-standards.md +67 -8
  32. package/bundled/core/turn-completion-integrity.md +196 -0
  33. package/bundled/core/workflow-enforcement.md +8 -0
  34. package/bundled/core/workflow-state-protocol.md +8 -0
  35. package/bundled/hooks/check-dangerous-cmd.mjs +60 -0
  36. package/bundled/hooks/check-logging-standard.mjs +59 -0
  37. package/bundled/hooks/check-turn-completion.mjs +233 -0
  38. package/bundled/hooks/inject-standards.mjs +183 -0
  39. package/bundled/hooks/telemetry-wrapper.mjs +77 -0
  40. package/bundled/hooks/turn-completion/detect.mjs +99 -0
  41. package/bundled/hooks/turn-completion/locales/en.mjs +159 -0
  42. package/bundled/hooks/turn-completion/locales/zh-TW.mjs +166 -0
  43. package/bundled/hooks/validate-commit-msg.mjs +104 -0
  44. package/bundled/locales/zh-CN/CHANGELOG.md +73 -3
  45. package/bundled/locales/zh-CN/CLAUDE.md +1 -1
  46. package/bundled/locales/zh-CN/README.md +2 -2
  47. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  48. package/bundled/locales/zh-CN/core/adr-standards.md +1 -1
  49. package/bundled/locales/zh-CN/core/agent-communication-protocol.md +7 -0
  50. package/bundled/locales/zh-CN/core/branch-completion.md +7 -0
  51. package/bundled/locales/zh-CN/core/change-batching-standards.md +7 -0
  52. package/bundled/locales/zh-CN/core/execution-history.md +7 -0
  53. package/bundled/locales/zh-CN/core/governance-layer.md +118 -6
  54. package/bundled/locales/zh-CN/core/pipeline-integration-standards.md +7 -0
  55. package/bundled/locales/zh-CN/core/retrospective-standards.md +1 -1
  56. package/bundled/locales/zh-CN/core/tech-debt-standards.md +71 -4
  57. package/bundled/locales/zh-CN/core/turn-completion-integrity.md +190 -0
  58. package/bundled/locales/zh-CN/core/workflow-enforcement.md +7 -0
  59. package/bundled/locales/zh-CN/core/workflow-state-protocol.md +7 -0
  60. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +8 -1
  61. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +29 -68
  62. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +25 -15
  63. package/bundled/locales/zh-CN/docs/MIGRATION-v6.md +8 -4
  64. package/bundled/locales/zh-CN/docs/USAGE-MODES-COMPARISON.md +1 -2
  65. package/bundled/locales/zh-CN/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
  66. package/bundled/locales/zh-CN/integrations/google-antigravity/README.md +3 -3
  67. package/bundled/locales/zh-CN/skills/atdd-assistant/SKILL.md +2 -0
  68. package/bundled/locales/zh-CN/skills/bdd-assistant/SKILL.md +2 -0
  69. package/bundled/locales/zh-CN/skills/brainstorm-assistant/SKILL.md +22 -12
  70. package/bundled/locales/zh-CN/skills/brainstorm-assistant/guide.md +12 -9
  71. package/bundled/locales/zh-CN/skills/code-review-assistant/SKILL.md +1 -0
  72. package/bundled/locales/zh-CN/skills/commands/brainstorm.md +17 -13
  73. package/bundled/locales/zh-CN/skills/commands/config.md +0 -1
  74. package/bundled/locales/zh-CN/skills/commands/init.md +1 -2
  75. package/bundled/locales/zh-CN/skills/commit-standards/SKILL.md +2 -0
  76. package/bundled/locales/zh-CN/skills/contract-test-assistant/SKILL.md +1 -0
  77. package/bundled/locales/zh-CN/skills/dev-methodology/SKILL.md +2 -0
  78. package/bundled/locales/zh-CN/skills/observability-assistant/SKILL.md +1 -0
  79. package/bundled/locales/zh-CN/skills/project-structure-guide/SKILL.md +1 -0
  80. package/bundled/locales/zh-CN/skills/release-standards/SKILL.md +3 -0
  81. package/bundled/locales/zh-CN/skills/requirement-assistant/SKILL.md +2 -0
  82. package/bundled/locales/zh-CN/skills/reverse-engineer/SKILL.md +3 -0
  83. package/bundled/locales/zh-CN/skills/runbook-assistant/SKILL.md +1 -0
  84. package/bundled/locales/zh-CN/skills/slo-assistant/SKILL.md +1 -0
  85. package/bundled/locales/zh-CN/skills/tdd-assistant/SKILL.md +2 -0
  86. package/bundled/locales/zh-TW/CHANGELOG.md +74 -3
  87. package/bundled/locales/zh-TW/CLAUDE.md +1 -1
  88. package/bundled/locales/zh-TW/README.md +2 -2
  89. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  90. package/bundled/locales/zh-TW/core/acceptance-criteria-traceability.md +2 -0
  91. package/bundled/locales/zh-TW/core/adr-standards.md +26 -5
  92. package/bundled/locales/zh-TW/core/agent-communication-protocol.md +7 -0
  93. package/bundled/locales/zh-TW/core/branch-completion.md +7 -0
  94. package/bundled/locales/zh-TW/core/change-batching-standards.md +7 -0
  95. package/bundled/locales/zh-TW/core/code-review-checklist.md +2 -0
  96. package/bundled/locales/zh-TW/core/container-image-standards.md +2 -2
  97. package/bundled/locales/zh-TW/core/contract-testing-standards.md +2 -2
  98. package/bundled/locales/zh-TW/core/cross-flow-regression.md +8 -7
  99. package/bundled/locales/zh-TW/core/data-contract.md +2 -2
  100. package/bundled/locales/zh-TW/core/data-migration-testing.md +2 -2
  101. package/bundled/locales/zh-TW/core/data-pipeline.md +2 -2
  102. package/bundled/locales/zh-TW/core/deferred-item-exit.md +251 -0
  103. package/bundled/locales/zh-TW/core/documentation-writing-standards.md +228 -3
  104. package/bundled/locales/zh-TW/core/execution-history.md +7 -0
  105. package/bundled/locales/zh-TW/core/full-coverage-testing.md +15 -2
  106. package/bundled/locales/zh-TW/core/governance-layer.md +118 -5
  107. package/bundled/locales/zh-TW/core/iac-design-principles.md +2 -2
  108. package/bundled/locales/zh-TW/core/incident-response.md +2 -2
  109. package/bundled/locales/zh-TW/core/model-provenance.md +4 -2
  110. package/bundled/locales/zh-TW/core/pii-classification.md +42 -6
  111. package/bundled/locales/zh-TW/core/pipeline-integration-standards.md +7 -0
  112. package/bundled/locales/zh-TW/core/prd-standards.md +4 -2
  113. package/bundled/locales/zh-TW/core/product-metrics-standards.md +4 -2
  114. package/bundled/locales/zh-TW/core/release-readiness-gate.md +2 -2
  115. package/bundled/locales/zh-TW/core/resource-cost-boundary.md +2 -2
  116. package/bundled/locales/zh-TW/core/retrospective-standards.md +5 -3
  117. package/bundled/locales/zh-TW/core/reverse-engineering-standards.md +83 -5
  118. package/bundled/locales/zh-TW/core/runbook.md +2 -2
  119. package/bundled/locales/zh-TW/core/schema-evolution.md +2 -2
  120. package/bundled/locales/zh-TW/core/secret-management-standards.md +2 -2
  121. package/bundled/locales/zh-TW/core/slo-sli.md +2 -2
  122. package/bundled/locales/zh-TW/core/spec-driven-development.md +2 -0
  123. package/bundled/locales/zh-TW/core/tech-debt-standards.md +71 -4
  124. package/bundled/locales/zh-TW/core/turn-completion-integrity.md +190 -0
  125. package/bundled/locales/zh-TW/core/user-journey-testing.md +2 -2
  126. package/bundled/locales/zh-TW/core/user-story-mapping.md +2 -2
  127. package/bundled/locales/zh-TW/core/verification-oracle.md +2 -2
  128. package/bundled/locales/zh-TW/core/workflow-enforcement.md +7 -0
  129. package/bundled/locales/zh-TW/core/workflow-state-protocol.md +7 -0
  130. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +8 -1
  131. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +29 -68
  132. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +25 -15
  133. package/bundled/locales/zh-TW/docs/MIGRATION-v6.md +8 -4
  134. package/bundled/locales/zh-TW/docs/USAGE-MODES-COMPARISON.md +1 -2
  135. package/bundled/locales/zh-TW/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
  136. package/bundled/locales/zh-TW/integrations/google-antigravity/README.md +3 -3
  137. package/bundled/locales/zh-TW/skills/adr-assistant/SKILL.md +1 -1
  138. package/bundled/locales/zh-TW/skills/atdd-assistant/SKILL.md +2 -0
  139. package/bundled/locales/zh-TW/skills/bdd-assistant/SKILL.md +2 -0
  140. package/bundled/locales/zh-TW/skills/brainstorm-assistant/SKILL.md +22 -12
  141. package/bundled/locales/zh-TW/skills/brainstorm-assistant/guide.md +12 -9
  142. package/bundled/locales/zh-TW/skills/code-review-assistant/SKILL.md +1 -0
  143. package/bundled/locales/zh-TW/skills/commands/brainstorm.md +17 -13
  144. package/bundled/locales/zh-TW/skills/commands/config.md +0 -1
  145. package/bundled/locales/zh-TW/skills/commands/init.md +1 -2
  146. package/bundled/locales/zh-TW/skills/commit-standards/SKILL.md +2 -0
  147. package/bundled/locales/zh-TW/skills/contract-test-assistant/SKILL.md +2 -1
  148. package/bundled/locales/zh-TW/skills/dev-methodology/SKILL.md +2 -0
  149. package/bundled/locales/zh-TW/skills/dev-workflow-guide/SKILL.md +1 -1
  150. package/bundled/locales/zh-TW/skills/knowledge-graph/guide.md +2 -2
  151. package/bundled/locales/zh-TW/skills/migration-assistant/SKILL.md +1 -1
  152. package/bundled/locales/zh-TW/skills/observability-assistant/SKILL.md +1 -0
  153. package/bundled/locales/zh-TW/skills/project-discovery/SKILL.md +1 -0
  154. package/bundled/locales/zh-TW/skills/project-structure-guide/SKILL.md +1 -0
  155. package/bundled/locales/zh-TW/skills/release-standards/SKILL.md +3 -0
  156. package/bundled/locales/zh-TW/skills/requirement-assistant/SKILL.md +2 -0
  157. package/bundled/locales/zh-TW/skills/reverse-engineer/SKILL.md +3 -0
  158. package/bundled/locales/zh-TW/skills/runbook-assistant/SKILL.md +1 -0
  159. package/bundled/locales/zh-TW/skills/slo-assistant/SKILL.md +1 -0
  160. package/bundled/locales/zh-TW/skills/tdd-assistant/SKILL.md +2 -0
  161. package/bundled/skills/atdd-assistant/SKILL.md +2 -0
  162. package/bundled/skills/bdd-assistant/SKILL.md +2 -0
  163. package/bundled/skills/brainstorm-assistant/SKILL.md +31 -13
  164. package/bundled/skills/brainstorm-assistant/guide.md +9 -6
  165. package/bundled/skills/code-review-assistant/SKILL.md +1 -0
  166. package/bundled/skills/commands/brainstorm.md +12 -9
  167. package/bundled/skills/commands/config.md +0 -1
  168. package/bundled/skills/commands/init.md +2 -3
  169. package/bundled/skills/commit-standards/SKILL.md +2 -0
  170. package/bundled/skills/contract-test-assistant/SKILL.md +1 -0
  171. package/bundled/skills/dev-methodology/SKILL.md +4 -0
  172. package/bundled/skills/observability-assistant/SKILL.md +1 -0
  173. package/bundled/skills/project-discovery/SKILL.md +1 -0
  174. package/bundled/skills/project-structure-guide/SKILL.md +1 -0
  175. package/bundled/skills/release-standards/SKILL.md +3 -0
  176. package/bundled/skills/requirement-assistant/SKILL.md +2 -0
  177. package/bundled/skills/reverse-engineer/SKILL.md +3 -0
  178. package/bundled/skills/runbook-assistant/SKILL.md +1 -0
  179. package/bundled/skills/slo-assistant/SKILL.md +1 -0
  180. package/bundled/skills/tdd-assistant/SKILL.md +2 -0
  181. package/bundled/templates/.ai-context.yaml.template +194 -0
  182. package/bundled/templates/CLAUDE.md.template +145 -0
  183. package/bundled/templates/DESIGN.md +237 -0
  184. package/bundled/templates/SKILL-BRIEF-TEMPLATE.md +57 -0
  185. package/bundled/templates/SKILL-CANDIDATES.md +39 -0
  186. package/bundled/templates/gates/check-error-exit.mjs +309 -0
  187. package/bundled/templates/mcp-config.json +10 -0
  188. package/bundled/templates/methodology-template.yaml +209 -0
  189. package/bundled/templates/migration-template.md +408 -0
  190. package/bundled/templates/requirement-checklist.md +410 -0
  191. package/bundled/templates/requirement-document-template.md +591 -0
  192. package/bundled/templates/requirement-template.md +881 -0
  193. package/bundled/templates/reverse-spec-template.md +409 -0
  194. package/bundled/templates/test-case-template.md +74 -0
  195. package/bundled/templates/test-plan-template.md +74 -0
  196. package/package.json +12 -9
  197. package/src/commands/audit.js +82 -0
  198. package/src/commands/check.js +227 -34
  199. package/src/commands/config.js +19 -19
  200. package/src/commands/init.js +174 -20
  201. package/src/commands/spec.js +2 -2
  202. package/src/commands/update.js +537 -43
  203. package/src/compilers/claude-code-compiler.js +4 -1
  204. package/src/config/ai-agent-paths.js +62 -17
  205. package/src/core/constants.js +42 -11
  206. package/src/core/manifest.js +201 -3
  207. package/src/core/paths.js +2 -2
  208. package/src/i18n/messages.js +42 -29
  209. package/src/installers/hooks-installer.js +167 -75
  210. package/src/installers/integration-installer.js +13 -9
  211. package/src/installers/skills-installer.js +4 -4
  212. package/src/installers/standards-installer.js +3 -3
  213. package/src/prompts/init.js +47 -18
  214. package/src/reconciler/diff-engine.js +57 -2
  215. package/src/reconciler/plan-executor.js +31 -11
  216. package/src/utils/detector.js +21 -1
  217. package/src/utils/effect-boundary.js +1093 -0
  218. package/src/utils/hasher.js +166 -1
  219. package/src/utils/hook-stats.js +1 -1
  220. package/src/utils/integration-generator.js +288 -82
  221. package/src/utils/reference-sync.js +107 -8
  222. package/src/utils/registry.js +57 -0
  223. package/src/utils/spinner.js +31 -0
  224. package/src/utils/yaml-generator.js +51 -9
  225. package/standards-registry.json +32 -9
@@ -1,12 +1,12 @@
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
- import { existsSync, readFileSync, unlinkSync } from 'fs';
6
- import { join, basename } from 'path';
7
- import { readManifest, writeManifest, copyStandard, isInitialized } from '../utils/copier.js';
8
- import { getRepositoryInfo, getAllStandards, getStandardSource } from '../utils/registry.js';
9
- import { computeFileHash, scanForUntrackedFiles, refreshIntegrationBlockHashes } from '../utils/hasher.js';
5
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
6
+ import { join, basename, dirname, relative } from 'path';
7
+ import { readManifest, writeManifest, copyStandard, isInitialized, getRepoRoot } from '../utils/copier.js';
8
+ import { getRepositoryInfo, getAllStandards, getShippableFilenames, getStandardSource } from '../utils/registry.js';
9
+ import { computeFileHash, planStandardsRemovals, refreshIntegrationBlockHashes } from '../utils/hasher.js';
10
10
  import {
11
11
  writeIntegrationFile,
12
12
  getToolFilePath,
@@ -42,6 +42,7 @@ import {
42
42
  getCommandsDirForAgent
43
43
  } from '../config/ai-agent-paths.js';
44
44
  import { getMarketplaceSkillsInfo } from '../utils/github.js';
45
+ import { detectAITools } from '../utils/detector.js';
45
46
  import {
46
47
  promptSkillsInstallLocation,
47
48
  promptCommandsInstallation
@@ -55,8 +56,111 @@ import {
55
56
  } from '../reconciler/index.js';
56
57
  import { restoreSingleFile } from './check.js';
57
58
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
58
- import { resolveIntegrationFile } from '../core/constants.js';
59
- import { mergeInstalledNames } from '../core/manifest.js';
59
+ import { resolveIntegrationFile, SUPPORTED_AI_TOOLS } from '../core/constants.js';
60
+ import {
61
+ mergeInstalledNames,
62
+ recordFileProvenance,
63
+ forgetFileProvenance,
64
+ establishProvenance,
65
+ isProvenanceEstablished
66
+ } from '../core/manifest.js';
67
+
68
+ /**
69
+ * How aggressive `uds update` is about deleting standards it no longer ships.
70
+ *
71
+ * XSPEC-384 §4 leaves the staging strategy for R2 undecided, so this is the
72
+ * most conservative of the three options on the table and it lives in one named
73
+ * place rather than being spread through the deletion path. Changing this
74
+ * constant is the whole change of default; the alternatives are:
75
+ *
76
+ * 'report-only' list what would go, delete nothing without --prune (current)
77
+ * 'ask' list, and prompt in interactive sessions only
78
+ * 'auto' delete as part of a normal update
79
+ *
80
+ * It starts at 'report-only' because the change is adopter-visible in the
81
+ * dangerous direction: files that survive today start disappearing. A default
82
+ * that deletes must not arrive in a patch release, and must not arrive at all
83
+ * before someone decides it should.
84
+ */
85
+ export const PRUNE_POLICY = 'report-only';
86
+
87
+ /**
88
+ * Decide whether this run is allowed to delete.
89
+ *
90
+ * `--prune` is deliberately its own flag rather than a use of `--yes`. `--yes`
91
+ * means "don't ask me about the thing I already asked for"; it is set in CI and
92
+ * in scripts, and letting it also authorise deletion would enable the
93
+ * destructive path silently in exactly the unattended environments that cannot
94
+ * notice it. Non-interactive runs therefore never delete unless --prune is
95
+ * explicit. (XSPEC-384 R3)
96
+ *
97
+ * @param {Object} plan - Result of planStandardsRemovals
98
+ * @param {Object} options - Command options
99
+ * @returns {boolean}
100
+ */
101
+ export function shouldPrune(plan, options = {}) {
102
+ if (plan.remove.length === 0) return false;
103
+ if (options.prune) return true;
104
+ return PRUNE_POLICY === 'auto';
105
+ }
106
+
107
+ /**
108
+ * Print the removal plan, including its denominator. (XSPEC-384 R3)
109
+ *
110
+ * The counts are not decoration. A report that lists only the files it intends
111
+ * to touch is bit-for-bit identical to a report from a scan that only *saw*
112
+ * those files — which is the failure the previous code shipped for months,
113
+ * silently, because nothing ever printed how many files it had considered.
114
+ *
115
+ * @param {Object} plan - Result of planStandardsRemovals
116
+ * @param {boolean} provenanceWasEstablished - Whether ownership was already known
117
+ * @param {boolean} willDelete - Whether this run is going to act on the plan
118
+ */
119
+ export function reportRemovalPlan(plan, provenanceWasEstablished, willDelete = false) {
120
+ if (plan.scanned === 0) return;
121
+
122
+ console.log();
123
+ console.log(chalk.gray(
124
+ ` .standards/ examined: ${plan.scanned} file(s) — ` +
125
+ `${plan.census.udsOwned} written by UDS, ` +
126
+ `${plan.census.foreign} not ours, ` +
127
+ `${plan.census.unknown} ownership unknown, ` +
128
+ `${plan.excluded.length} excluded`
129
+ ));
130
+ for (const { path: p, reason } of plan.excluded) {
131
+ console.log(chalk.gray(` excluded ${p} (${reason})`));
132
+ }
133
+
134
+ if (!provenanceWasEstablished) {
135
+ console.log(chalk.gray(
136
+ ' first run with ownership tracking: files installed before this ' +
137
+ 'release cannot be attributed yet, so none will be removed.'
138
+ ));
139
+ }
140
+
141
+ if (plan.remove.length === 0) {
142
+ if (plan.census.foreign > 0 || plan.census.unknown > 0) {
143
+ console.log(chalk.gray(
144
+ ` kept ${plan.census.foreign + plan.census.unknown} file(s) UDS did not write`
145
+ ));
146
+ }
147
+ return;
148
+ }
149
+
150
+ console.log();
151
+ console.log(chalk.yellow(
152
+ ` ${plan.remove.length} file(s) are no longer shipped by UDS` +
153
+ (willDelete ? ' and will be removed:' : ':')
154
+ ));
155
+ for (const { path: p, reason } of plan.remove) {
156
+ console.log(chalk.yellow(` - ${p} (${reason})`));
157
+ }
158
+ console.log(chalk.gray(
159
+ willDelete
160
+ ? ' Removing now (--prune). Files UDS did not write are never touched.'
161
+ : ' Not deleted. Re-run with --prune to remove them.'
162
+ ));
163
+ }
60
164
 
61
165
  /**
62
166
  * Determine the correct target directory for a standard file.
@@ -170,7 +274,7 @@ function buildInstallCommand(pm, tag) {
170
274
  */
171
275
  async function updateCliAndExit(useBeta = false) {
172
276
  const msg = t().commands.update;
173
- const spinner = ora(msg.updatingCli).start();
277
+ const spinner = createSpinner(msg.updatingCli).start();
174
278
 
175
279
  try {
176
280
  // Command is hardcoded - no user input, safe from injection
@@ -477,6 +581,23 @@ export async function updateCommand(options) {
477
581
  if (versionComparison > 0) {
478
582
  console.log(chalk.gray(` ${msg.newerVersion.replace('{version}', currentVersion)}`));
479
583
  }
584
+
585
+ // Still forget records of files that are gone and no longer shipped. An
586
+ // adopter who followed MIGRATION-v6 §2 is on the current version by
587
+ // definition — telling them to run `uds update --prune` and then returning
588
+ // before anything is examined is how the complaint became unclearable.
589
+ const retiredOnLatest = pruneRetiredHashes(manifest, projectPath);
590
+ if (retiredOnLatest.length > 0) {
591
+ console.log();
592
+ console.log(chalk.gray(
593
+ ` ${(msg.droppedRetiredHashes || 'Dropped {count} manifest record(s) for files UDS no longer ships and that are already gone:').replace('{count}', retiredOnLatest.length)}`
594
+ ));
595
+ for (const path of retiredOnLatest) {
596
+ console.log(chalk.gray(` - ${path}`));
597
+ }
598
+ writeManifest(manifest, projectPath);
599
+ }
600
+
480
601
  console.log();
481
602
  return;
482
603
  }
@@ -538,7 +659,7 @@ export async function updateCommand(options) {
538
659
 
539
660
  // Perform update
540
661
  console.log();
541
- const spinner = ora(msg.updatingStandards).start();
662
+ const spinner = createSpinner(msg.updatingStandards).start();
542
663
 
543
664
  const results = {
544
665
  updated: [],
@@ -546,6 +667,21 @@ export async function updateCommand(options) {
546
667
  errors: []
547
668
  };
548
669
 
670
+ // Paths UDS actually wrote this run, taken from the copy result rather than
671
+ // recomputed from the source name. Recomputing is how `fileHashes` ended up
672
+ // with five `.standards/options/<name>.ai.yaml` entries for files that live
673
+ // at `.standards/options/<category>/<name>.ai.yaml`; a provenance record
674
+ // built the same way would claim files that do not exist and disown ones
675
+ // that do. (XSPEC-384 R1)
676
+ const writtenPaths = [];
677
+ const recordWritten = (result) => {
678
+ if (!result?.success || !result.data) return;
679
+ const abs = typeof result.data === 'string' ? result.data : null;
680
+ if (!abs) return;
681
+ const rel = relative(projectPath, abs).replace(/\\/g, '/');
682
+ if (rel && !rel.startsWith('..')) writtenPaths.push(rel);
683
+ };
684
+
549
685
  // Update standards
550
686
  const updateFormat = manifest.format || 'ai';
551
687
  const allStdsUpdate = getAllStandards();
@@ -561,6 +697,7 @@ export async function updateCommand(options) {
561
697
  const result = await copyStandard(sourcePath, getStandardTargetDir(sourcePath), projectPath);
562
698
  if (result.success) {
563
699
  results.updated.push(std);
700
+ recordWritten(result);
564
701
  } else {
565
702
  results.errors.push(`${std}: ${result.error}`);
566
703
  }
@@ -572,6 +709,7 @@ export async function updateCommand(options) {
572
709
  const result = await copyStandard(ext, '.standards', projectPath);
573
710
  if (result.success) {
574
711
  results.updated.push(ext);
712
+ recordWritten(result);
575
713
  } else {
576
714
  results.errors.push(`${ext}: ${result.error}`);
577
715
  }
@@ -593,7 +731,7 @@ export async function updateCommand(options) {
593
731
  }
594
732
 
595
733
  if (shouldInstallNew) {
596
- const newSpinner = ora(msg.installingNewStandards).start();
734
+ const newSpinner = createSpinner(msg.installingNewStandards).start();
597
735
  let newCount = 0;
598
736
 
599
737
  for (const ns of newStandards) {
@@ -601,6 +739,7 @@ export async function updateCommand(options) {
601
739
  if (result.success) {
602
740
  manifest.standards.push(ns.source);
603
741
  results.updated.push(ns.source);
742
+ recordWritten(result);
604
743
  newCount++;
605
744
  } else {
606
745
  results.errors.push(`${ns.source}: ${result.error}`);
@@ -613,7 +752,7 @@ export async function updateCommand(options) {
613
752
 
614
753
  // Update integrations (unless --standards-only)
615
754
  if (!options.standardsOnly && manifest.integrations && manifest.integrations.length > 0) {
616
- const intSpinner = ora(msg.syncingIntegrations).start();
755
+ const intSpinner = createSpinner(msg.syncingIntegrations).start();
617
756
 
618
757
  // Build installed standards list
619
758
  // Raw, not basename()d. Resolution needs the registry (an ID is not a
@@ -691,6 +830,7 @@ export async function updateCommand(options) {
691
830
  installedAt: new Date().toISOString()
692
831
  };
693
832
  }
833
+ refreshTrackedFileHash(manifest, projectPath, agentsMdResult.path);
694
834
  }
695
835
  }
696
836
 
@@ -751,6 +891,20 @@ export async function updateCommand(options) {
751
891
  manifest.fileHashes = {};
752
892
  }
753
893
 
894
+ // The set of `.standards/` files the *current* registry says should exist.
895
+ //
896
+ // Built by accumulating inside the loops that already resolve each entry,
897
+ // rather than by a second function that re-derives the same paths. A second
898
+ // derivation is a second copy, and the two would answer "where does this
899
+ // standard live" differently the moment either is edited — which is the
900
+ // failure mode XSPEC-384 is about, not a shape to reproduce. (XSPEC-384 R2)
901
+ const desiredStandardsFiles = new Set();
902
+
903
+ // Standards whose ID no longer resolves against the registry. Collected here
904
+ // instead of being silently `continue`d, because "skipped" and "deleted
905
+ // upstream" were previously indistinguishable — issue #165. (XSPEC-384 R2)
906
+ const unresolvedStandardIds = [];
907
+
754
908
  // Update hashes for standards (handles both ID format and legacy path format)
755
909
  const hashFormat = manifest.format || 'ai';
756
910
  const allStdsHash = getAllStandards();
@@ -759,7 +913,13 @@ export async function updateCommand(options) {
759
913
  let resolvedPath = std;
760
914
  if (!std.includes('/') && !std.includes('.')) {
761
915
  const entry = allStdsHash.find(r => r.id === std);
762
- if (!entry) continue; // Skip unrecognized IDs (e.g. stale AI tool names)
916
+ if (!entry) {
917
+ // Unrecognized ID: either a standard removed from the registry, or a
918
+ // stale AI tool name an old CLI wrote into `standards`. Either way it
919
+ // is no longer a thing we ship, so it must not enter the desired set.
920
+ unresolvedStandardIds.push(std);
921
+ continue;
922
+ }
763
923
  const src = getStandardSource(entry, hashFormat);
764
924
  if (src) resolvedPath = src;
765
925
  }
@@ -767,6 +927,10 @@ export async function updateCommand(options) {
767
927
  const relativePath = (resolvedPath.includes('options/')
768
928
  ? join('.standards', 'options', fileName)
769
929
  : join('.standards', fileName)).replace(/\\/g, '/');
930
+ // Added before the hash check: a file that exists but failed to hash is
931
+ // still wanted. Nominating it for deletion because hashing failed would
932
+ // turn a transient read error into data loss.
933
+ desiredStandardsFiles.add(relativePath);
770
934
  const fullPath = join(projectPath, relativePath);
771
935
  const hashInfo = computeFileHash(fullPath);
772
936
  if (hashInfo) {
@@ -779,6 +943,7 @@ export async function updateCommand(options) {
779
943
  if (typeof ext !== 'string') continue;
780
944
  const fileName = basename(ext);
781
945
  const relativePath = join('.standards', fileName).replace(/\\/g, '/');
946
+ desiredStandardsFiles.add(relativePath);
782
947
  const fullPath = join(projectPath, relativePath);
783
948
  const hashInfo = computeFileHash(fullPath);
784
949
  if (hashInfo) {
@@ -795,21 +960,79 @@ export async function updateCommand(options) {
795
960
  }
796
961
  }
797
962
 
798
- // Clean up orphan files in .standards/ that are no longer tracked
799
- // This handles files renamed or removed between versions
800
- const orphanFiles = scanForUntrackedFiles(projectPath, manifest);
801
- const standardsOrphans = orphanFiles.filter(f => f.startsWith('.standards/'));
802
- if (standardsOrphans.length > 0) {
803
- for (const orphan of standardsOrphans) {
963
+ // Record what UDS wrote this run, then decide what (if anything) may go.
964
+ // (XSPEC-384 R1/R2/R3)
965
+ const provenanceWasEstablished = isProvenanceEstablished(manifest);
966
+ let provenanceCarrier = manifest;
967
+ for (const written of [...writtenPaths, ...results.integrations]) {
968
+ provenanceCarrier = recordFileProvenance(provenanceCarrier, written, 'update');
969
+ }
970
+ manifest.provenance = provenanceCarrier.provenance;
971
+
972
+ // Drop `standards` entries the registry no longer knows about. Previously
973
+ // these were `continue`d in three separate loops and left in the manifest
974
+ // forever, so `upstream.version` advanced to "latest" while the manifest
975
+ // still listed standards that had not existed for releases. (issue #165)
976
+ if (unresolvedStandardIds.length > 0) {
977
+ manifest.standards = manifest.standards.filter(s => !unresolvedStandardIds.includes(s));
978
+ console.log();
979
+ console.log(chalk.yellow(
980
+ `⚠ ${unresolvedStandardIds.length} manifest entr${unresolvedStandardIds.length === 1 ? 'y is' : 'ies are'} no longer in the registry and ${unresolvedStandardIds.length === 1 ? 'was' : 'were'} dropped:`
981
+ ));
982
+ for (const id of unresolvedStandardIds) {
983
+ console.log(chalk.gray(` - ${id}`));
984
+ }
985
+ }
986
+
987
+ const removalPlan = planStandardsRemovals(projectPath, manifest, desiredStandardsFiles);
988
+ // Decide before reporting, so the report states what this run will actually
989
+ // do. Reporting first meant printing "Not deleted. Re-run with --prune" on a
990
+ // run that was about to delete — a message contradicting the very action it
991
+ // preceded, which is worse than no message.
992
+ const mayPrune = shouldPrune(removalPlan, options);
993
+ reportRemovalPlan(removalPlan, provenanceWasEstablished, mayPrune);
994
+
995
+ if (mayPrune && removalPlan.remove.length > 0) {
996
+ for (const { path: orphan } of removalPlan.remove) {
804
997
  try {
805
998
  unlinkSync(join(projectPath, orphan));
806
999
  results.updated.push(`removed: ${orphan}`);
1000
+ delete manifest.fileHashes[orphan];
1001
+ // Deleting the file is how we give up the claim on that path, so a
1002
+ // file the user later creates under the same name is not inherited.
1003
+ manifest.provenance = forgetFileProvenance(manifest, orphan).provenance;
807
1004
  } catch {
808
1005
  // Ignore removal errors
809
1006
  }
810
1007
  }
811
1008
  }
812
1009
 
1010
+ // Records of files that are already gone and that UDS no longer ships. Not
1011
+ // part of the removal plan above: that plan walks what is on disk, and these
1012
+ // are precisely the ones that are not. Reported rather than done quietly,
1013
+ // because a manifest shrinking without explanation is the shape this whole
1014
+ // release is about.
1015
+ const retiredHashes = pruneRetiredHashes(manifest, projectPath);
1016
+ if (retiredHashes.length > 0) {
1017
+ console.log();
1018
+ console.log(chalk.gray(
1019
+ ` ${(msg.droppedRetiredHashes || 'Dropped {count} manifest record(s) for files UDS no longer ships and that are already gone:').replace('{count}', retiredHashes.length)}`
1020
+ ));
1021
+ for (const path of retiredHashes) {
1022
+ console.log(chalk.gray(` - ${path}`));
1023
+ }
1024
+ }
1025
+
1026
+ // Provenance is only a complete account of what we own once a run has
1027
+ // written the whole installed set. Until then every unrecorded file is
1028
+ // `unknown` rather than `foreign`, and nothing gets deleted on the strength
1029
+ // of an absent record. Establishing it here — after the writes, not before —
1030
+ // is what makes the first upgrade conservative by construction rather than
1031
+ // by a flag someone has to remember. (XSPEC-384 §4)
1032
+ if (results.errors.length === 0) {
1033
+ manifest.provenance = establishProvenance(manifest).provenance;
1034
+ }
1035
+
813
1036
  // Migrate display_language: if manifest options is missing display_language,
814
1037
  // attempt to backfill from ~/.udsrc ui.language so the project honours the
815
1038
  // language the user already configured globally.
@@ -921,7 +1144,7 @@ export async function updateCommand(options) {
921
1144
  }
922
1145
 
923
1146
  if (shouldRestore) {
924
- const restoreSpinner = ora((msg.restoringMissing || 'Restoring missing files...')).start();
1147
+ const restoreSpinner = createSpinner((msg.restoringMissing || 'Restoring missing files...')).start();
925
1148
  const checkMsg = t().commands.check;
926
1149
  let restoredCount = 0;
927
1150
 
@@ -988,7 +1211,7 @@ export async function updateCommand(options) {
988
1211
 
989
1212
  // Install Skills if user agreed
990
1213
  if (installSkills.length > 0) {
991
- const skillSpinner = ora(msg.installingNewSkills || 'Installing Skills...').start();
1214
+ const skillSpinner = createSpinner(msg.installingNewSkills || 'Installing Skills...').start();
992
1215
  const skillsLocale = resolveLocale(manifest, projectPath, options);
993
1216
  const skillResult = await installSkillsToMultipleAgents(installSkills, null, projectPath, skillsLocale);
994
1217
 
@@ -1036,7 +1259,7 @@ export async function updateCommand(options) {
1036
1259
 
1037
1260
  // Update outdated Skills if user agreed
1038
1261
  if (updateSkills.length > 0) {
1039
- const updateSpinner = ora(msg.updatingSkills || 'Updating Skills...').start();
1262
+ const updateSpinner = createSpinner(msg.updatingSkills || 'Updating Skills...').start();
1040
1263
  const updateLocale = resolveLocale(manifest, projectPath, options);
1041
1264
  const updateResult = await installSkillsToMultipleAgents(updateSkills, null, projectPath, updateLocale);
1042
1265
 
@@ -1074,7 +1297,7 @@ export async function updateCommand(options) {
1074
1297
 
1075
1298
  // Install Commands if user agreed
1076
1299
  if (installCommands.length > 0) {
1077
- const cmdSpinner = ora(msg.installingNewCommands || 'Installing commands...').start();
1300
+ const cmdSpinner = createSpinner(msg.installingNewCommands || 'Installing commands...').start();
1078
1301
  const cmdLocale = resolveLocale(manifest, projectPath, options);
1079
1302
  const cmdResult = await installCommandsToMultipleAgents(installCommands, null, projectPath, cmdLocale);
1080
1303
 
@@ -1105,7 +1328,7 @@ export async function updateCommand(options) {
1105
1328
 
1106
1329
  // Update outdated Commands if user agreed
1107
1330
  if (updateCommands.length > 0) {
1108
- const updateCmdSpinner = ora(msg.updatingCommands || 'Updating Commands...').start();
1331
+ const updateCmdSpinner = createSpinner(msg.updatingCommands || 'Updating Commands...').start();
1109
1332
  const updateCmdLocale = resolveLocale(manifest, projectPath, options);
1110
1333
  const updateCmdResult = await installCommandsToMultipleAgents(updateCommands, null, projectPath, updateCmdLocale);
1111
1334
 
@@ -1181,7 +1404,7 @@ export async function updateCommand(options) {
1181
1404
  let hasChanges = false;
1182
1405
 
1183
1406
  if (missingSkills.length > 0) {
1184
- const spinner = ora(msg.installingNewSkills || 'Installing Skills...').start();
1407
+ const spinner = createSpinner(msg.installingNewSkills || 'Installing Skills...').start();
1185
1408
  const result = await installSkillsToMultipleAgents(missingSkills, null, projectPath, skillsLocale);
1186
1409
  if (!manifest.skills) manifest.skills = {};
1187
1410
  manifest.skills.installed = true;
@@ -1202,7 +1425,7 @@ export async function updateCommand(options) {
1202
1425
  }
1203
1426
 
1204
1427
  if (outdatedSkills.length > 0) {
1205
- const spinner = ora(msg.updatingSkills || 'Updating Skills...').start();
1428
+ const spinner = createSpinner(msg.updatingSkills || 'Updating Skills...').start();
1206
1429
  const result = await installSkillsToMultipleAgents(outdatedSkills, null, projectPath, skillsLocale);
1207
1430
  if (!manifest.skills) manifest.skills = {};
1208
1431
  manifest.skills.version = repoInfo.skills.version;
@@ -1226,7 +1449,7 @@ export async function updateCommand(options) {
1226
1449
  }
1227
1450
 
1228
1451
  if (missingCommands.length > 0) {
1229
- const spinner = ora(msg.installingNewCommands || 'Installing commands...').start();
1452
+ const spinner = createSpinner(msg.installingNewCommands || 'Installing commands...').start();
1230
1453
  const result = await installCommandsToMultipleAgents(missingCommands, null, projectPath, skillsLocale);
1231
1454
  if (!manifest.commands) manifest.commands = {};
1232
1455
  manifest.commands.installed = true;
@@ -1246,7 +1469,7 @@ export async function updateCommand(options) {
1246
1469
  }
1247
1470
 
1248
1471
  if (outdatedCommands.length > 0) {
1249
- const spinner = ora(msg.updatingCommands || 'Updating Commands...').start();
1472
+ const spinner = createSpinner(msg.updatingCommands || 'Updating Commands...').start();
1250
1473
  const result = await installCommandsToMultipleAgents(outdatedCommands, null, projectPath, skillsLocale);
1251
1474
  if (!manifest.commands) manifest.commands = {};
1252
1475
  manifest.commands.version = repoInfo.skills.version;
@@ -1366,11 +1589,58 @@ export async function updateCommand(options) {
1366
1589
 
1367
1590
  console.log();
1368
1591
 
1592
+ // 錯誤訊息單一出口閘門:問過才寫,而且從不覆寫。
1593
+ await offerErrorExitGate(projectPath, options);
1594
+
1369
1595
  // Exit explicitly to prevent hanging.
1370
1596
  // T11: a partial update exits non-zero so CI / scripts can detect the failure.
1371
1597
  process.exit(updateIncomplete ? 1 : 0);
1372
1598
  }
1373
1599
 
1600
+ /**
1601
+ * 提供錯誤訊息單一出口閘門——**顯示它是什麼、問過、才寫,而且絕不覆寫既有檔案。**
1602
+ *
1603
+ * 🔴 為什麼不自動寫:`uds` 是別人裝進自己 repo 的工具。未經同意往陌生人的專案
1604
+ * 寫一支可執行腳本,比它要修的那個問題更糟——那會變成信任問題,而不是 bug。
1605
+ * 採用者說不要,我們就不寫,而且下次 `uds check` 只會再提一次,不會偷偷放進去。
1606
+ *
1607
+ * ⚠️ 已存在就直接跳過,連問都不問:那個檔案裡的 CONFIG 是採用者自己填的,
1608
+ * 我們沒有立場拿範本蓋掉它。要更新內容是另一件事,不在這裡做。
1609
+ */
1610
+ async function offerErrorExitGate(projectPath, options) {
1611
+ const dest = join(projectPath, 'scripts', 'check-error-exit.mjs');
1612
+ if (existsSync(dest)) return; // 他們自己的檔案
1613
+ if (!existsSync(join(projectPath, 'src'))) return; // 沒有原始碼就沒有這個問題
1614
+
1615
+ const src = join(getRepoRoot(), 'templates', 'gates', 'check-error-exit.mjs');
1616
+ if (!existsSync(src)) return; // 範本沒出貨——沉默,不要假裝提供了
1617
+
1618
+ console.log(chalk.bold(' 錯誤訊息單一出口閘門'));
1619
+ console.log(chalk.gray(' 防的是:每個呼叫端各自把錯誤回應拼成給人看的字串。'));
1620
+ console.log(chalk.gray(' 第一處是實作,第二處開始就會各寫各的,而畫面上只剩一句 Bad Request。'));
1621
+ console.log(chalk.gray(` 要寫入:scripts/check-error-exit.mjs(純 Node、零相依、${Math.round(readFileSync(src, 'utf-8').length / 1024)}KB)`));
1622
+ console.log(chalk.gray(' 寫入後它會 exit 2 直到你填好 CONFIG——那是刻意的,'));
1623
+ console.log(chalk.gray(' 一支在新 repo 裡靜靜回綠的閘門跟一支沒裝的無從分辨。'));
1624
+ console.log();
1625
+
1626
+ const ok = await confirmOrFail({
1627
+ message: '要把這道閘門寫進 scripts/check-error-exit.mjs 嗎?',
1628
+ defaultValue: false, // 🔴 預設不寫。沉默不是同意。
1629
+ options
1630
+ });
1631
+ if (!ok) {
1632
+ console.log(chalk.gray(' 跳過。`uds check` 之後會再提醒,但不會自己寫進去。'));
1633
+ console.log();
1634
+ return;
1635
+ }
1636
+
1637
+ mkdirSync(dirname(dest), { recursive: true });
1638
+ writeFileSync(dest, readFileSync(src, 'utf-8'), 'utf-8');
1639
+ console.log(chalk.green(' ✓ 已寫入 scripts/check-error-exit.mjs'));
1640
+ console.log(chalk.gray(' 下一步:填好檔頭的 CONFIG,然後把它接進你的 CI 或 pre-commit。'));
1641
+ console.log();
1642
+ }
1643
+
1374
1644
  /**
1375
1645
  * Regenerate integration files for all configured AI tools
1376
1646
  * Reusable core logic that can be called from both updateIntegrationsOnly and configureCommand
@@ -1452,6 +1722,7 @@ export function regenerateIntegrations(projectPath, manifest) {
1452
1722
  installedAt: now
1453
1723
  };
1454
1724
  }
1725
+ refreshTrackedFileHash(manifest, projectPath, agentsMdResult.path);
1455
1726
  }
1456
1727
  }
1457
1728
 
@@ -1575,7 +1846,7 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1575
1846
  return;
1576
1847
  }
1577
1848
 
1578
- const spinner = ora(msg.regeneratingIntegrations).start();
1849
+ const spinner = createSpinner(msg.regeneratingIntegrations).start();
1579
1850
 
1580
1851
  // Use reusable regeneration function
1581
1852
  const results = regenerateIntegrations(projectPath, manifest);
@@ -1604,6 +1875,188 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
1604
1875
  process.exit(0);
1605
1876
  }
1606
1877
 
1878
+ /**
1879
+ * Rebuild `manifest.integrationConfigs` from what the manifest already records.
1880
+ *
1881
+ * 🔴 Only `uds init`'s interactive flow and `--sync-refs` itself ever wrote this
1882
+ * key; no update or reconcile path backfills it, so a project created with
1883
+ * `uds init -y` carries `{}` from the first day (reproduced on a clean 6.9.0
1884
+ * project, 2026-09-16). `--sync-refs` then refuses to run — and `uds check` was
1885
+ * recommending exactly that command. Everything the config holds is derivable
1886
+ * from the manifest, so derive it instead of demanding the user re-init.
1887
+ *
1888
+ * Existing entries are never overwritten: they may hold choices (categories,
1889
+ * content mode) that this derivation cannot know.
1890
+ *
1891
+ * @param {Object} manifest - Manifest object (mutated)
1892
+ * @returns {number} How many entries were added
1893
+ */
1894
+ export function backfillIntegrationConfigs(manifest) {
1895
+ if (!manifest.integrationConfigs) manifest.integrationConfigs = {};
1896
+
1897
+ const tools = manifest.aiTools?.length ? manifest.aiTools : [];
1898
+ const byFile = new Map();
1899
+ for (const tool of tools) {
1900
+ const file = resolveIntegrationFile(tool) || getToolFilePath(tool);
1901
+ if (file && !byFile.has(file)) byFile.set(file, tool);
1902
+ }
1903
+ // Integrations can be recorded as file names (`CLAUDE.md`) or tool keys.
1904
+ for (const entry of manifest.integrations || []) {
1905
+ const file = resolveIntegrationFile(entry) || (String(entry).includes('.') ? String(entry) : null);
1906
+ if (!file) continue;
1907
+ if (!byFile.has(file)) {
1908
+ const tool = getToolFromPath(file) || resolveToolKeyFromEntry(entry);
1909
+ if (tool) byFile.set(file, tool);
1910
+ }
1911
+ }
1912
+
1913
+ let added = 0;
1914
+ for (const [file, tool] of byFile) {
1915
+ if (manifest.integrationConfigs[file]) continue;
1916
+ manifest.integrationConfigs[file] = {
1917
+ ...buildToolIntegrationConfig(manifest, tool),
1918
+ format: manifest.format || 'ai',
1919
+ backfilledAt: new Date().toISOString()
1920
+ };
1921
+ added++;
1922
+ }
1923
+ return added;
1924
+ }
1925
+
1926
+ function resolveToolKeyFromEntry(entry) {
1927
+ const key = String(entry).replace(/\.[^.]+$/, '').toLowerCase();
1928
+ return SUPPORTED_AI_TOOLS?.[key] ? key : null;
1929
+ }
1930
+
1931
+ /**
1932
+ * If the manifest already tracks this file, make its record describe what is on
1933
+ * disk now.
1934
+ *
1935
+ * Every path that writes AGENTS.md recorded `integrationBlockHashes` and none
1936
+ * recorded `fileHashes`, so a project whose manifest tracked the file (an older
1937
+ * install, or one where AGENTS.md was written as a tool's integration file) got
1938
+ * `⚠ AGENTS.md (modified)` from `uds check` the moment UDS itself regenerated
1939
+ * it. UDS wrote the file and did not record what it wrote.
1940
+ *
1941
+ * Conditional on purpose. `uds init` does not enrol AGENTS.md in `fileHashes`,
1942
+ * and adopters are expected to extend that summary — a path that started
1943
+ * tracking it would convert every legitimate edit into a reported fault. This
1944
+ * only ever refreshes a record that already exists.
1945
+ *
1946
+ * @param {Object} manifest - Project manifest (mutated)
1947
+ * @param {string} projectPath - Project root
1948
+ * @param {string} relativePath - Path as UDS reports it
1949
+ * @returns {boolean} Whether a record was refreshed
1950
+ */
1951
+ export function refreshTrackedFileHash(manifest, projectPath, relativePath) {
1952
+ if (!manifest?.fileHashes || !relativePath) return false;
1953
+ const normalized = String(relativePath).replace(/\\/g, '/');
1954
+ if (!(normalized in manifest.fileHashes)) return false;
1955
+
1956
+ const hashInfo = computeFileHash(join(projectPath, normalized));
1957
+ if (!hashInfo) return false;
1958
+
1959
+ manifest.fileHashes[normalized] = { ...hashInfo, installedAt: new Date().toISOString() };
1960
+ return true;
1961
+ }
1962
+
1963
+ /**
1964
+ * Forget `fileHashes` records for files that are gone AND that UDS no longer
1965
+ * ships.
1966
+ *
1967
+ * `--prune` deletes files it finds while walking `.standards/`. A file the
1968
+ * adopter already removed by hand is not in that walk, so its record survived
1969
+ * every prune — and the record is what made `uds check` report the completed
1970
+ * migration as seven faults. Following MIGRATION-v6 §2 therefore produced a
1971
+ * complaint that no command could clear.
1972
+ *
1973
+ * This is not a deletion. The file is already absent and no registry entry can
1974
+ * ever resolve to it again, so the record cannot refer to anything. Both facts
1975
+ * are required: a missing file UDS still ships is a real fault and stays; a
1976
+ * descoped standard still on disk is `--prune`'s business, gated on ownership,
1977
+ * and is left alone here.
1978
+ *
1979
+ * @param {Object} manifest - Project manifest (mutated)
1980
+ * @param {string} projectPath - Project root
1981
+ * @param {{shippable?: Set<string>}} [deps] - Injection point for tests
1982
+ * @returns {string[]} Paths whose records were dropped, sorted
1983
+ */
1984
+ export function pruneRetiredHashes(manifest, projectPath, deps = {}) {
1985
+ if (!manifest?.fileHashes) return [];
1986
+
1987
+ let shippable;
1988
+ try {
1989
+ shippable = deps.shippable || getShippableFilenames();
1990
+ } catch {
1991
+ return [];
1992
+ }
1993
+
1994
+ // A shippable set this small means the registry did not load, not that UDS
1995
+ // stopped shipping everything. Without this arm a broken install would empty
1996
+ // the manifest and every downstream check would go quiet at the same time.
1997
+ if (!shippable || shippable.size < 50) return [];
1998
+
1999
+ const dropped = [];
2000
+ for (const recorded of Object.keys(manifest.fileHashes)) {
2001
+ const normalized = recorded.replace(/\\/g, '/');
2002
+ if (!normalized.startsWith('.standards/')) continue;
2003
+ const fileName = normalized.split('/').pop();
2004
+ if (shippable.has(fileName)) continue;
2005
+ if (existsSync(join(projectPath, recorded))) continue;
2006
+ delete manifest.fileHashes[recorded];
2007
+ dropped.push(recorded);
2008
+ }
2009
+ return dropped.sort();
2010
+ }
2011
+
2012
+ /**
2013
+ * Build the config `--sync-refs` regenerates an integration file from.
2014
+ *
2015
+ * The stored `integrationConfigs[file]` is a snapshot taken when the file was
2016
+ * installed. Its `categories` are the thing sync-refs exists to update, and its
2017
+ * `installedStandards` is the thing that must never be trusted: it is not
2018
+ * refreshed by any path, so on a project upgraded across majors it still lists
2019
+ * standards that stopped shipping, and whatever it lists becomes the file's
2020
+ * content. That is how a manifest holding 73 standards produced a CLAUDE.md
2021
+ * announcing 76, which `uds check` then flagged against the same manifest.
2022
+ *
2023
+ * So: manifest-derived fields win, the caller's freshly computed categories win
2024
+ * over both, and anything the manifest knows nothing about (timestamps, fields
2025
+ * a future version adds) is carried through untouched.
2026
+ *
2027
+ * `outputLanguage` is the one place the stored value still gets a say — a
2028
+ * manifest with no `output_language` should not silently reset a project that
2029
+ * chose one before the option was recorded there.
2030
+ *
2031
+ * @param {Object} manifest - Project manifest
2032
+ * @param {string} toolName - Tool key (e.g. 'claude-code')
2033
+ * @param {Object} storedConfig - The existing integrationConfigs entry
2034
+ * @param {string[]} expectedCategories - Categories computed from current standards
2035
+ * @returns {Object} Config for writeIntegrationFile
2036
+ */
2037
+ export function refreshIntegrationConfig(manifest, toolName, storedConfig = {}, expectedCategories = []) {
2038
+ const outputLanguage =
2039
+ manifest.options?.output_language
2040
+ || manifest.options?.commit_language
2041
+ || storedConfig.outputLanguage
2042
+ || storedConfig.commitLanguage
2043
+ || 'english';
2044
+
2045
+ const fresh = buildToolIntegrationConfig(
2046
+ { ...manifest, options: { ...(manifest.options || {}), output_language: outputLanguage } },
2047
+ toolName
2048
+ );
2049
+
2050
+ return {
2051
+ ...storedConfig,
2052
+ ...fresh,
2053
+ tool: toolName,
2054
+ categories: expectedCategories,
2055
+ outputLanguage,
2056
+ format: manifest.format || storedConfig.format || 'ai'
2057
+ };
2058
+ }
2059
+
1607
2060
  /**
1608
2061
  * Sync integration file references based on manifest standards
1609
2062
  * @param {string} projectPath - Project path
@@ -1615,6 +2068,16 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1615
2068
  console.log(chalk.cyan(msg.syncingRefs));
1616
2069
  console.log();
1617
2070
 
2071
+ // Rebuild the config when it is absent rather than sending the user away —
2072
+ // the manifest holds everything it is derived from.
2073
+ if (!manifest.integrationConfigs || Object.keys(manifest.integrationConfigs).length === 0) {
2074
+ const filled = backfillIntegrationConfigs(manifest);
2075
+ if (filled > 0) {
2076
+ console.log(chalk.gray(` ${(msg.integrationConfigsBackfilled || 'Rebuilt integration config for {count} file(s) from the manifest.').replace('{count}', filled)}`));
2077
+ console.log();
2078
+ }
2079
+ }
2080
+
1618
2081
  // Check if integrationConfigs exists
1619
2082
  if (!manifest.integrationConfigs || Object.keys(manifest.integrationConfigs).length === 0) {
1620
2083
  console.log(chalk.yellow(msg.noIntegrationConfigs));
@@ -1667,14 +2130,9 @@ async function syncIntegrationReferences(projectPath, manifest, { plan = false }
1667
2130
  continue;
1668
2131
  }
1669
2132
 
1670
- // Regenerate the integration file with updated categories
1671
- const newConfig = {
1672
- ...config,
1673
- tool: toolName,
1674
- categories: expectedCategories,
1675
- // Pass output_language for dynamic commit standards generation
1676
- outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || config.outputLanguage || config.commitLanguage || 'english'
1677
- };
2133
+ // Regenerate the integration file with updated categories, from the
2134
+ // manifest rather than from the snapshot stored beside it.
2135
+ const newConfig = refreshIntegrationConfig(manifest, toolName, config, expectedCategories);
1678
2136
 
1679
2137
  if (plan) {
1680
2138
  console.log(chalk.yellow(` ~ ${integrationPath}: categories would change to ${expectedCategories.join(', ') || '(none)'} (dry run — nothing is written)`));
@@ -1845,7 +2303,35 @@ async function updateSkillsOnly(projectPath, manifest, options) {
1845
2303
  // Convert legacy format to new format
1846
2304
  const legacyLocation = manifest.skills.location;
1847
2305
  if (legacyLocation === 'user' || legacyLocation === 'project') {
1848
- skillsInstallations.push({ agent: 'claude-code', level: legacyLocation });
2306
+ // 🔴 A legacy manifest records a LOCATION, never a tool — and this used to
2307
+ // fill the gap with `claude-code`, unconditionally. For everyone who ran the
2308
+ // old `uds init -y` in a codex or opencode repo, that guess is wrong and
2309
+ // self-perpetuating: the fix to `init.js` would never reach them, because
2310
+ // update would keep re-installing to `.claude/skills/` on the strength of a
2311
+ // tool name nobody ever recorded. (XSPEC-408 §18.3 — Codex does not read
2312
+ // that directory; measured, two arms, 2026-09-08.)
2313
+ //
2314
+ // So re-derive from what is actually detected in the repo, and keep
2315
+ // `claude-code` only as the fallback for when nothing is detected — which is
2316
+ // exactly the case the old line was written for.
2317
+ let detected = {};
2318
+ try {
2319
+ detected = detectAITools(projectPath) || {};
2320
+ } catch {
2321
+ // Detection failing puts us in the "we do not know" case, which is exactly
2322
+ // what the old unconditional `claude-code` line was for. Never let a probe
2323
+ // for extra information break the update that would have worked without it.
2324
+ detected = {};
2325
+ }
2326
+ const detectedSkillsTools = Object.entries(detected)
2327
+ .filter(([, present]) => present)
2328
+ .map(([key]) => (key === 'claudeCode' ? 'claude-code' : key === 'geminiCli' ? 'gemini-cli' : key))
2329
+ .filter(agent => {
2330
+ const config = getAgentConfig(agent);
2331
+ return Boolean(config?.supportsSkills && config?.skills);
2332
+ });
2333
+ const agents = detectedSkillsTools.length > 0 ? detectedSkillsTools : ['claude-code'];
2334
+ for (const agent of agents) skillsInstallations.push({ agent, level: legacyLocation });
1849
2335
  } else if (legacyLocation === 'marketplace') {
1850
2336
  console.log(chalk.yellow(msg.skillsViaMarketplace || 'Skills installed via Marketplace'));
1851
2337
  console.log(chalk.gray(` ${msg.updateViaMarketplace || 'Update through Plugin Marketplace'}`));
@@ -1895,7 +2381,7 @@ async function updateSkillsOnly(projectPath, manifest, options) {
1895
2381
  return;
1896
2382
  }
1897
2383
 
1898
- const spinner = ora(msg.installingSkills || 'Installing Skills...').start();
2384
+ const spinner = createSpinner(msg.installingSkills || 'Installing Skills...').start();
1899
2385
 
1900
2386
  const skillsLocaleForUpdate = resolveLocale(manifest, projectPath, options);
1901
2387
  const result = await installSkillsToMultipleAgents(
@@ -1913,8 +2399,16 @@ async function updateSkillsOnly(projectPath, manifest, options) {
1913
2399
  }).join(', ');
1914
2400
 
1915
2401
  if (result.totalErrors === 0) {
2402
+ // 🔴 `{count}` is worded "AI tools" in every locale, and this passed
2403
+ // `totalInstalled` — the number of skill FILES written, summed across targets.
2404
+ // A two-tool update of 55 skills printed "Updated Skills for 165 AI tools".
2405
+ // It was invisible while this branch only ran from the interactive flow; the
2406
+ // fix to `init.js` makes it the common path, so it stops being invisible.
1916
2407
  spinner.succeed((msg.skillsUpdated || 'Updated Skills in {count} locations: {locations}')
1917
- .replace('{count}', result.totalInstalled)
2408
+ // Counted from the same list that builds `locations`, so the number and the
2409
+ // names can never disagree — and it does not depend on the shape of the
2410
+ // installer's return value.
2411
+ .replace('{count}', fileBasedInstallations.length)
1918
2412
  .replace('{locations}', locations));
1919
2413
  } else {
1920
2414
  spinner.warn((msg.skillsUpdatedWithErrors || 'Updated Skills with {errors} errors')
@@ -2011,7 +2505,7 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2011
2505
  }
2012
2506
  console.log();
2013
2507
 
2014
- const spinner = ora(msg.installingCommands || 'Installing commands...').start();
2508
+ const spinner = createSpinner(msg.installingCommands || 'Installing commands...').start();
2015
2509
 
2016
2510
  const commandsLocale = resolveLocale(manifest, projectPath, options);
2017
2511
  const result = await installCommandsToMultipleAgents(
@@ -2508,7 +3002,7 @@ async function handleRollback(projectPath) {
2508
3002
  * Handle --plan: show what the reconciler would do without executing.
2509
3003
  */
2510
3004
  async function handlePlan(projectPath, options) {
2511
- const spinner = ora('Calculating reconciliation plan...').start();
3005
+ const spinner = createSpinner('Calculating reconciliation plan...').start();
2512
3006
 
2513
3007
  const result = await reconcilerPlan(projectPath, { force: false });
2514
3008
 
@@ -2583,7 +3077,7 @@ async function handleReconcile(projectPath, options, { force }) {
2583
3077
  }
2584
3078
 
2585
3079
  // Execute
2586
- const spinner = ora('Applying reconciliation plan...').start();
3080
+ const spinner = createSpinner('Applying reconciliation plan...').start();
2587
3081
 
2588
3082
  const result = await reconcile(projectPath, {
2589
3083
  force,