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
@@ -3,7 +3,8 @@ import { select, checkbox, confirm } from '@inquirer/prompts';
3
3
  import { readManifest, manifestExists, writeManifest } from '../core/manifest.js';
4
4
  import { t } from '../i18n/messages.js';
5
5
  import { uninstallStandards } from '../uninstallers/standards-uninstaller.js';
6
- import { uninstallHook } from '../uninstallers/hook-uninstaller.js';
6
+ import { uninstallHook, pruneCreatedDirs } from '../uninstallers/hook-uninstaller.js';
7
+ import { forgetRecords } from '../core/install-records.js';
7
8
  import { uninstallIntegrations } from '../uninstallers/integration-uninstaller.js';
8
9
  import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
9
10
 
@@ -12,6 +13,35 @@ import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
12
13
  */
13
14
  const CATEGORIES = ['hooks', 'skills', 'integrations', 'standards'];
14
15
 
16
+ /**
17
+ * Exit codes. Before these existed every path out of `uninstall` was 0 — a run
18
+ * that removed nothing because the prompt died looked exactly like a completed
19
+ * uninstall to CI and to the adopter's own scripts.
20
+ */
21
+ const EXIT_NOT_INITIALIZED = 1; // nothing to uninstall / manifest unreadable
22
+ const EXIT_CANNOT_PROMPT = 2; // needed an answer, nobody could give one (same as `uds update`)
23
+ const EXIT_INTERRUPTED = 130; // the prompt was closed before answering (SIGINT convention)
24
+
25
+ /**
26
+ * Can anything answer a prompt? Decided BEFORE a prompt is drawn.
27
+ *
28
+ * `uds update` catches ExitPromptError instead (see confirmOrFail there) and
29
+ * explains why it avoids `isTTY`: a wrapped stdin can answer with isTTY unset.
30
+ * That reasoning is sound for a prompt that has already been drawn, but it leaves
31
+ * the visible symptom this command was reported for — the checkbox painted onto a
32
+ * pipe, then a stack trace. Here the question is asked first, and only stdin
33
+ * matters: prompts read from it. An answer that arrives anyway (a test double, a
34
+ * wrapper) is not blocked by ExitPromptError handling further down.
35
+ */
36
+ function canPrompt() {
37
+ return Boolean(process.stdin.isTTY);
38
+ }
39
+
40
+ /** `@inquirer/prompts` throws this when stdin closes or the user presses Ctrl+C. */
41
+ function isPromptClosed(err) {
42
+ return err?.name === 'ExitPromptError' || /force closed the prompt/i.test(err?.message || '');
43
+ }
44
+
15
45
  /**
16
46
  * Uninstall command - remove UDS standards, integrations, skills, and hooks
17
47
  * @param {Object} options - Command options
@@ -25,19 +55,25 @@ export async function uninstallCommand(options) {
25
55
  console.log(chalk.bold(msg.title));
26
56
  console.log(chalk.gray('─'.repeat(50)));
27
57
 
28
- // Check if UDS is initialized
58
+ // Check if UDS is initialized. A project with nothing to uninstall is not a
59
+ // successful uninstall: exit non-zero so a script can tell the two apart.
29
60
  if (!manifestExists(projectPath)) {
30
61
  console.log(chalk.yellow(common.notInitialized));
31
62
  console.log(chalk.gray(` ${common.runInit}`));
63
+ process.exitCode = EXIT_NOT_INITIALIZED;
32
64
  return;
33
65
  }
34
66
 
35
67
  const manifest = readManifest(projectPath);
36
68
  if (!manifest) {
37
69
  console.log(chalk.red(common.couldNotReadManifest));
70
+ process.exitCode = EXIT_NOT_INITIALIZED;
38
71
  return;
39
72
  }
40
73
 
74
+ const includeUserLevel = options.all || false;
75
+ const dryRun = options.dryRun || false;
76
+
41
77
  // Determine which categories to uninstall
42
78
  let selectedCategories;
43
79
  if (options.all) {
@@ -48,20 +84,34 @@ export async function uninstallCommand(options) {
48
84
  selectedCategories = ['skills'];
49
85
  } else if (options.integrationsOnly) {
50
86
  selectedCategories = ['integrations'];
51
- } else if (options.yes) {
52
- // --yes without specific flag → all categories
87
+ } else if (options.yes || dryRun) {
88
+ // --yes without a specific flag → all categories.
89
+ // --dry-run → all categories too, and WITHOUT a prompt: a dry run writes
90
+ // nothing, so there is nothing to ask before showing what a run would do —
91
+ // and its whole use is to be runnable unattended (CI, a pipe). It used to
92
+ // draw the category checkbox even then, and die on it.
53
93
  selectedCategories = [...CATEGORIES];
54
94
  } else {
55
95
  // Interactive: checkbox selection
56
- const categories = await checkbox({
57
- message: msg.selectCategories,
58
- choices: [
59
- { name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
60
- { name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
61
- { name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
62
- { name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
63
- ]
64
- });
96
+ if (!canPrompt()) {
97
+ refuseToPrompt(msg);
98
+ return;
99
+ }
100
+ let categories;
101
+ try {
102
+ categories = await checkbox({
103
+ message: msg.selectCategories,
104
+ choices: [
105
+ { name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
106
+ { name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
107
+ { name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
108
+ { name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
109
+ ]
110
+ });
111
+ } catch (err) {
112
+ if (reportClosedPrompt(err, msg)) return;
113
+ throw err;
114
+ }
65
115
 
66
116
  if (categories.length === 0) {
67
117
  console.log(chalk.yellow(msg.nothingSelected));
@@ -70,9 +120,6 @@ export async function uninstallCommand(options) {
70
120
  selectedCategories = categories;
71
121
  }
72
122
 
73
- const includeUserLevel = options.all || false;
74
- const dryRun = options.dryRun || false;
75
-
76
123
  // Gather preview: run all uninstallers in dry-run mode to build summary
77
124
  const preview = await gatherPreview(projectPath, manifest, selectedCategories, includeUserLevel);
78
125
 
@@ -87,10 +134,22 @@ export async function uninstallCommand(options) {
87
134
 
88
135
  // Confirm (unless --yes or --dry-run)
89
136
  if (!dryRun && !options.yes) {
90
- const confirmed = await confirm({
91
- message: msg.confirmUninstall,
92
- default: false
93
- });
137
+ // Not auto-confirmed when nobody can answer: an unattended shell must not get
138
+ // more permission to delete files than an interactive one is given.
139
+ if (!canPrompt()) {
140
+ refuseToPrompt(msg);
141
+ return;
142
+ }
143
+ let confirmed;
144
+ try {
145
+ confirmed = await confirm({
146
+ message: msg.confirmUninstall,
147
+ default: false
148
+ });
149
+ } catch (err) {
150
+ if (reportClosedPrompt(err, msg)) return;
151
+ throw err;
152
+ }
94
153
 
95
154
  if (!confirmed) {
96
155
  console.log(chalk.yellow(common.cancelled));
@@ -106,18 +165,52 @@ export async function uninstallCommand(options) {
106
165
 
107
166
  // Execute uninstallation in order: hooks → skills → integrations → standards
108
167
  console.log();
109
- const results = await executeUninstall(
110
- projectPath, manifest, selectedCategories,
111
- { includeUserLevel, interactive: !options.yes }
112
- );
168
+ let results;
169
+ try {
170
+ results = await executeUninstall(
171
+ projectPath, manifest, selectedCategories,
172
+ { includeUserLevel, interactive: !options.yes }
173
+ );
174
+ } catch (err) {
175
+ // A per-file question asked mid-run (which is why this cannot be checked up
176
+ // front) was closed. Earlier steps are already done and stay done.
177
+ if (!isPromptClosed(err)) throw err;
178
+ console.log();
179
+ console.log(chalk.red(msg.promptClosedMidRun));
180
+ console.log();
181
+ process.exitCode = EXIT_INTERRUPTED;
182
+ return;
183
+ }
113
184
 
114
185
  // Update or remove manifest
115
- updateManifestAfterUninstall(projectPath, manifest, selectedCategories);
186
+ updateManifestAfterUninstall(projectPath, manifest, selectedCategories, collectDeletedPaths(results));
116
187
 
117
188
  // Display results
118
189
  displayResults(results, msg);
119
190
  }
120
191
 
192
+ /** Say why a real run cannot proceed without --yes, and set the exit code. Nothing has been changed. */
193
+ function refuseToPrompt(msg) {
194
+ console.log();
195
+ console.log(chalk.red(msg.cannotPrompt));
196
+ console.log(chalk.gray(` ${msg.cannotPromptHint}`));
197
+ console.log();
198
+ process.exitCode = EXIT_CANNOT_PROMPT;
199
+ }
200
+
201
+ /**
202
+ * If `err` is "the prompt was closed before an answer", report it, set a non-zero
203
+ * exit code and return true. Any other error is the caller's to rethrow.
204
+ */
205
+ function reportClosedPrompt(err, msg) {
206
+ if (!isPromptClosed(err)) return false;
207
+ console.log();
208
+ console.log(chalk.red(msg.promptClosed));
209
+ console.log();
210
+ process.exitCode = EXIT_INTERRUPTED;
211
+ return true;
212
+ }
213
+
121
214
  /**
122
215
  * Gather preview of what will be removed (dry-run all uninstallers)
123
216
  */
