universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.4

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 (53) hide show
  1. package/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
  2. package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
  3. package/bundled/ai/standards/open-work-tracking.ai.yaml +4 -1
  4. package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
  5. package/bundled/core/ai-response-navigation.md +128 -12
  6. package/bundled/core/open-work-tracking.md +1 -1
  7. package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
  8. package/bundled/extensions/languages/csharp-style.md +464 -0
  9. package/bundled/extensions/languages/php/fat-free-patterns.md +915 -0
  10. package/bundled/extensions/languages/php/php-style.md +693 -0
  11. package/bundled/extensions/languages/php-style.md +700 -0
  12. package/bundled/extensions/locales/zh-cn.md +717 -0
  13. package/bundled/extensions/locales/zh-tw.md +717 -0
  14. package/bundled/locales/COVERAGE.md +5 -4
  15. package/bundled/locales/zh-CN/CHANGELOG.md +44 -3
  16. package/bundled/locales/zh-CN/README.md +2 -2
  17. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  18. package/bundled/locales/zh-CN/core/ai-response-navigation.md +110 -12
  19. package/bundled/locales/zh-CN/skills/README.md +1 -0
  20. package/bundled/locales/zh-CN/skills/comprehension-ladder/SKILL.md +289 -0
  21. package/bundled/locales/zh-CN/skills/comprehension-ladder/eval-cases.md +261 -0
  22. package/bundled/locales/zh-TW/CHANGELOG.md +44 -3
  23. package/bundled/locales/zh-TW/README.md +2 -2
  24. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  25. package/bundled/locales/zh-TW/core/ai-response-navigation.md +110 -12
  26. package/bundled/locales/zh-TW/core/open-work-tracking.md +3 -3
  27. package/bundled/locales/zh-TW/skills/README.md +1 -0
  28. package/bundled/locales/zh-TW/skills/comprehension-ladder/SKILL.md +289 -0
  29. package/bundled/locales/zh-TW/skills/comprehension-ladder/eval-cases.md +261 -0
  30. package/bundled/skills/README.md +1 -0
  31. package/bundled/skills/comprehension-ladder/SKILL.md +283 -0
  32. package/bundled/skills/comprehension-ladder/eval-cases.md +255 -0
  33. package/package.json +2 -2
  34. package/src/commands/check.js +9 -0
  35. package/src/commands/init.js +100 -27
  36. package/src/commands/uninstall.js +144 -30
  37. package/src/commands/update.js +62 -3
  38. package/src/core/install-records.js +191 -0
  39. package/src/i18n/messages.js +39 -6
  40. package/src/installers/hooks-installer.js +61 -30
  41. package/src/installers/integration-installer.js +5 -1
  42. package/src/installers/standards-installer.js +16 -23
  43. package/src/reconciler/plan-executor.js +10 -11
  44. package/src/uninstallers/hook-uninstaller.js +219 -33
  45. package/src/uninstallers/integration-uninstaller.js +35 -5
  46. package/src/utils/copier.js +57 -0
  47. package/src/utils/git-hooks.js +139 -7
  48. package/src/utils/hasher.js +36 -0
  49. package/src/utils/integration-generator.js +16 -6
  50. package/src/utils/legacy-hook-migration.js +112 -0
  51. package/src/utils/locale.js +19 -0
  52. package/src/utils/open-work-tracking.mjs +124 -23
  53. package/standards-registry.json +21 -7
