universal-dev-standards 6.9.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 (47) hide show
  1. package/bundled/core/agent-communication-protocol.md +8 -0
  2. package/bundled/core/branch-completion.md +8 -0
  3. package/bundled/core/change-batching-standards.md +8 -0
  4. package/bundled/core/execution-history.md +8 -0
  5. package/bundled/core/pipeline-integration-standards.md +8 -0
  6. package/bundled/core/workflow-enforcement.md +8 -0
  7. package/bundled/core/workflow-state-protocol.md +8 -0
  8. package/bundled/locales/zh-CN/CHANGELOG.md +29 -3
  9. package/bundled/locales/zh-CN/README.md +1 -1
  10. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  11. package/bundled/locales/zh-CN/core/agent-communication-protocol.md +7 -0
  12. package/bundled/locales/zh-CN/core/branch-completion.md +7 -0
  13. package/bundled/locales/zh-CN/core/change-batching-standards.md +7 -0
  14. package/bundled/locales/zh-CN/core/execution-history.md +7 -0
  15. package/bundled/locales/zh-CN/core/pipeline-integration-standards.md +7 -0
  16. package/bundled/locales/zh-CN/core/workflow-enforcement.md +7 -0
  17. package/bundled/locales/zh-CN/core/workflow-state-protocol.md +7 -0
  18. package/bundled/locales/zh-CN/docs/MIGRATION-v6.md +8 -4
  19. package/bundled/locales/zh-TW/CHANGELOG.md +30 -3
  20. package/bundled/locales/zh-TW/README.md +1 -1
  21. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  22. package/bundled/locales/zh-TW/core/agent-communication-protocol.md +7 -0
  23. package/bundled/locales/zh-TW/core/branch-completion.md +7 -0
  24. package/bundled/locales/zh-TW/core/change-batching-standards.md +7 -0
  25. package/bundled/locales/zh-TW/core/execution-history.md +7 -0
  26. package/bundled/locales/zh-TW/core/pipeline-integration-standards.md +7 -0
  27. package/bundled/locales/zh-TW/core/workflow-enforcement.md +7 -0
  28. package/bundled/locales/zh-TW/core/workflow-state-protocol.md +7 -0
  29. package/bundled/locales/zh-TW/docs/MIGRATION-v6.md +8 -4
  30. package/package.json +7 -6
  31. package/src/commands/check.js +161 -24
  32. package/src/commands/config.js +19 -19
  33. package/src/commands/init.js +13 -4
  34. package/src/commands/spec.js +2 -2
  35. package/src/commands/update.js +251 -29
  36. package/src/i18n/messages.js +36 -0
  37. package/src/installers/integration-installer.js +4 -4
  38. package/src/installers/skills-installer.js +4 -4
  39. package/src/installers/standards-installer.js +3 -3
  40. package/src/prompts/init.js +33 -4
  41. package/src/reconciler/diff-engine.js +57 -2
  42. package/src/reconciler/plan-executor.js +31 -11
  43. package/src/utils/integration-generator.js +209 -81
  44. package/src/utils/reference-sync.js +103 -7
  45. package/src/utils/registry.js +57 -0
  46. package/src/utils/spinner.js +31 -0
  47. package/standards-registry.json +8 -8
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
2
+ import { createSpinner } from '../utils/spinner.js';
3
3
  import { basename, join } from 'path';