@@ -125,7 +218,7 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
125
218
  const preview = {};
126
219
 
127
220
  if (categories.includes('hooks')) {
128
- preview.hooks = uninstallHook(projectPath, { dryRun: true });
221
+ preview.hooks = uninstallHook(projectPath, { dryRun: true, manifest });
129
222
  }
130
223
  if (categories.includes('skills')) {
131
224
  preview.skills = uninstallSkills(projectPath, manifest, { dryRun: true, includeUserLevel });
@@ -136,10 +229,23 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
136
229
  if (categories.includes('standards')) {
137
230
  preview.standards = uninstallStandards(projectPath, { dryRun: true });
138
231
  }
232
+ if (categories.includes('hooks')) {
233
+ // Nothing is deleted yet in a preview, so folders are judged against what the
234
+ // steps above WOULD delete.
235
+ preview.folders = pruneCreatedDirs(projectPath, manifest, {
236
+ dryRun: true,
237
+ plannedDeletions: collectDeletedPaths(preview)
238
+ });
239
+ }
139
240
 
140
241
  return preview;
141
242
  }
142
243
 
244
+ /** Every relative path the given step results deleted (or, for a preview, would delete). */
245
+ function collectDeletedPaths(results) {
246
+ return Object.values(results).flatMap((r) => r.deletedPaths || []);
247
+ }
248
+
143
249
  /**
144
250
  * Execute actual uninstallation
145
251
  */
