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
@@ -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
@@ -264,18 +272,34 @@ function diffIntegrations(desiredMap, actualMap, actions, warnings, summary, for
264
272
  }
265
273
  });
266
274
  summary.migrate_block++;
275
+ } else if (desiredEntry.hash && actualEntry.hash && desiredEntry.hash === actualEntry.hash) {
276
+ // XSPEC adopter-report Q6: the block on disk already hashes the same
277
+ // as what generation would produce right now — nothing to do. Before
278
+ // this, every migrate_block fired unconditionally ("we always update
279
+ // integrations since content is generated dynamically"), so `--plan`
280
+ // reported `Migrate Block: N` forever, even immediately after
281
+ // `--apply` on an unchanged project. `desiredEntry.hash` is computed
282
+ // by desired-state-calculator.js actually generating the content
283
+ // ahead of time (see calculateIntegrations), the same generation
284
+ // `--apply` itself would run.
285
+ summary.unchanged++;
267
286
  } else {
268
- // Check if block hash differs from what we'd generate
269
- // We always update integrations since content is generated dynamically
287
+ // Either the hashes genuinely differ, or one side has no hash to
288
+ // compare (generation failed, or an older/mocked desired state that
289
+ // never computed one) — in which case this falls back to the
290
+ // previous unconditional behavior rather than silently doing nothing.
270
291
  actions.push({
271
292
  type: 'migrate_block',
272
293
  category: 'integration',
273
294
  path: desiredEntry.relativePath,
274
- reason: 'integration content may need update',
295
+ reason: (desiredEntry.hash && actualEntry.hash)
296
+ ? 'integration content differs from what would be generated'
297
+ : 'integration content may need update (no hash available for comparison)',
275
298
  details: {
276
299
  toolName: desiredEntry.metadata.toolName,
277
300
  format: desiredEntry.metadata.format,
278
301
  currentBlockHash: actualEntry.metadata?.blockHash?.blockHash,
302
+ desiredBlockHash: desiredEntry.hash,
279
303
  metadata: desiredEntry.metadata
280
304
  }
281
305
  });
@@ -330,6 +354,53 @@ function diffIntegrations(desiredMap, actualMap, actions, warnings, summary, for
330
354
  * Files in .standards/ are always UDS-managed.
331
355
  * Skill/command entries from manifest installations are UDS-managed.
332
356
  */
357
+ /**
358
+ * Files under `.standards/` that hold a choice the user made, not content UDS
359
+ * ships.
360
+ *
361
+ * `release-config.yaml` is written by `uds init` from the release mode the user
362
+ * picked and rewritten by `uds config`. Nothing in the desired-state calculator
363
+ * models it, so it fell out of every diff as "no longer in desired state" and
364
+ * into the delete plan — a plan that discards a user setting and calls that
365
+ * reconciliation. Reported 2026-09-16 by an adopter who could not tell from the
366
+ * plan whether the file held anything of theirs. It did.
367
+ *
368
+ * @param {string} relativePath
369
+ * @returns {boolean}
370
+ */
371
+ export function isUserConfigPath(relativePath) {
372
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
373
+ return normalized === '.standards/release-config.yaml';
374
+ }
375
+
376
+ /**
377
+ * Why this file is being deleted, in terms the adopter can act on.
378
+ *
379
+ * "no longer in desired state" was the reason given for a standard UDS retired
380
+ * upstream, an option the project deselected, and a path the desired state does
381
+ * not model — three different situations, three different right responses, one
382
+ * sentence. The distinction that matters most is the first one: if UDS still
383
+ * ships the file, the change came from this project and is reversible by
384
+ * re-selecting it; if UDS does not, it is gone upstream and re-selecting it will
385
+ * not bring it back.
386
+ *
387
+ * @param {string} relativePath
388
+ * @param {string} category - 'standard' | 'option' | 'skill' | 'command' | …
389
+ * @returns {string}
390
+ */
391
+ export function describeDeletion(relativePath, category) {
392
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
393
+
394
+ if (category === 'standard' || category === 'option') {
395
+ const fileName = normalized.split('/').pop();
396
+ return isShippedFilename(fileName)
397
+ ? 'this project no longer selects it (UDS still ships it, so re-selecting restores it)'
398
+ : 'UDS no longer ships this file (re-selecting will not bring it back)';
399
+ }
400
+
401
+ return `not part of what this project's manifest asks for (category: ${category})`;
402
+ }
403
+
333
404
  function isUDSManaged(entry) {
334
405
  // Files in .standards/ are always UDS-managed
335
406
  if (entry.relativePath.startsWith('.standards/')) return true;
@@ -22,7 +22,7 @@ import {
22
22
  import { writeManifest } from '../core/manifest.js';
23
23
  import { getRepositoryInfo } from '../utils/registry.js';
24
24
  import { displayLanguageToLocale } from '../utils/locale.js';
25
- import { computeFileHash } from '../utils/hasher.js';
25
+ import { computeFileHash, pruneIntegrationFileHashes } from '../utils/hasher.js';
26
26
  import { createBackup, cleanupBackups } from './backup-manager.js';
27
27
 
28
28
  /**
@@ -179,6 +179,11 @@ export async function executePlan(projectPath, plan, manifest, options = {}) {
179
179
  // Write updated manifest
180
180
  if (!dryRun) {
181
181
  try {
182
+ // XSPEC-418 R6: catches any stale whole-file entry for an integration
183
+ // file this particular plan didn't touch (e.g. `--plan --skills` never
184
+ // reaches executeMigrateBlock), not just the one this run might
185
+ // otherwise have added.
186
+ pruneIntegrationFileHashes(updatedManifest);
182
187
  writeManifest(updatedManifest, projectPath);
183
188
  } catch (err) {
184
189
  results.push({
@@ -351,21 +356,17 @@ function executeMigrateBlock(projectPath, action, manifest) {
351
356
  if (result.blockHashInfo) {
352
357
  manifest.integrationBlockHashes[result.path] = result.blockHashInfo;
353
358
  }
354
- // `init` also records integration files in the whole-file `fileHashes`, which
355
- // is what `uds check`'s File Integrity compares. Rewriting the block without
356
- // refreshing that entry left the file permanently reported as "modified" —
357
- // a successful reconcile that reads, afterwards, as a damaged install.
358
- // (XSPEC-343 R2)
359
- const tracked = manifest.fileHashes?.[result.path];
360
- if (tracked) {
361
- const info = computeFileHash(join(projectPath, result.path));
362
- if (info) {
363
- manifest.fileHashes[result.path] = {
364
- ...info,
365
- installedAt: tracked.installedAt || new Date().toISOString()
366
- };
367
- }
368
- }
359
+ // XSPEC-418 R6 supersedes the XSPEC-343 R2 fix this replaces. That fix
360
+ // refreshed a whole-file `fileHashes` entry here if one already existed,
361
+ // reasoning that leaving it stale would report the file "modified"
362
+ // forever. Refreshing it does stop that — but a whole-file hash for an
363
+ // integration file disagrees with reality the moment the adopter edits
364
+ // anything OUTSIDE the block, which the marker-based update above
365
+ // deliberately leaves alone; "modified" then fires for content UDS itself
366
+ // says it preserves, and contradicts the same run's block-integrity
367
+ // check. The correct fix is for this path not to be in `fileHashes` at
368
+ // all — it is tracked by the block hash above instead.
369
+ if (manifest.fileHashes) delete manifest.fileHashes[result.path];
369
370
  return { action, success: true };
370
371
  }
371
372
 
@@ -436,6 +437,22 @@ async function executeSkillBatch(projectPath, skillActions, manifest) {
436
437
  return results;
437
438
  }
438
439
 
440
+ /**
441
+ * Hash-map key for a command action: `<agent>/<file>`.
442
+ *
443
+ * `commandHashes` is keyed by agent and filename; a plan action carries a
444
+ * project-relative path (`.opencode/command/atdd.md`). Deleting by the wrong key
445
+ * leaves the entry behind and the next `check` reports the file missing.
446
+ */
447
+ function commandHashKey(action) {
448
+ const meta = action.details?.metadata;
449
+ if (meta?.agent && meta?.commandName) return `${meta.agent}/${meta.commandName}.md`;
450
+ const parts = action.path.split('/').filter(Boolean);
451
+ const agent = parts[0] ? parts[0].replace(/^\./, '') : null;
452
+ const file = parts[parts.length - 1];
453
+ return agent && file ? `${agent}/${file}` : action.path;
454
+ }
455
+
439
456
  /**
440
457
  * Batch execute command installations.
441
458
  */
@@ -452,6 +469,9 @@ async function executeCommandBatch(projectPath, commandActions, manifest) {
452
469
  if (existsSync(targetPath)) {
453
470
  rmSync(targetPath, { recursive: true, force: true });
454
471
  }
472
+ // A deleted file must also lose its hash entry, or the next check calls
473
+ // it missing for good.
474
+ delete manifest.commandHashes[commandHashKey(action)];
455
475
  results.push({ action, success: true });
456
476
  } catch (err) {
457
477
  results.push({ action, success: false, error: err.message });
@@ -475,17 +495,18 @@ async function executeCommandBatch(projectPath, commandActions, manifest) {
475
495
  );
476
496
 
477
497
  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
- }
498
+ // Merge, exactly as the skill path above does.
499
+ //
500
+ // 🔴 This used to delete every key whose prefix matched an agent in the
501
+ // result before merging. That is correct only when the installer
502
+ // reinstalled ALL of that agent's commands — which `uds update`'s own
503
+ // call sites do (they pass `commandNames = null`). Here the plan names a
504
+ // subset, so the wipe threw away the entry for every command the plan did
505
+ // not mention. Measured on 6.9.0 (2026-09-16): a three-file drift turned
506
+ // 51 tracked commands into 3, the other 48 stayed on disk untracked, and
507
+ // `uds check` printed "All command files intact (3 files)" in the same run
508
+ // as "Commands: 51 installed" — tampering with one of the 48 changed
509
+ // neither line.
489
510
  Object.assign(manifest.commandHashes, installResult.allFileHashes);
490
511
  }
491
512
 
@@ -4,6 +4,7 @@ import { join, relative } from 'path';
4
4
  import { UDS_MARKERS } from '../core/constants.js';
5
5
  import { resolveIntegrationFile } from '../core/constants.js';
6
6
  import { isProvenanceEstablished } from '../core/manifest.js';
7
+ import { locateMarkerBlock, AmbiguousMarkerError } from './marker-locator.js';
7
8
 
8
9
  // GitHub issue #155. `git config core.autocrlf true` (the common
9
10
  // Windows default) rewrites LF to CRLF on checkout. The manifest's stored
@@ -472,13 +473,16 @@ function detectFormat(filePath) {
472
473
  */
473
474
  function extractBlockContent(content, format) {
474
475
  const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
475
- const startIdx = content.indexOf(markers.start);
476
- const endIdx = content.indexOf(markers.end);
476
+ // XSPEC adopter-report Q5: locateMarkerBlock only counts a marker when it
477
+ // occupies a whole line by itself, not merely appears somewhere on one —
478
+ // see marker-locator.js for why raw indexOf broke on real files.
479
+ const block = locateMarkerBlock(content, markers);
477
480
 
478
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
481
+ if (!block) {
479
482
  return { before: content, blockContent: '', after: '' };
480
483
  }
481
484
 
485
+ const { startIdx, endIdx } = block;
482
486
  return {
483
487
  before: content.substring(0, startIdx),
484
488
  blockContent: content.substring(startIdx + markers.start.length, endIdx).trim(),
@@ -515,7 +519,15 @@ export function computeIntegrationBlockHash(filePath) {
515
519
  fullHash: `sha256:${fullHash}`,
516
520
  fullSize: Buffer.byteLength(content, 'utf-8')
517
521
  };
518
- } catch {
522
+ } catch (error) {
523
+ // XSPEC adopter-report Q5: an ambiguous marker pair (two real START or
524
+ // END lines) is a distinct, reportable condition — not "no markers
525
+ // found". Every other error (unreadable file, etc.) keeps the original
526
+ // silent-null behavior; callers that need to surface the ambiguity to a
527
+ // user must catch AmbiguousMarkerError explicitly (see check.js).
528
+ if (error instanceof AmbiguousMarkerError) {
529
+ throw error;
530
+ }
519
531
  return null;
520
532
  }
521
533
  }
@@ -631,7 +643,14 @@ export function compareDirectoryHashes(dirPath, storedHashes, baseKey = '') {
631
643
 
632
644
  /**
633
645
  * Refresh all integrationBlockHashes in manifest by recalculating from disk
634
- * Ensures manifest hashes always match actual file content
646
+ * Ensures manifest hashes always match actual file content.
647
+ *
648
+ * Also prunes any `fileHashes` entry for the same paths (XSPEC-418 R6) — every
649
+ * call site of this function is a "we just wrote/restored an integration file,
650
+ * about to persist the manifest" checkpoint, which is exactly where a stale
651
+ * whole-file hash for that same path (written by an older CLI, or a write path
652
+ * this fix missed) needs to stop existing. See `pruneIntegrationFileHashes`.
653
+ *
635
654
  * @param {Object} manifest - Manifest object (mutated in place)
636
655
  * @param {string} projectPath - Project root path
637
656
  * @returns {Object} The updated manifest
@@ -652,5 +671,42 @@ export function refreshIntegrationBlockHashes(manifest, projectPath) {
652
671
  }
653
672
  }
654
673
 
674
+ pruneIntegrationFileHashes(manifest);
675
+
655
676
  return manifest;
656
677
  }
678
+
679
+ /**
680
+ * Remove `fileHashes` entries for files UDS tracks by their UDS block instead
681
+ * (`integrationBlockHashes`) — CLAUDE.md, CLAUDE.local.md, AGENTS.md, GEMINI.md,
682
+ * etc. (XSPEC-418 R6).
683
+ *
684
+ * An integration file's whole-file hash and its block hash disagree the
685
+ * moment an adopter edits anything OUTSIDE the UDS block — exactly the
686
+ * customization UDS's marker-based update promises to preserve. Several write
687
+ * paths (`uds update`, `uds update --integrations-only`, `uds check
688
+ * --restore`, `uds check --migrate`) used to add a whole-file entry for these
689
+ * paths anyway, so `uds check --ci` could report "CLAUDE.md (modified)" from
690
+ * standards-file integrity in the same run its own block-integrity check said
691
+ * the block was intact — the two checks contradicted each other, and the one
692
+ * that failed was the one punishing content UDS says it preserves.
693
+ *
694
+ * `manifest.integrationBlockHashes` is the authoritative registry of which
695
+ * paths are integration files — every writer of it (`writeIntegrationFile`,
696
+ * `writeAgentsMdSummary`) sets an entry there and nowhere else, so keying off
697
+ * its keys needs no second list of "known" integration files to keep in sync.
698
+ *
699
+ * @param {Object} manifest - Manifest object (mutated in place)
700
+ * @returns {string[]} Paths whose stale whole-file hash was removed
701
+ */
702
+ export function pruneIntegrationFileHashes(manifest) {
703
+ if (!manifest?.fileHashes || !manifest?.integrationBlockHashes) return [];
704
+ const removed = [];
705
+ for (const path of Object.keys(manifest.integrationBlockHashes)) {
706
+ if (path in manifest.fileHashes) {
707
+ delete manifest.fileHashes[path];
708
+ removed.push(path);
709
+ }
710
+ }
711
+ return removed;
712
+ }