universal-dev-standards 6.14.0-beta.1 → 6.14.0-beta.3

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 (37) hide show
  1. package/bin/uds.js +37 -0
  2. package/bundled/ai/standards/open-work-tracking.ai.yaml +7 -2
  3. package/bundled/core/open-work-tracking.md +1 -1
  4. package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
  5. package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
  6. package/bundled/locales/zh-CN/CHANGELOG.md +38 -3
  7. package/bundled/locales/zh-CN/README.md +2 -2
  8. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  9. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
  10. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
  11. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
  12. package/bundled/locales/zh-TW/CHANGELOG.md +38 -3
  13. package/bundled/locales/zh-TW/README.md +2 -2
  14. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  15. package/bundled/locales/zh-TW/core/open-work-tracking.md +4 -4
  16. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
  17. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
  18. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
  19. package/package.json +1 -1
  20. package/src/commands/check.js +9 -0
  21. package/src/commands/init.js +73 -18
  22. package/src/commands/open-work.js +60 -0
  23. package/src/commands/uninstall.js +144 -30
  24. package/src/commands/update.js +149 -0
  25. package/src/core/install-records.js +191 -0
  26. package/src/i18n/messages.js +42 -6
  27. package/src/installers/hooks-installer.js +236 -27
  28. package/src/installers/integration-installer.js +5 -1
  29. package/src/uninstallers/hook-uninstaller.js +216 -32
  30. package/src/uninstallers/integration-uninstaller.js +35 -5
  31. package/src/utils/detector.js +46 -1
  32. package/src/utils/git-hooks.js +135 -7
  33. package/src/utils/hasher.js +36 -0
  34. package/src/utils/integration-generator.js +16 -6
  35. package/src/utils/legacy-hook-migration.js +112 -0
  36. package/src/utils/open-work-tracking.mjs +794 -0
  37. package/standards-registry.json +7 -7
@@ -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,30 @@ 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)) {
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
432
  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);
433
+ const filtered = lines.filter(line => !NATIVE_UDS_LINE.test(line));
434
+ if (nothingButShebang(filtered)) {
435
+ if (!dryRun) unlinkSync(nativeHookPath);
319
436
  result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
437
+ result.deletedPaths.push('.git/hooks/pre-commit');
320
438
  } else {
321
- writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
439
+ if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
322
440
  result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
441
+ result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
323
442
  }
324
443
  }