@@ -148,7 +254,7 @@ async function executeUninstall(projectPath, manifest, categories, options) {
148
254
  const results = {};
149
255
 
150
256
  if (categories.includes('hooks')) {
151
- results.hooks = uninstallHook(projectPath);
257
+ results.hooks = uninstallHook(projectPath, { manifest });
152
258
  }
153
259
  if (categories.includes('skills')) {
154
260
  results.skills = uninstallSkills(projectPath, manifest, { includeUserLevel });
@@ -163,6 +269,10 @@ async function executeUninstall(projectPath, manifest, categories, options) {
163
269
  if (categories.includes('standards')) {
164
270
  results.standards = uninstallStandards(projectPath);
165
271
  }
272
+ if (categories.includes('hooks')) {
273
+ // After everything else, so a folder that only held UDS files is empty by now.
274
+ results.folders = pruneCreatedDirs(projectPath, manifest);
275
+ }
166
276
 
167
277
  return results;
168
278
  }
@@ -188,7 +298,7 @@ function createIntegrationPromptFn() {
188
298
  /**
189
299
  * Update or remove manifest after uninstall
190
300
  */
191
- function updateManifestAfterUninstall(projectPath, manifest, categories) {
301
+ function updateManifestAfterUninstall(projectPath, manifest, categories, deletedPaths = []) {
192
302
  const removedStandards = categories.includes('standards');
193
303
 
194
304
  if (removedStandards) {
@@ -196,8 +306,10 @@ function updateManifestAfterUninstall(projectPath, manifest, categories) {
196
306
  return;
197
307
  }
198
308
 
199
- // Partial uninstall: update manifest to reflect removed items
200
- const updated = { ...manifest };
309
+ // Partial uninstall: update manifest to reflect removed items. Install records
310
+ // for the paths just deleted go too — a record for a file that is gone would
311
+ // otherwise "prove" authorship of whatever a user later puts at that path.
312
+ const updated = forgetRecords({ ...manifest }, deletedPaths);
201
313
 
202
314
  if (categories.includes('skills')) {
203
315
  updated.skills = {
@@ -281,6 +393,8 @@ function displayResults(results, msg) {
281
393
  console.log(chalk.green(msg.uninstallSuccess));
282
394
  } else {
283
395
  console.log(chalk.yellow(msg.uninstallPartial));
396
+ // "Completed with errors" is not a success to whoever runs this from a script.
397
+ process.exitCode = 1;
284
398
  }
285
399
  console.log(chalk.gray(` ${msg.removed}: ${totalRemoved} ${msg.skippedLabel}: ${totalSkipped} ${msg.errorsLabel}: ${totalErrors}`));
286
400
  }
@@ -46,6 +46,9 @@ import {
46
46
  } from '../config/ai-agent-paths.js';
47
47
  import { getMarketplaceSkillsInfo } from '../utils/github.js';
48
48
  import { detectAITools } from '../utils/detector.js';
49
+ import { HOOK_CAPABLE_TOOLS, resolveHookTools, installMissingHooks } from '../installers/hooks-installer.js';
50
+ import { persistRecorder, mergeRecorderInto } from '../core/install-records.js';
51
+ import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
49
52
  import {
50
53
  promptSkillsInstallLocation,
51
54
  promptCommandsInstallation
@@ -451,6 +454,15 @@ export async function updateCommand(options) {
451
454
  return;
452
455
  }
453
456
 
457
+ // Handle --with-hooks (added 2026-09-29): install the enforcement hooks that are
458
+ // MISSING from an already-initialized project — the door `uds init --with-hooks`
459
+ // cannot be, because `uds init` refuses to run twice. Standalone like
460
+ // --claude-target and --sync-refs; and like them it honours --plan by writing nothing.
461
+ if (options.withHooks) {
462
+ await updateHooksOnly(projectPath, manifest, options);
463
+ return;
464
+ }
465
+
454
466
  // Handle --sync-refs option.
455
467
  // `--plan` is honoured here too. This branch is above the mode dispatch
456
468
  // because sync-refs is its own operation rather than a scope of the
@@ -488,6 +500,13 @@ export async function updateCommand(options) {
488
500
  const scopedToSkills = !!options.skills;
489
501
  const scopedToCommands = !!options.commands;
490
502
 
503
+ // A pre-commit line an older UDS wrote asks npm for the bare name `uds`, which
504
+ // is not this project. Fixed here, before any mode below can return early
505
+ // (an up-to-date adopter still has it). Narrowed runs stay narrowed.
506
+ if (!options.rollback && !scopedToSkills && !scopedToCommands && !options.standardsOnly) {
507
+ migrateLegacyPreCommitHook(projectPath, manifest, { plan: !!options.plan });
508
+ }
509
+
491
510
  // Handle --rollback option (DSR). It restores a whole backup, so a scope
492
511
  // flag cannot narrow it — say so rather than appearing to honour it.
493
512
  if (options.rollback) {
@@ -1827,6 +1846,136 @@ async function switchClaudeTarget(projectPath, manifest, target, options) { // e
1827
1846
  * @param {Object} manifest - Manifest object (will be mutated with updated hashes)
1828
1847
  * @returns {{success: boolean, updated: string[], errors: string[]}}
1829
1848
  */
1849
+ /**
1850
+ * Swap the pre-commit line an older UDS wrote (`npx` + the bare name `uds`) for
1851
+ * the block current UDS writes, and say what was done or why not.
1852
+ *
1853
+ * Runs in every `uds update` mode that is not narrowed to something else, and in
1854
+ * `--with-hooks`, and it runs BEFORE the "already up to date" early return: an
1855
+ * adopter whose standards are current is exactly the one still carrying the old
1856
+ * line. Under `--plan` it reports and writes nothing.
1857
+ *
1858
+ * Only a line UDS can be shown to have written is changed (exact match under
1859
+ * UDS's marker comment — see legacy-hook-migration.js); any other bare-name line
1860
+ * is reported with its line number and left alone.
1861
+ *
1862
+ * @param {string} projectPath
1863
+ * @param {object} manifest - mutated only to carry a refreshed install record
1864
+ * @param {{plan?: boolean}} [opts]
1865
+ * @returns {ReturnType<typeof migrateLegacyHuskyHook>}
1866
+ */
1867
+ export function migrateLegacyPreCommitHook(projectPath, manifest, { plan = false } = {}) {
1868
+ const msg = t().commands.update;
1869
+ const r = migrateLegacyHuskyHook(projectPath, { plan, manifest });
1870
+ if (r.state === 'error') {
1871
+ console.log(chalk.yellow(` ${msg.hookMigrateFailed.replace('{error}', r.error)}`));
1872
+ console.log();
1873
+ return r;
1874
+ }
1875
+ if (r.state === 'migrated') console.log(chalk.green(` ${msg.hookMigrated}`));
1876
+ if (r.state === 'would-migrate') console.log(chalk.cyan(` ${msg.hookWouldMigrate}`));
1877
+ for (const k of r.kept) {
1878
+ console.log(chalk.yellow(` ${msg.hookLegacyKept.replace('{line}', k.line).replace('{text}', k.text)}`));
1879
+ }
1880
+ if (r.state !== 'none') console.log();
1881
+ if (r.recorder) {
1882
+ persistRecorder(projectPath, r.recorder);
1883
+ // The manifest object is written again later in most update paths; without
1884
+ // this the record persisted just above would be overwritten by the older copy.
1885
+ Object.assign(manifest, mergeRecorderInto(manifest, r.recorder));
1886
+ }
1887
+ return r;
1888
+ }
1889
+
1890
+ /**
1891
+ * `uds update --with-hooks [--ai-tool <list>] [--plan] [--force]`
1892
+ *
1893
+ * Adds the enforcement hooks that are missing; a hook that is already there is not
1894
+ * touched, and the adopter's own hooks are never touched (see installMissingHooks).
1895
+ * Exit code 1 only when there is no hook-capable tool to act on, or an install failed:
1896
+ * "nothing to do" is 0, "could not tell which tool" is not.
1897
+ *
1898
+ * @param {string} projectPath
1899
+ * @param {object} manifest
1900
+ * @param {{ plan?: boolean, force?: boolean, aiTool?: string, yes?: boolean }} options
1901
+ * @returns {Promise<void>}
1902
+ */
1903
+ export async function updateHooksOnly(projectPath, manifest, options = {}) {
1904
+ const ignored = ['skills', 'commands', 'syncRefs', 'integrationsOnly', 'standardsOnly', 'apply', 'rollback', 'prune']
1905
+ .filter((k) => options[k]).map((k) => `--${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`);
1906
+ if (ignored.length) {
1907
+ console.log(chalk.yellow(` ! --with-hooks does one thing and does not compose; ${ignored.join(', ')} ignored.`));
1908
+ console.log();
1909
+ }
1910
+
1911
+ // The hook lines UDS wrote in the past belong to this command's subject too.
1912
+ migrateLegacyPreCommitHook(projectPath, manifest, { plan: !!options.plan });
1913
+
1914
+ const capable = HOOK_CAPABLE_TOOLS.join(', ');
1915
+ const resolved = resolveHookTools(projectPath, manifest, { aiTool: options.aiTool });
1916
+
1917
+ if (resolved.unknown.length) {
1918
+ console.log(chalk.red(` ✗ No hook installer for: ${resolved.unknown.join(', ')}`));
1919
+ console.log(chalk.gray(` Tools with hooks: ${capable}`));
1920
+ console.log();
1921
+ process.exitCode = 1;
1922
+ return;
1923
+ }
1924
+ if (resolved.tools.length === 0) {
1925
+ // Both non-interactive and interactive get the same message: there is no prompt to
1926
+ // fall back to here, so the way to say which tool is spelled out instead.
1927
+ console.log(chalk.yellow(' ⚠ Could not tell which AI tool to install hooks for.'));
1928
+ console.log(chalk.gray(' Neither this project\'s manifest nor its files name one of: ' + capable + '.'));
1929
+ console.log(chalk.gray(' Say which one: uds update --with-hooks --ai-tool <tool>[,<tool>...]'));
1930
+ console.log(chalk.gray(' e.g. uds update --with-hooks --ai-tool antigravity'));
1931
+ console.log(chalk.gray(' What is detected: claude-code (.claude/ or CLAUDE.md) · codex (root AGENTS.md) · gemini-cli (GEMINI.md)'));
1932
+ console.log(chalk.gray(' antigravity (.agents/AGENTS.md, .agents/rules/, .agents/workflows/, .agents/plugins/ or .agents/hooks.json; .agents/skills/ is shared with Codex and does not count)'));
1933
+ console.log();
1934
+ process.exitCode = 1;
1935
+ return;
1936
+ }
1937
+
1938
+ console.log(chalk.bold(options.plan ? 'Hooks — plan (nothing is written)' : 'Hooks'));
1939
+ for (const tool of resolved.tools) {
1940
+ console.log(chalk.gray(` ${tool}: ${resolved.sources[tool].join(', ')}`));
1941
+ }
1942
+
1943
+ const { results, scripts, artifacts } = installMissingHooks(projectPath, resolved.tools, {
1944
+ plan: !!options.plan,
1945
+ overwriteScripts: !!options.force,
1946
+ });
1947
+
1948
+ // Record what was just written (hook scripts, and folders UDS had to create) in
1949
+ // the manifest, so `uds uninstall` can remove exactly that. Never on --plan: a
1950
+ // plan writes nothing, and the recorder is empty then anyway.
1951
+ if (!options.plan) persistRecorder(projectPath, artifacts);
1952
+
1953
+ let failed = false;
1954
+ for (const r of results) {
1955
+ const rel = r.path ? relative(projectPath, r.path) || r.path : '';
1956
+ if (r.outcome === 'installed') console.log(chalk.green(` ✓ ${r.tool}: installed${r.repaired ? ' (replaced an out-of-date UDS entry)' : ''} — ${rel}`));
1957
+ else if (r.outcome === 'would-install') console.log(chalk.cyan(` + ${r.tool}: would install${r.repaired ? ' (replacing an out-of-date UDS entry)' : ''} — ${rel}`));
1958
+ else if (r.outcome === 'unchanged') console.log(chalk.gray(` · ${r.tool}: already installed, not touched — ${rel}`));
1959
+ else { failed = true; console.log(chalk.yellow(` ⚠ ${r.tool}: not installed — ${r.why}`)); }
1960
+ }
1961
+ const installedAny = results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install');
1962
+ if (installedAny && scripts.kept.length) {
1963
+ console.log(chalk.yellow(` ! ${scripts.kept.length} hook script(s) in scripts/hooks/ differ from this UDS version and were kept: ${scripts.kept.slice(0, 5).join(', ')}${scripts.kept.length > 5 ? ', ...' : ''}`));
1964
+ console.log(chalk.gray(' Add --force to overwrite them with the shipped versions.'));
1965
+ }
1966
+ if (results.some((r) => r.tool === 'antigravity' && r.outcome === 'installed')) {
1967
+ console.log(chalk.yellow(' ⚠ Verified against a real agy session for a single turn without tool calls in `agy -p` mode only; multi-turn, tool-call turns and interactive mode are not yet verified.'));
1968
+ }
1969
+ if (results.some((r) => r.tool === 'codex' && r.outcome === 'installed')) {
1970
+ console.log(chalk.yellow(' ⚠ Codex will not run it until you trust it: open Codex in this project, trust the project, then run /hooks and trust this hook.'));
1971
+ }
1972
+ if (!results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install')) {
1973
+ console.log(chalk.gray(' Nothing to add.'));
1974
+ }
1975
+ console.log();
1976
+ if (failed) process.exitCode = 1;
1977
+ }
1978
+
1830
1979
  export function regenerateIntegrations(projectPath, manifest) {
1831
1980
  const aiTools = manifest.aiTools || [];
1832
1981
 
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Install records — the manifest's account of what UDS itself put on disk, kept
3
+ * so that `uds uninstall` removes exactly that and nothing else.
4
+ *
5
+ * Why a record and not a rule: `uds uninstall` used to leave four kinds of UDS
6
+ * output behind (hook scripts under scripts/hooks/, emptied `.codex/`-style
7
+ * folders, integration files whose generated header sits outside the UDS
8
+ * marker block, and the body of the native pre-commit hook). Each could be
9
+ * cleaned by pattern-matching on its content or path — and each of those
10
+ * patterns also matches files an adopter wrote themselves (`scripts/hooks/` is
11
+ * a directory UDS scaffolds INSIDE the adopter's project, where their own hook
12
+ * scripts may live). Origin is a fact about the moment of writing, so it is
13
+ * recorded then: a file is deleted only if a record says UDS wrote it AND its
14
+ * content still hashes to what UDS wrote. No record, or a changed hash, means
15
+ * the file is kept and the reason is printed.
16
+ *
17
+ * Deliberately NOT stored in `fileHashes` (`uds check` treats that as the list of
18
+ * standards files and would report these as modified/untracked) or in
19
+ * `provenance` (`uds update` prunes entries outside its expected set).
20
+ * `installedArtifacts` is read by nothing except uninstall.
21
+ *
22
+ * Shape:
23
+ * installedArtifacts: {
24
+ * files: { '<rel/path>': { kind, hash, size, installedAt } },
25
+ * createdDirs: ['<rel/dir>', ...] // directories UDS had to mkdir
26
+ * }
27
+ *
28
+ * `kind` selects what `hash` covers:
29
+ * 'hook-script', 'git-hook' → whole file (line endings normalized)
30
+ * 'integration-file' → everything outside the UDS marker block
31
+ *
32
+ * @module core/install-records
33
+ */
34
+
35
+ import { existsSync, mkdirSync, lstatSync, readFileSync, writeFileSync } from 'fs';
36
+ import { join, dirname, relative, isAbsolute } from 'path';
37
+ import { computeFileHash, computeOutsideBlockHash } from '../utils/hasher.js';
38
+ import { getManifestPath } from './manifest.js';
39
+
40
+ export const RECORDS_KEY = 'installedArtifacts';
41
+
42
+ export const RECORD_KINDS = Object.freeze({
43
+ HOOK_SCRIPT: 'hook-script',
44
+ GIT_HOOK: 'git-hook',
45
+ INTEGRATION_FILE: 'integration-file'
46
+ });
47
+
48
+ const norm = (p) => String(p).replace(/\\/g, '/');
49
+
50
+ /** A fresh, empty recorder. Installers fill it; a caller persists it. */
51
+ export function newRecorder() {
52
+ return { files: {}, createdDirs: [] };
53
+ }
54
+
55
+ /**
56
+ * The hash a record of this kind holds for a file as it is on disk right now.
57
+ * Also what uninstall recomputes and compares — one function, so the two sides
58
+ * cannot drift apart.
59
+ * @returns {{hash: string, size: number}|null}
60
+ */
61
+ export function currentHashFor(kind, absPath) {
62
+ if (kind === RECORD_KINDS.INTEGRATION_FILE) return computeOutsideBlockHash(absPath);
63
+ return computeFileHash(absPath);
64
+ }
65
+
66
+ /**
67
+ * Record a file UDS has just written. Call it AFTER the write, so the hash is of
68
+ * what is on disk. No-op (returns false) if the file cannot be hashed.
69
+ */
70
+ export function recordFile(recorder, projectPath, relPath, kind) {
71
+ const rel = norm(relPath);
72
+ const h = currentHashFor(kind, join(projectPath, rel));
73
+ if (!h) return false;
74
+ recorder.files[rel] = { kind, hash: h.hash, size: h.size, installedAt: new Date().toISOString() };
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * `mkdir -p`, remembering every directory that did not exist before this call.
80
+ * "UDS created this directory" is only knowable at the moment of creation; after
81
+ * that an empty `.codex/` looks the same whether UDS or the adopter made it.
82
+ */
83
+ export function mkdirTracked(recorder, projectPath, absDir) {
84
+ const missing = [];
85
+ let cur = absDir;
86
+ while (!existsSync(cur)) {
87
+ missing.push(cur);
88
+ const parent = dirname(cur);
89
+ if (parent === cur) break;
90
+ cur = parent;
91
+ }
92
+ if (missing.length === 0) return;
93
+ mkdirSync(absDir, { recursive: true });
94
+ for (const dir of missing.reverse()) {
95
+ const rel = norm(relative(projectPath, dir));
96
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
97
+ if (!recorder.createdDirs.includes(rel)) recorder.createdDirs.push(rel);
98
+ }
99
+ }
100
+
101
+ /** Fold a recorder into a manifest object (returned as a new object; input untouched). */
102
+ export function mergeRecorderInto(manifest, recorder) {
103
+ const prev = manifest?.[RECORDS_KEY] || {};
104
+ const dirs = new Set([...(prev.createdDirs || []), ...(recorder?.createdDirs || [])]);
105
+ return {
106
+ ...manifest,
107
+ [RECORDS_KEY]: {
108
+ files: { ...(prev.files || {}), ...(recorder?.files || {}) },
109
+ createdDirs: [...dirs]
110
+ }
111
+ };
112
+ }
113
+
114
+ /** Drop records for paths UDS has now removed. */
115
+ export function forgetRecords(manifest, relPaths) {
116
+ const prev = manifest?.[RECORDS_KEY];
117
+ if (!prev) return manifest;
118
+ const gone = new Set((relPaths || []).map(norm));
119
+ const files = Object.fromEntries(Object.entries(prev.files || {}).filter(([k]) => !gone.has(k)));
120
+ const createdDirs = (prev.createdDirs || []).filter((d) => !gone.has(d));
121
+ return { ...manifest, [RECORDS_KEY]: { files, createdDirs } };
122
+ }
123
+
124
+ /** The record for a path, or undefined. */
125
+ export function getFileRecord(manifest, relPath) {
126
+ return manifest?.[RECORDS_KEY]?.files?.[norm(relPath)];
127
+ }
128
+
129
+ /** True when the recorder holds anything worth writing. */
130
+ export function hasRecords(recorder) {
131
+ return !!recorder && (Object.keys(recorder.files).length > 0 || recorder.createdDirs.length > 0);
132
+ }
133
+
134
+ /**
135
+ * Persist a recorder into the project's manifest on disk. For callers that run
136
+ * after the manifest was written (init's hook step, `uds update --with-hooks`).
137
+ * Returns false — and writes nothing — when there is nothing to record or no
138
+ * manifest to record it in.
139
+ *
140
+ * Reads and writes the manifest as RAW JSON, not through `readManifest`. That
141
+ * function migrates on read (it rewrites `standards` from paths to registry IDs,
142
+ * among other normalizations), so a read-modify-write through it would silently
143
+ * change every other field in the file as a side effect of recording one new
144
+ * one — measured: the e2e test "auto-restore missing files" reads `standards`
145
+ * straight from the file `uds init` wrote and started failing because init's
146
+ * manifest had come out in a different format.
147
+ */
148
+ export function persistRecorder(projectPath, recorder) {
149
+ if (!hasRecords(recorder)) return false;
150
+ const manifestPath = getManifestPath(projectPath);
151
+ if (!existsSync(manifestPath)) return false;
152
+ let raw;
153
+ try {
154
+ raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
155
+ } catch {
156
+ return false;
157
+ }
158
+ if (!raw || typeof raw !== 'object') return false;
159
+ writeFileSync(manifestPath, JSON.stringify(mergeRecorderInto(raw, recorder), null, 2));
160
+ return true;
161
+ }
162
+
163
+ /** Is `absPath` a real directory (not a symlink to one)? Uninstall never follows links. */
164
+ export function isRealDirectory(absPath) {
165
+ try {
166
+ const st = lstatSync(absPath);
167
+ return st.isDirectory() && !st.isSymbolicLink();
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @returns {{ state: 'proven'|'no-record'|'changed', why: string }}
175
+ * proven a record exists and the file still hashes to it
176
+ * no-record nothing says UDS wrote it (older UDS, or written by hand)
177
+ * changed UDS wrote it, and it has been edited since
178
+ */
179
+ export function proveUnchanged(manifest, projectPath, relPath) {
180
+ const rec = getFileRecord(manifest, relPath);
181
+ if (!rec) {
182
+ return {
183
+ state: 'no-record',
184
+ why: 'no install record — installed by an older UDS or not by UDS, so UDS cannot prove it wrote this'
185
+ };
186
+ }
187
+ const now = currentHashFor(rec.kind, join(projectPath, relPath));
188
+ if (now && now.hash === rec.hash) return { state: 'proven', why: 'unchanged since UDS wrote it' };
189
+ return { state: 'changed', why: 'modified since UDS wrote it' };
190
+ }
191
+