@@ -141,6 +141,10 @@ export async function installIntegrations(config, projectPath) {
141
141
  if (result.success) {
142
142
  results.integrations.push(result.path);
143
143
  generatedFiles.add(targetFile);
144
+ // Files this run created from nothing (not merged into an existing file);
145
+ // init records them so uninstall can prove they are wholly UDS's. Added only
146
+ // when there is one, so an empty run keeps its original result shape.
147
+ if (result.created) (results.createdFiles ||= []).push(result.path);
144
148
 
145
149
  // Capture integration block hash for tracking UDS content
146
150
  if (result.blockHashInfo) {
@@ -310,7 +314,7 @@ export async function generateUniversalAgentsMd(config, integrationResults, proj
310
314
 
311
315
  if (result.success) {
312
316
  spinner.succeed(msg.generatedAgentsMd || 'Generated AGENTS.md (universal summary)');
313
- return { path: result.path, error: null, blockHashInfo: result.blockHashInfo };
317
+ return { path: result.path, error: null, blockHashInfo: result.blockHashInfo, created: result.created };
314
318
  } else {
315
319
  spinner.warn(msg.couldNotGenerateAgentsMd || 'Could not generate AGENTS.md');
316
320
  return { path: null, error: result.error };
@@ -7,7 +7,7 @@ import {
7
7
  getOptionSource,
8
8
  findOption
9
9
  } from '../utils/registry.js';
10
- import { copyStandard } from '../utils/copier.js';
10
+ import { copyStandard, copyExtension } from '../utils/copier.js';
11
11
  import { t } from '../i18n/messages.js';
12
12
  import { computeFileHash } from '../utils/hasher.js';
13
13
  import { MANIFEST_OPTION_BINDINGS } from '../core/constants.js';
@@ -115,36 +115,29 @@ export async function installStandards(config, projectPath) {
115
115
  if (config.languages.length > 0 || config.frameworks.length > 0 || localeExtension) {
116
116
  const extSpinner = createSpinner(msg.copyingExtensions).start();
117
117
 
118
- for (const lang of config.languages) {
119
- if (EXTENSION_MAPPINGS[lang]) {
120
- const result = await copyStandard(EXTENSION_MAPPINGS[lang], '.standards', projectPath);
121
- if (result.success) {
122
- results.extensions.push(EXTENSION_MAPPINGS[lang]);
123
- } else {
124
- results.errors.push(`${EXTENSION_MAPPINGS[lang]}: ${result.error}`);
125
- }
118
+ // One helper, one copy call. Extension files come from the installed package only:
119
+ // a declared extension that is missing from it is an error naming that file, never a
120
+ // download and never a silent skip (XSPEC-452 R2).
121
+ const installExtension = async (sourcePath) => {
122
+ const result = await copyExtension(sourcePath, '.standards', projectPath);
123
+ if (result.success) {
124
+ results.extensions.push(sourcePath);
125
+ } else {
126
+ results.errors.push(`${sourcePath}: ${result.error}`);
126
127
  }
128
+ };
129
+
130
+ for (const lang of config.languages) {
131
+ if (EXTENSION_MAPPINGS[lang]) await installExtension(EXTENSION_MAPPINGS[lang]);
127
132
  }
128
133
 
129
134
  for (const fw of config.frameworks) {
130
- if (EXTENSION_MAPPINGS[fw]) {
131
- const result = await copyStandard(EXTENSION_MAPPINGS[fw], '.standards', projectPath);
132
- if (result.success) {
133
- results.extensions.push(EXTENSION_MAPPINGS[fw]);
134
- } else {
135
- results.errors.push(`${EXTENSION_MAPPINGS[fw]}: ${result.error}`);
136
- }
137
- }
135
+ if (EXTENSION_MAPPINGS[fw]) await installExtension(EXTENSION_MAPPINGS[fw]);
138
136
  }
139
137
 
140
138
  // Auto-install locale extension based on display language
141
139
  if (localeExtension && EXTENSION_MAPPINGS[localeExtension]) {
142
- const result = await copyStandard(EXTENSION_MAPPINGS[localeExtension], '.standards', projectPath);
143
- if (result.success) {
144
- results.extensions.push(EXTENSION_MAPPINGS[localeExtension]);
145
- } else {
146
- results.errors.push(`${EXTENSION_MAPPINGS[localeExtension]}: ${result.error}`);
147
- }
140
+ await installExtension(EXTENSION_MAPPINGS[localeExtension]);
148
141
  }
149
142
 
150
143
  extSpinner.succeed(msg.copiedExtensions.replace('{count}', results.extensions.length));
@@ -249,18 +249,17 @@ async function executeCreateOrUpdate(projectPath, action, manifest) {
249
249
  let targetDir = '.standards';
250
250
 
251
251
  if (metadata?.extensionSource) {
252
- // An entry from `manifest.extensions`. PathResolver cannot resolve these
253
- // from an npm install: the published package's `files` list is bin, src,
254
- // bundled, standards-registry.json and README.md — no `extensions/`. So
255
- // `sourcePath` is null for every adopter who did not install from a
256
- // source checkout, and this function answered "No source path available"
257
- // for a file it was perfectly able to fetch.
252
+ // An entry from `manifest.extensions`. It is resolved from the installed
253
+ // package (`bundled/extensions/`, shipped since XSPEC-452) — never from GitHub.
254
+ // `copyStandard` hands any `extensions/` path to `copyExtension`, which fails
255
+ // by name when the package lacks the file, instead of downloading it.
258
256
  //
259
- // The legacy update path never had the bug because it calls copyStandard
260
- // directly (`update.js`, "Update extensions"). The same upgrade therefore
261
- // refreshed `.standards/zh-tw.md` under `uds update` and failed under the
262
- // reconciler — the two paths disagreed about whether the file was
263
- // reachable, and only one of them was right. (XSPEC-343)
257
+ // History: before XSPEC-452 the published package had no `extensions/`, so
258
+ // `sourcePath` was null for every adopter who did not install from a source
259
+ // checkout, and this function answered "No source path available" for a file it
260
+ // could fetch from GitHub. The legacy update path (`update.js`, "Update
261
+ // extensions") was the one that worked — only because it fell back to a
262
+ // download. (XSPEC-343)
264
263
  sourceStr = metadata.extensionSource;
265
264
  } else if (metadata?.registryEntry) {
266
265
  const source = metadata.registryEntry.source;
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
2
2
  import { join, basename } from 'path';
3
+ import { proveUnchanged, RECORD_KINDS, RECORDS_KEY, isRealDirectory } from '../core/install-records.js';
4
+ import { stripUdsHookBlock } from '../utils/git-hooks.js';
3
5
  import {
4
6
  collectHookConfigs, standardsSourceDir, hooksSourceDir,
5
7
  CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
@@ -88,7 +90,7 @@ function applyHooksMap(config, nextMap) {
88
90
  * difference is which file and which key they look at.
89
91
  */
90
92
  function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile }) {
91
- const result = { removed: [], skipped: [], errors: [] };
93
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
92
94
  if (!existsSync(configPath)) return result; // nothing installed here — nothing to report
93
95
 
94
96
  let config;
@@ -106,18 +108,21 @@ function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile })
106
108
  }
107
109
 
108
110
  const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
111
+ const updatedConfig = applyHooksMap(config, nextMap);
112
+ const willDeleteFile = deleteEmptyFile && Object.keys(updatedConfig).length === 0;
113
+ // Decided BEFORE the dry-run return: the preview must say the file goes away,
114
+ // and the folder-cleanup step needs to know the folder will be empty.
109
115
  if (dryRun) {
110
- result.removed.push(entryLabel);
116
+ result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
117
+ if (willDeleteFile) result.deletedPaths.push(label);
111
118
  return result;
112
119
  }
113
120
 
114
- const updatedConfig = applyHooksMap(config, nextMap);
115
- const isNowEmpty = Object.keys(updatedConfig).length === 0;
116
-
117
121
  try {
118
- if (deleteEmptyFile && isNowEmpty) {
122
+ if (willDeleteFile) {
119
123
  unlinkSync(configPath);
120
124
  result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
125
+ result.deletedPaths.push(label);
121
126
  } else {
122
127
  writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n');
123
128
  result.removed.push(entryLabel);
@@ -186,7 +191,7 @@ export function uninstallAgyHooks(projectPath, options = {}) {
186
191
  const configPath = join(projectPath, '.agents', 'hooks.json');
187
192
  const label = '.agents/hooks.json';
188
193
  const dryRun = options.dryRun || false;
189
- const result = { removed: [], skipped: [], errors: [] };
194
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
190
195
  if (!existsSync(configPath)) return result;
191
196
 
192
197
  let config;
@@ -242,14 +247,17 @@ export function uninstallAgyHooks(projectPath, options = {}) {
242
247
  }
243
248
 
244
249
  const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
250
+ const willDeleteFile = Object.keys(next).length === 0;
245
251
  if (dryRun) {
246
- result.removed.push(entryLabel);
252
+ result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
253
+ if (willDeleteFile) result.deletedPaths.push(label);
247
254
  return result;
248
255
  }
249
256
  try {
250
- if (Object.keys(next).length === 0) {
257
+ if (willDeleteFile) {
251
258
  unlinkSync(configPath);
252
259
  result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
260
+ result.deletedPaths.push(label);
253
261
  } else {
254
262
  writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
255
263
  result.removed.push(entryLabel);
@@ -260,18 +268,113 @@ export function uninstallAgyHooks(projectPath, options = {}) {
260
268
  return result;
261
269
  }
262
270
 
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+ // Proof of authorship — "only delete what UDS can prove it wrote, unchanged".
273
+ //
274
+ // A record in `manifest.installedArtifacts` (core/install-records.js) says UDS
275
+ // wrote a file; a matching hash says nobody has changed it since. Both are
276
+ // required to delete a whole file. Anything else is KEPT, and the reason is
277
+ // printed, because "not provably ours" is the honest state of a file from an
278
+ // older UDS (no record) or one an adopter has edited (hash differs) — and
279
+ // guessing "probably ours" is how an adopter's own hook script gets deleted.
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+
282
+ /** Relative paths (forward slashes) of every file under `dir`, or [] if it is not there. */
283
+ function listFiles(dir, rel = '') {
284
+ if (!dir || !existsSync(dir)) return [];
285
+ const out = [];
286
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
287
+ const r = rel ? `${rel}/${e.name}` : e.name;
288
+ if (e.isDirectory()) out.push(...listFiles(join(dir, e.name), r));
289
+ else out.push(r);
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /**
295
+ * Remove the hook scripts UDS copied into `scripts/hooks/`.
296
+ *
297
+ * `scripts/hooks/` is a directory UDS scaffolds inside the adopter's project, so
298
+ * an adopter's own scripts can sit beside ours (a test in hook-uninstaller.test.js
299
+ * places one there and it must survive). Only files that have an install record
300
+ * AND still match it are deleted.
301
+ */
302
+ export function uninstallHookScripts(projectPath, manifest, { dryRun = false, blockedBy = null } = {}) {
303
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
304
+ const files = manifest?.[RECORDS_KEY]?.files || {};
305
+ const recorded = Object.entries(files)
306
+ .filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
307
+ .map(([rel]) => rel)
308
+ .sort();
309
+
310
+ for (const rel of recorded) {
311
+ const abs = join(projectPath, rel);
312
+ if (!existsSync(abs)) continue; // already gone — nothing to remove, nothing to report
313
+ if (blockedBy) {
314
+ result.skipped.push(`${rel} (kept: ${blockedBy})`);
315
+ continue;
316
+ }
317
+ const proof = proveUnchanged(manifest, projectPath, rel);
318
+ if (proof.state !== 'proven') {
319
+ result.skipped.push(`${rel} (kept: ${proof.why})`);
320
+ continue;
321
+ }
322
+ try {
323
+ if (!dryRun) unlinkSync(abs);
324
+ result.removed.push(`${rel} (deleted — installed by UDS, ${proof.why})`);
325
+ result.deletedPaths.push(rel);
326
+ } catch (error) {
327
+ result.errors.push(`${rel} — ${error.message}`);
328
+ }
329
+ }
330
+
331
+ // Files that carry the name of a script UDS ships but have no record. Say so once,
332
+ // so a leftover `scripts/hooks/` is an explained decision and not a silent gap.
333
+ const recordedSet = new Set(recorded);
334
+ const shipped = listFiles(hooksSourceDir());
335
+ const unrecorded = shipped
336
+ .map((f) => `scripts/hooks/${f}`)
337
+ .filter((rel) => existsSync(join(projectPath, rel)) && !recordedSet.has(rel));
338
+ if (unrecorded.length > 0) {
339
+ result.skipped.push(
340
+ `scripts/hooks/ (kept: ${unrecorded.length} file${unrecorded.length === 1 ? '' : 's'} carry the name of a UDS hook script, ` +
341
+ 'but the manifest has no record that UDS wrote them — installed by an older UDS, or by hand. ' +
342
+ 'Delete them yourself if you no longer want them)'
343
+ );
344
+ }
345
+ return result;
346
+ }
347
+
348
+ // Line-level fallback, for the one-line form older UDS wrote and for a block whose
349
+ // end marker an adopter removed. The current block is removed whole, by its markers
350
+ // (stripUdsHookBlock) — its inner lines are not individually matchable.
351
+ const UDS_PRECOMMIT_LINE = /uds\s+check|universal-dev-standards\s+check|checkin-standards|^#\s*UDS Standard Check\s*$/;
352
+ const NATIVE_UDS_LINE = /uds\s+check|checkin-standards|UDS pre-commit hook/;
353
+
354
+ /** True when nothing is left but a shebang and blank lines. */
355
+ function nothingButShebang(lines) {
356
+ return lines.every((l) => !l.trim() || /^#!/.test(l.trim()));
357
+ }
358
+
263
359
  /**
264
360
  * Remove UDS-related lines from .husky/pre-commit, the native
265
361
  * .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
266
362
  * into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
267
- * .agents/hooks.json.
363
+ * .agents/hooks.json; and the hook scripts copied into scripts/hooks/.
364
+ *
365
+ * Whole-file deletion (a hook file, a script) needs proof — see proveUnchanged.
366
+ * A hook file that is not provably wholly UDS's still has its UDS lines removed
367
+ * (each is a line UDS wrote, and the rest of the file is untouched) and the
368
+ * remainder is reported as kept, with the reason.
369
+ *
268
370
  * @param {string} projectPath - Project root path
269
- * @param {Object} options - { dryRun: boolean }
270
- * @returns {Object} { removed: string[], skipped: string[], errors: string[] }
371
+ * @param {Object} options - { dryRun: boolean, manifest: object|null }
372
+ * `manifest` must be read BEFORE `.standards/` is removed; it carries the install records.
373
+ * @returns {Object} { removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }
271
374
  */
272
375
  export function uninstallHook(projectPath, options = {}) {
273
- const { dryRun = false } = options;
274
- const result = { removed: [], skipped: [], errors: [] };
376
+ const { dryRun = false, manifest = null } = options;
377
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
275
378
  const hookPath = join(projectPath, '.husky', 'pre-commit');
276
379
 
277
380
  if (!existsSync(hookPath)) {
@@ -279,12 +382,23 @@ export function uninstallHook(projectPath, options = {}) {
279
382
  } else {
280
383
  try {
281
384
  const content = readFileSync(hookPath, 'utf-8');
385
+ const proof = proveUnchanged(manifest, projectPath, '.husky/pre-commit');
282
386
  const lines = content.split('\n');
283
- const udsPattern = /uds\s+check|checkin-standards/;
284
- const filteredLines = lines.filter(line => !udsPattern.test(line));
387
+ const filteredLines = stripUdsHookBlock(content).content.split('\n')
388
+ .filter(line => !UDS_PRECOMMIT_LINE.test(line));
285
389
 
286
- if (filteredLines.length === lines.length) {
390
+ if (proof.state === 'proven') {
391
+ // UDS created this file and nobody has touched it: nothing of the adopter's is in it.
392
+ if (!dryRun) unlinkSync(hookPath);
393
+ result.removed.push(`.husky/pre-commit (deleted — created by UDS, ${proof.why})`);
394
+ result.deletedPaths.push('.husky/pre-commit');
395
+ } else if (filteredLines.length === lines.length) {
287
396
  result.skipped.push('.husky/pre-commit (no UDS lines found)');
397
+ } else if (nothingButShebang(filteredLines)) {
398
+ // Only UDS's lines (and a shebang) were in it — removing them leaves nothing worth keeping.
399
+ if (!dryRun) unlinkSync(hookPath);
400
+ result.removed.push('.husky/pre-commit (UDS check lines; file removed — nothing else was in it)');
401
+ result.deletedPaths.push('.husky/pre-commit');
288
402
  } else if (dryRun) {
289
403
  result.removed.push('.husky/pre-commit (UDS check lines)');
290
404
  } else {
@@ -301,25 +415,32 @@ export function uninstallHook(projectPath, options = {}) {
301
415
  if (existsSync(nativeHookPath)) {
302
416
  try {
303
417
  const content = readFileSync(nativeHookPath, 'utf-8');
304
- const udsPattern = /uds\s+check|checkin-standards|UDS pre-commit hook/;
418
+ const proof = proveUnchanged(manifest, projectPath, '.git/hooks/pre-commit');
305
419
 
306
- if (!udsPattern.test(content)) {
420
+ if (proof.state === 'proven') {
421
+ // The whole script is what `uds init` wrote — including the parts no line
422
+ // pattern would ever match (its "Auto-generated by uds init" header, the
423
+ // linter fallbacks, the closing echo). Deleting only the matching lines
424
+ // used to leave that body behind, still executable, still claiming
425
+ // "Pre-commit checks passed".
426
+ if (!dryRun) unlinkSync(nativeHookPath);
427
+ result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
428
+ result.deletedPaths.push('.git/hooks/pre-commit');
429
+ } else if (!NATIVE_UDS_LINE.test(content) && !stripUdsHookBlock(content).removed) {
307
430
  result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
308
- } else if (dryRun) {
309
- result.removed.push('.git/hooks/pre-commit (UDS native hook)');
310
431
  } else {
311
- // Remove only UDS-related lines, keep other hook content
312
- const lines = content.split('\n');
313
- const filtered = lines.filter(line => !udsPattern.test(line));
314
-
315
- // If only shebang remains, remove the file entirely; otherwise rewrite
316
- const nonEmpty = filtered.filter(l => l.trim() && l.trim() !== '#!/bin/sh');
317
- if (nonEmpty.length === 0) {
318
- unlinkSync(nativeHookPath);
432
+ // The block the native hook carries is removed whole, by its markers — its
433
+ // inner lines are not individually matchable (same as the husky path above).
434
+ const filtered = stripUdsHookBlock(content).content.split('\n')
435
+ .filter(line => !NATIVE_UDS_LINE.test(line));
436
+ if (nothingButShebang(filtered)) {
437
+ if (!dryRun) unlinkSync(nativeHookPath);
319
438
  result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
439
+ result.deletedPaths.push('.git/hooks/pre-commit');
320
440
  } else {
321
- writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
441
+ if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
322
442
  result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
443
+ result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
323
444
  }
324
445
  }
325
446
  } catch (error) {
@@ -329,16 +450,81 @@ export function uninstallHook(projectPath, options = {}) {
329
450
 
330
451
  // Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
331
452
  // — the gap this function used to have entirely (see module doc comment above).
332
- for (const sub of [
453
+ const configResults = [
333
454
  uninstallClaudeCodeHooks(projectPath, { dryRun }),
334
455
  uninstallCodexHooks(projectPath, { dryRun }),
335
456
  uninstallGeminiHooks(projectPath, { dryRun }),
336
457
  uninstallAgyHooks(projectPath, { dryRun }),
337
- ]) {
458
+ ];
459
+ for (const sub of configResults) {
338
460
  result.removed.push(...sub.removed);
339
461
  result.skipped.push(...sub.skipped);
340
462
  result.errors.push(...sub.errors);
463
+ result.deletedPaths.push(...sub.deletedPaths);
341
464
  }
342
465
 
466
+ // The scripts those entries ran. If a config file could not be cleaned (it still
467
+ // names a script), deleting the script would turn a working hook into a failing one.
468
+ const configFailed = configResults.some((r) => r.errors.length > 0);
469
+ const scripts = uninstallHookScripts(projectPath, manifest, {
470
+ dryRun,
471
+ blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
472
+ });
473
+ result.removed.push(...scripts.removed);
474
+ result.skipped.push(...scripts.skipped);
475
+ result.errors.push(...scripts.errors);
476
+ result.deletedPaths.push(...scripts.deletedPaths);
477
+
478
+ return result;
479
+ }
480
+
481
+ /**
482
+ * Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
483
+ * once they are empty.
484
+ *
485
+ * A folder is removed only if (a) the manifest recorded that UDS created it and
486
+ * (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
487
+ * indistinguishable by looking; the record is the only thing that tells them apart.
488
+ * A folder that still holds anything — `.agents/rules/style.md` — is kept and
489
+ * reported, so the adopter sees why it stayed.
490
+ *
491
+ * In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
492
+ * the run WOULD delete) stands in for the missing deletions when judging "empty".
493
+ *
494
+ * @param {string} projectPath
495
+ * @param {Object|null} manifest - read before `.standards/` was removed
496
+ * @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
497
+ * @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
498
+ */
499
+ export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
500
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
501
+ const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
502
+ const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
503
+ // Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
504
+ const ordered = [...new Set(dirs)]
505
+ .filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
506
+ .sort((a, b) => b.split('/').length - a.split('/').length);
507
+
508
+ for (const rel of ordered) {
509
+ const abs = join(projectPath, rel);
510
+ if (!existsSync(abs)) continue;
511
+ if (!isRealDirectory(abs)) {
512
+ result.skipped.push(`${rel}/ (kept: not a plain directory)`);
513
+ continue;
514
+ }
515
+ try {
516
+ const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
517
+ if (left.length > 0) {
518
+ result.skipped.push(`${rel}/ (kept: created by UDS but not empty — still holds ${left.slice(0, 3).join(', ')}${left.length > 3 ? ', ...' : ''}, which UDS did not write)`);
519
+ continue;
520
+ }
521
+ if (!dryRun) rmdirSync(abs);
522
+ gone.add(rel);
523
+ result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
524
+ result.deletedPaths.push(rel);
525
+ } catch (error) {
526
+ result.errors.push(`${rel}/ — ${error.message}`);
527
+ }
528
+ }
343
529
  return result;
344
530
  }
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { extractMarkedContent } from '../utils/integration-generator.js';
4
4
  import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
5
5
  import { resolveIntegrationFile } from '../core/constants.js';
6
+ import { proveUnchanged } from '../core/install-records.js';
6
7
 
7
8
  /**
8
9
  * Get the format for a given integration file name
@@ -29,7 +30,7 @@ function getFormatForFile(fileName) {
29
30
  */
30
31
  export async function uninstallIntegrations(projectPath, manifest, options = {}) {
31
32
  const { dryRun = false, interactive = false, promptFn = null } = options;
32
- const result = { removed: [], skipped: [], errors: [] };
33
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
33
34
 
34
35
  const integrations = manifest?.integrations || [];
35
36
  if (integrations.length === 0) {
@@ -64,11 +65,27 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
64
65
  const userContent = (parts.before.trim() + parts.after.trim()).trim();
65
66
  const hasUserContent = userContent.length > 0;
66
67
 
68
+ // Text outside the UDS block is not automatically the adopter's. A file UDS
69
+ // creates from nothing carries generated text there — AGENTS.md's
70
+ // "> Auto-generated by ..." header and sections, CLAUDE.md's leading guidance —
71
+ // and treating all of it as "user content" left an orphaned file pointing at
72
+ // the `.standards/` this very command removes. It is only "not the adopter's"
73
+ // if UDS recorded a hash of it when it created the file and the text still
74
+ // matches; without that proof it stays, and the reason is printed.
75
+ const proof = hasUserContent ? proveUnchanged(manifest, projectPath, fileName) : null;
76
+ const outsideIsUdsOwn = hasUserContent && proof.state === 'proven';
77
+ const keptNote = () => `${fileName} (kept: the text outside the UDS block — ${proof.why})`;
78
+
67
79
  if (dryRun) {
68
- if (hasUserContent) {
69
- result.removed.push(`${fileName} (remove UDS block, keep user content)`);
70
- } else {
80
+ if (!hasUserContent) {
71
81
  result.removed.push(`${fileName} (delete file)`);
82
+ result.deletedPaths.push(fileName);
83
+ } else if (outsideIsUdsOwn) {
84
+ result.removed.push(`${fileName} (delete file — everything outside the UDS block was generated by UDS)`);
85
+ result.deletedPaths.push(fileName);
86
+ } else {
87
+ result.removed.push(`${fileName} (remove UDS block, keep user content)`);
88
+ result.skipped.push(keptNote());
72
89
  }
73
90
  continue;
74
91
  }
@@ -77,26 +94,39 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
77
94
  // 100% UDS-generated → delete entire file
78
95
  unlinkSync(filePath);
79
96
  result.removed.push(`${fileName} (deleted)`);
97
+ result.deletedPaths.push(fileName);
98
+ } else if (outsideIsUdsOwn) {
99
+ // UDS created this file and its non-block text is still exactly what UDS
100
+ // wrote: there is no adopter content to preserve, so no question to ask.
101
+ unlinkSync(filePath);
102
+ result.removed.push(`${fileName} (deleted — everything outside the UDS block was generated by UDS and is unchanged)`);
103
+ result.deletedPaths.push(fileName);
80
104
  } else if (interactive && promptFn) {
81
105
  // Ask user what to do
82
106
  const action = await promptFn(fileName, hasUserContent);
83
107
  if (action === 'delete-file') {
84
108
  unlinkSync(filePath);
85
109
  result.removed.push(`${fileName} (deleted)`);
110
+ result.deletedPaths.push(fileName);
86
111
  } else if (action === 'remove-block') {
87
112
  const cleaned = (parts.before + parts.after).trim() + '\n';
88
113
  writeFileSync(filePath, cleaned, 'utf-8');
89
114
  result.removed.push(`${fileName} (UDS block removed)`);
115
+ result.skipped.push(keptNote());
90
116
  } else {
91
117
  result.skipped.push(`${fileName} (user skipped)`);
92
118
  }
93
119
  } else {
94
- // Non-interactive (--yes): remove UDS block only, preserve user content
120
+ // Non-interactive (--yes): remove UDS block only, preserve everything else
95
121
  const cleaned = (parts.before + parts.after).trim() + '\n';
96
122
  writeFileSync(filePath, cleaned, 'utf-8');
97
123
  result.removed.push(`${fileName} (UDS block removed)`);
124
+ result.skipped.push(keptNote());
98
125
  }
99
126
  } catch (error) {
127
+ // A closed prompt is the user stopping the run, not a failure of this file:
128
+ // let the command report it (and exit non-zero) instead of retrying the next file.
129
+ if (error?.name === 'ExitPromptError') throw error;
100
130
  result.errors.push(`${fileName} — ${error.message}`);
101
131
  }
102
132
  }
@@ -26,15 +26,72 @@ function getSourcePath(sourcePath) {
26
26
  return PathResolver.getStandardSource(sourcePath);
27
27
  }
28
28
 
29
+ /**
30
+ * Is this source path one of the add-on files under `extensions/`?
31
+ * @param {string} sourcePath - Relative path from repo root
32
+ * @returns {boolean}
33
+ */
34
+ export function isExtensionPath(sourcePath) {
35
+ return typeof sourcePath === 'string' && sourcePath.replace(/\\/g, '/').startsWith('extensions/');
36
+ }
37
+
38
+ /**
39
+ * Copy an extension file (language style guide, framework pattern, locale pack)
40
+ * into the target project — from the installed package ONLY.
41
+ *
42
+ * 🔴 There is deliberately no download fallback here (XSPEC-452 R2). `extensions/`
43
+ * used to be missing from the npm package, so the copy below fell through to
44
+ * `raw.githubusercontent.com/.../main`: offline installs failed, an install pulled
45
+ * whatever `main` held that day instead of the version the adopter had installed,
46
+ * and a file that did not exist anywhere (`extensions/locales/zh-cn.md`, XSPEC-451)
47
+ * hid behind that fallback until someone ran the locale. A declared extension that
48
+ * is not in the package is a packaging defect, so it fails — by name.
49
+ *
50
+ * @param {string} sourcePath - Relative path from repo root (e.g., 'extensions/locales/zh-tw.md')
51
+ * @param {string} targetDir - Target directory (usually '.standards')
52
+ * @param {string} projectPath - Project root path
53
+ * @returns {Promise<Object>} Result with success status and copied path
54
+ */
55
+ export async function copyExtension(sourcePath, targetDir, projectPath) {
56
+ try {
57
+ const source = getSourcePath(sourcePath);
58
+ if (!source) {
59
+ return failure(
60
+ `Extension file not found in the installed package: ${sourcePath} (extension files are never downloaded; reinstall or upgrade universal-dev-standards)`,
61
+ ERROR_CODES.FILE_NOT_FOUND,
62
+ { sourcePath, targetDir }
63
+ );
64
+ }
65
+ const targetFolder = join(projectPath, targetDir);
66
+ const targetFile = join(targetFolder, basename(sourcePath));
67
+ if (!existsSync(targetFolder)) {
68
+ mkdirSync(targetFolder, { recursive: true });
69
+ }
70
+ copyFileSync(source, targetFile);
71
+ return success(targetFile, { source: 'local', sourcePath, targetFile });
72
+ } catch (error) {
73
+ return failure(
74
+ error.message,
75
+ ERROR_CODES.FILE_COPY_FAILED,
76
+ { sourcePath, targetDir, projectPath }
77
+ );
78
+ }
79
+ }
80
+
29
81
  /**
30
82
  * Copy a standard file to target project
31
83
  * Falls back to downloading from GitHub if local file not found
84
+ * (except for `extensions/` files, which are package-only — see copyExtension)
32
85
  * @param {string} sourcePath - Relative path from repo root (e.g., 'core/anti-hallucination.md')
33
86
  * @param {string} targetDir - Target directory (usually '.standards')
34
87
  * @param {string} projectPath - Project root path
35
88
  * @returns {Promise<Object>} Result with success status and copied path
36
89
  */
37
90
  export async function copyStandard(sourcePath, targetDir, projectPath) {
91
+ // Extensions never take the GitHub fallback below, whichever caller got here (XSPEC-452 R2).
92
+ if (isExtensionPath(sourcePath)) {
93
+ return copyExtension(sourcePath, targetDir, projectPath);
94
+ }
38
95
  try {
39
96
  const targetFolder = join(projectPath, targetDir);
40
97
  const targetFile = join(targetFolder, basename(sourcePath));