325
444
  } catch (error) {
@@ -329,16 +448,81 @@ export function uninstallHook(projectPath, options = {}) {
329
448
 
330
449
  // Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
331
450
  // — the gap this function used to have entirely (see module doc comment above).
332
- for (const sub of [
451
+ const configResults = [
333
452
  uninstallClaudeCodeHooks(projectPath, { dryRun }),
334
453
  uninstallCodexHooks(projectPath, { dryRun }),
335
454
  uninstallGeminiHooks(projectPath, { dryRun }),
336
455
  uninstallAgyHooks(projectPath, { dryRun }),
337
- ]) {
456
+ ];
457
+ for (const sub of configResults) {
338
458
  result.removed.push(...sub.removed);
339
459
  result.skipped.push(...sub.skipped);
340
460
  result.errors.push(...sub.errors);
461
+ result.deletedPaths.push(...sub.deletedPaths);
341
462
  }
342
463
 
464
+ // The scripts those entries ran. If a config file could not be cleaned (it still
465
+ // names a script), deleting the script would turn a working hook into a failing one.
466
+ const configFailed = configResults.some((r) => r.errors.length > 0);
467
+ const scripts = uninstallHookScripts(projectPath, manifest, {
468
+ dryRun,
469
+ blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
470
+ });
471
+ result.removed.push(...scripts.removed);
472
+ result.skipped.push(...scripts.skipped);
473
+ result.errors.push(...scripts.errors);
474
+ result.deletedPaths.push(...scripts.deletedPaths);
475
+
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
481
+ * once they are empty.
482
+ *
483
+ * A folder is removed only if (a) the manifest recorded that UDS created it and
484
+ * (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
485
+ * indistinguishable by looking; the record is the only thing that tells them apart.
486
+ * A folder that still holds anything — `.agents/rules/style.md` — is kept and
487
+ * reported, so the adopter sees why it stayed.
488
+ *
489
+ * In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
490
+ * the run WOULD delete) stands in for the missing deletions when judging "empty".
491
+ *
492
+ * @param {string} projectPath
493
+ * @param {Object|null} manifest - read before `.standards/` was removed
494
+ * @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
495
+ * @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
496
+ */
497
+ export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
498
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
499
+ const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
500
+ const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
501
+ // Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
502
+ const ordered = [...new Set(dirs)]
503
+ .filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
504
+ .sort((a, b) => b.split('/').length - a.split('/').length);
505
+
506
+ for (const rel of ordered) {
507
+ const abs = join(projectPath, rel);
508
+ if (!existsSync(abs)) continue;
509
+ if (!isRealDirectory(abs)) {
510
+ result.skipped.push(`${rel}/ (kept: not a plain directory)`);
511
+ continue;
512
+ }
513
+ try {
514
+ const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
515
+ if (left.length > 0) {
516
+ 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)`);
517
+ continue;
518
+ }
519
+ if (!dryRun) rmdirSync(abs);
520
+ gone.add(rel);
521
+ result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
522
+ result.deletedPaths.push(rel);
523
+ } catch (error) {
524
+ result.errors.push(`${rel}/ — ${error.message}`);
525
+ }
526
+ }
343
527
  return result;
344
528
  }
@@ -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
  }
@@ -111,6 +111,50 @@ export function detectFramework(projectPath) {
111
111
  return detected;
112
112
  }
113
113
 
114
+ /**
115
+ * Files and directories under `.agents/` that Antigravity (agy) owns.
116
+ *
117
+ * 🔴 Detection used to be `.agents/AGENTS.md` alone (2026-09-08). That file is one
118
+ * of the things agy reads but a project can use agy for a long time without ever
119
+ * creating it, so `uds init --with-hooks` in such a project never wired the hook
120
+ * (found 2026-09-29 installing 6.14.0-beta.1 into a fresh project).
121
+ *
122
+ * The markers below come from the agy 1.2.12 binary (`strings`: the literal path
123
+ * templates `.agents/rules/`, `.agents/workflows/`, `.agents/plugins/`,
124
+ * `.agents/hooks.json`, `.agents/skills.json`, `.agents/agents/`) and from
125
+ * antigravity.google/docs/hooks (project hooks live at `.agents/hooks.json`;
126
+ * "Rules" and "Workflows" are documented Antigravity concepts). Chosen: the ones
127
+ * documented or carried by the tool itself whose NAME is agy's — not the generic
128
+ * `agents/` or `skills.json`.
129
+ *
130
+ * ⚠️ `.agents/skills/` is NOT a marker. Codex reads project skills from the same
131
+ * `.agents/skills/` (measured 2026-09-08: only that arm made Codex see the
132
+ * skills), so a directory that both tools share cannot say which one is in use.
133
+ * A repo with root AGENTS.md plus `.agents/skills/` is Codex and must stay Codex.
134
+ *
135
+ * ⚠️ `.agents/hooks.json` is also the file `uds` itself writes for agy. Detecting
136
+ * on it is self-referential: after an install it proves the install happened, not
137
+ * that the adopter uses agy. It is kept because a hooks.json that someone else
138
+ * (the adopter, another tool) put there is real evidence, and because dropping it
139
+ * would make a re-run of the installer stop seeing the project it just wired.
140
+ *
141
+ * Not used: `~/.gemini/projects.json` lists the projects agy has opened. It is the
142
+ * tool's own registry and a strong signal, but it lives in the user's home, is
143
+ * machine-local, and would make the same repository detect differently on two
144
+ * machines. Detection stays a function of the project directory.
145
+ */
146
+ export const ANTIGRAVITY_MARKERS = ['AGENTS.md', 'hooks.json', 'rules', 'workflows', 'plugins'];
147
+
148
+ /**
149
+ * @param {string} projectPath
150
+ * @returns {boolean} true when `.agents/` carries something Antigravity owns
151
+ */
152
+ export function detectAntigravity(projectPath) {
153
+ const dir = join(projectPath, '.agents');
154
+ if (!existsSync(dir)) return false;
155
+ return ANTIGRAVITY_MARKERS.some((m) => existsSync(join(dir, m)));
156
+ }
157
+
114
158
  /**
115
159
  * Detect AI tools configured in the project
116
160
  * @param {string} projectPath - Path to the project
@@ -129,7 +173,8 @@ export function detectAITools(projectPath) {
129
173
  // Antigravity never read INSTRUCTIONS.md.
130
174
  // Measured 2026-09-08 with two positive controls in the same run: tokens planted in `AGENTS.md` and `.agents/AGENTS.md` both came back with correct attribution; the one in INSTRUCTIONS.md did not.
131
175
  // `.agents/AGENTS.md` is used rather than the repo root so it does not collide with Codex/OpenCode, which both target root AGENTS.md.
132
- antigravity: existsSync(join(projectPath, '.agents', 'AGENTS.md')),
176
+ // See detectAntigravity() for the wider marker set and for what is deliberately NOT one.
177
+ antigravity: detectAntigravity(projectPath),
133
178
  // 🔴 Roo Code had a full entry in the path table (`.roo/skills/`, tier "complete"
134
179
  // in REGISTRY.json) and NO line here, so `uds init` could never install for it —
135
180
  // however correct those paths were. Found by `check:install-paths`, which walks