4
4
  import {
5
5
  getAllStandards,
@@ -75,7 +75,7 @@ export async function installStandards(config, projectPath) {
75
75
  };
76
76
 
77
77
  // Start copying standards
78
- const copySpinner = ora(msg.copyingStandards).start();
78
+ const copySpinner = createSpinner(msg.copyingStandards).start();
79
79
 
80
80
  // Copy standards based on format
81
81
  const formatsToUse = config.format === 'both' ? ['ai', 'human'] : [config.format];
@@ -113,7 +113,7 @@ export async function installStandards(config, projectPath) {
113
113
  const localeExtension = (config.displayLanguage === 'zh-tw' || config.displayLanguage === 'zh-cn') ? config.displayLanguage : null;
114
114
 
115
115
  if (config.languages.length > 0 || config.frameworks.length > 0 || localeExtension) {
116
- const extSpinner = ora(msg.copyingExtensions).start();
116
+ const extSpinner = createSpinner(msg.copyingExtensions).start();
117
117
 
118
118
  for (const lang of config.languages) {
119
119
  if (EXTENSION_MAPPINGS[lang]) {
@@ -1,5 +1,5 @@
1
1
  import { select, checkbox, confirm as inquirerConfirm, Separator, input } from '@inquirer/prompts';
2
- import { existsSync, writeFileSync, readdirSync } from 'fs';
2
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import os from 'os';
@@ -1385,11 +1385,40 @@ export function suggestCommands(projectPath) {
1385
1385
  if (existsSync(p('Gemfile')))
1386
1386
  return { test: 'bundle exec rspec', lint: 'bundle exec rubocop', build: 'gem build *.gemspec', security: 'bundle audit' };
1387
1387
 
1388
- // C# — glob for .csproj / .sln
1388
+ // C# — glob for .csproj / .sln.
1389
+ //
1390
+ // `.csproj` has named two incompatible build systems since 2017. An SDK-style
1391
+ // project (`<Project Sdk="…">`, `<TargetFramework>`) builds with the `dotnet`
1392
+ // CLI. An old-style one (`<TargetFrameworkVersion>`, no Sdk attribute) needs
1393
+ // MSBuild and Visual Studio's targets, and handing it `dotnet build` produces
1394
+ // `MSB4019: could not find Import project …Microsoft.WebApplication.targets`
1395
+ // — reported 2026-09-16 from a .NET Framework 4.8 Web App, which also got
1396
+ // `dotnet list package --vulnerable`, a command that silently reports nothing
1397
+ // for a `packages.config` project because it only reads `PackageReference`.
1398
+ //
1399
+ // For the old style only `build` gets an answer. `dotnet format` requires an
1400
+ // SDK-style project and there is no drop-in equivalent; test discovery
1401
+ // depends on which runner the solution uses. The unknown-ecosystem branch
1402
+ // below already returns blanks "so user fills them in", and a blank a human
1403
+ // completes beats a command that looks authoritative and fails on first use.
1389
1404
  try {
1390
1405
  const files = readdirSync(projectPath);
1391
- if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln')))
1392
- return { test: 'dotnet test', lint: 'dotnet format --verify-no-changes', build: 'dotnet build', security: 'dotnet list package --vulnerable' };
1406
+ const projectFiles = files.filter(f => f.endsWith('.csproj') || f.endsWith('.vbproj'));
1407
+ if (projectFiles.length > 0 || files.some(f => f.endsWith('.sln'))) {
1408
+ const oldStyle = projectFiles.some(f => {
1409
+ try {
1410
+ const xml = readFileSync(join(projectPath, f), 'utf-8');
1411
+ if (/<Project[^>]*\sSdk\s*=/i.test(xml)) return false;
1412
+ return /<TargetFrameworkVersion\s*>/i.test(xml);
1413
+ } catch {
1414
+ return false;
1415
+ }
1416
+ });
1417
+
1418
+ return oldStyle
1419
+ ? { test: '', lint: '', build: 'msbuild /t:Build /p:Configuration=Release', security: '' }
1420
+ : { test: 'dotnet test', lint: 'dotnet format --verify-no-changes', build: 'dotnet build', security: 'dotnet list package --vulnerable' };
1421
+ }
1393
1422
  } catch {
1394
1423
  // ignore read errors
1395
1424
  }
@@ -14,6 +14,8 @@
14
14
  * Integration files use migrate_block: only the UDS marker block is replaced.
15
15
  */
16
16
 
17
+ import { isShippedFilename } from '../utils/registry.js';
18
+
17
19
  /**
18
20
  * @typedef {Object} PlanAction
19
21
  * @property {'create'|'update'|'delete'|'migrate_block'|'patch_hook'} type
@@ -205,12 +207,18 @@ function diffCategory(desiredMap, actualMap, category, actions, warnings, summar
205
207
  // Check actual entries not in desired → delete (only if UDS-managed)
206
208
  for (const [key, actualEntry] of actualMap) {
207
209
  if (!desiredMap.has(key)) {
208
- if (isUDSManaged(actualEntry)) {
210
+ if (isUserConfigPath(actualEntry.relativePath)) {
211
+ // Said out loud rather than skipped quietly: a plan that silently omits
212
+ // things is how the previous round of deletion bugs stayed invisible.
213
+ warnings.push(
214
+ `Keeping ${actualEntry.relativePath}: it holds a setting you chose (uds config writes it), not content UDS ships`
215
+ );
216
+ } else if (isUDSManaged(actualEntry)) {
209
217
  actions.push({
210
218
  type: 'delete',
211
219
  category,
212
220
  path: actualEntry.relativePath,
213
- reason: 'no longer in desired state',
221
+ reason: describeDeletion(actualEntry.relativePath, category),
214
222
  details: {
215
223
  currentHash: actualEntry.hash,
216
224
  metadata: actualEntry.metadata
@@ -330,6 +338,53 @@ function diffIntegrations(desiredMap, actualMap, actions, warnings, summary, for
330
338
  * Files in .standards/ are always UDS-managed.
331
339
  * Skill/command entries from manifest installations are UDS-managed.
332
340
  */
341
+ /**
342
+ * Files under `.standards/` that hold a choice the user made, not content UDS
343
+ * ships.
344
+ *
345
+ * `release-config.yaml` is written by `uds init` from the release mode the user
346
+ * picked and rewritten by `uds config`. Nothing in the desired-state calculator
347
+ * models it, so it fell out of every diff as "no longer in desired state" and
348
+ * into the delete plan — a plan that discards a user setting and calls that
349
+ * reconciliation. Reported 2026-09-16 by an adopter who could not tell from the
350
+ * plan whether the file held anything of theirs. It did.
351
+ *
352
+ * @param {string} relativePath
353
+ * @returns {boolean}
354
+ */
355
+ export function isUserConfigPath(relativePath) {
356
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
357
+ return normalized === '.standards/release-config.yaml';
358
+ }
359
+
360
+ /**
361
+ * Why this file is being deleted, in terms the adopter can act on.
362
+ *
363
+ * "no longer in desired state" was the reason given for a standard UDS retired
364
+ * upstream, an option the project deselected, and a path the desired state does
365
+ * not model — three different situations, three different right responses, one
366
+ * sentence. The distinction that matters most is the first one: if UDS still
367
+ * ships the file, the change came from this project and is reversible by
368
+ * re-selecting it; if UDS does not, it is gone upstream and re-selecting it will
369
+ * not bring it back.
370
+ *
371
+ * @param {string} relativePath
372
+ * @param {string} category - 'standard' | 'option' | 'skill' | 'command' | …
373
+ * @returns {string}
374
+ */
375
+ export function describeDeletion(relativePath, category) {
376
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
377
+
378
+ if (category === 'standard' || category === 'option') {
379
+ const fileName = normalized.split('/').pop();
380
+ return isShippedFilename(fileName)
381
+ ? 'this project no longer selects it (UDS still ships it, so re-selecting restores it)'
382
+ : 'UDS no longer ships this file (re-selecting will not bring it back)';
383
+ }
384
+
385
+ return `not part of what this project's manifest asks for (category: ${category})`;
386
+ }
387
+
333
388
  function isUDSManaged(entry) {
334
389
  // Files in .standards/ are always UDS-managed
335
390
  if (entry.relativePath.startsWith('.standards/')) return true;
@@ -436,6 +436,22 @@ async function executeSkillBatch(projectPath, skillActions, manifest) {
436
436
  return results;
437
437
  }
438
438
 
439
+ /**
440
+ * Hash-map key for a command action: `<agent>/<file>`.
441
+ *
442
+ * `commandHashes` is keyed by agent and filename; a plan action carries a
443
+ * project-relative path (`.opencode/command/atdd.md`). Deleting by the wrong key
444
+ * leaves the entry behind and the next `check` reports the file missing.
445
+ */
446
+ function commandHashKey(action) {
447
+ const meta = action.details?.metadata;
448
+ if (meta?.agent && meta?.commandName) return `${meta.agent}/${meta.commandName}.md`;
449
+ const parts = action.path.split('/').filter(Boolean);
450
+ const agent = parts[0] ? parts[0].replace(/^\./, '') : null;
451
+ const file = parts[parts.length - 1];
452
+ return agent && file ? `${agent}/${file}` : action.path;
453
+ }
454
+
439
455
  /**
440
456
  * Batch execute command installations.
441
457
  */
@@ -452,6 +468,9 @@ async function executeCommandBatch(projectPath, commandActions, manifest) {
452
468
  if (existsSync(targetPath)) {
453
469
  rmSync(targetPath, { recursive: true, force: true });
454
470
  }
471
+ // A deleted file must also lose its hash entry, or the next check calls
472
+ // it missing for good.
473
+ delete manifest.commandHashes[commandHashKey(action)];
455
474
  results.push({ action, success: true });
456
475
  } catch (err) {
457
476
  results.push({ action, success: false, error: err.message });
@@ -475,17 +494,18 @@ async function executeCommandBatch(projectPath, commandActions, manifest) {
475
494
  );
476
495
 
477
496
  if (installResult.allFileHashes) {
478
- // Clean up stale entries for agents being updated before merging
479
- const updatedPrefixes = new Set(
480
- Object.keys(installResult.allFileHashes).map(k => k.split('/')[0])
481
- );
482
- for (const prefix of updatedPrefixes) {
483
- for (const key of Object.keys(manifest.commandHashes)) {
484
- if (key.startsWith(prefix + '/')) {
485
- delete manifest.commandHashes[key];
486
- }
487
- }
488
- }
497
+ // Merge, exactly as the skill path above does.
498
+ //
499
+ // 🔴 This used to delete every key whose prefix matched an agent in the
500
+ // result before merging. That is correct only when the installer
501
+ // reinstalled ALL of that agent's commands — which `uds update`'s own
502
+ // call sites do (they pass `commandNames = null`). Here the plan names a
503
+ // subset, so the wipe threw away the entry for every command the plan did
504
+ // not mention. Measured on 6.9.0 (2026-09-16): a three-file drift turned
505
+ // 51 tracked commands into 3, the other 48 stayed on disk untracked, and
506
+ // `uds check` printed "All command files intact (3 files)" in the same run
507
+ // as "Commands: 51 installed" — tampering with one of the 48 changed
508
+ // neither line.
489
509
  Object.assign(manifest.commandHashes, installResult.allFileHashes);
490
510
  }
491
511