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
@@ -4,7 +4,7 @@ import { select, confirm as inquirerConfirm, checkbox, Separator } from '@inquir
4
4
  import { execSync } from 'child_process';
5
5
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
6
6
  import { join, basename, dirname, relative } from 'path';
7
- import { readManifest, writeManifest, copyStandard, isInitialized, getRepoRoot } from '../utils/copier.js';
7
+ import { readManifest, writeManifest, copyStandard, copyExtension, isInitialized, getRepoRoot } from '../utils/copier.js';
8
8
  import { getRepositoryInfo, getAllStandards, getShippableFilenames, getStandardSource } from '../utils/registry.js';
9
9
  import { computeFileHash, planStandardsRemovals, refreshIntegrationBlockHashes, pruneIntegrationFileHashes } from '../utils/hasher.js';
10
10
  import { AmbiguousMarkerError } from '../utils/marker-locator.js';
@@ -47,6 +47,8 @@ import {
47
47
  import { getMarketplaceSkillsInfo } from '../utils/github.js';
48
48
  import { detectAITools } from '../utils/detector.js';
49
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';
50
52
  import {
51
53
  promptSkillsInstallLocation,
52
54
  promptCommandsInstallation
@@ -498,6 +500,13 @@ export async function updateCommand(options) {
498
500
  const scopedToSkills = !!options.skills;
499
501
  const scopedToCommands = !!options.commands;
500
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
+
501
510
  // Handle --rollback option (DSR). It restores a whole backup, so a scope
502
511
  // flag cannot narrow it — say so rather than appearing to honour it.
503
512
  if (options.rollback) {
@@ -729,7 +738,8 @@ export async function updateCommand(options) {
729
738
  // Update extensions (skip non-string entries like custom-domain objects)
730
739
  for (const ext of manifest.extensions) {
731
740
  if (typeof ext !== 'string') continue;
732
- const result = await copyStandard(ext, '.standards', projectPath);
741
+ // Package-only: an extension is never downloaded (XSPEC-452 R2).
742
+ const result = await copyExtension(ext, '.standards', projectPath);
733
743
  if (result.success) {
734
744
  results.updated.push(ext);
735
745
  recordWritten(result);
@@ -1837,6 +1847,47 @@ async function switchClaudeTarget(projectPath, manifest, target, options) { // e
1837
1847
  * @param {Object} manifest - Manifest object (will be mutated with updated hashes)
1838
1848
  * @returns {{success: boolean, updated: string[], errors: string[]}}
1839
1849
  */
1850
+ /**
1851
+ * Swap the pre-commit line an older UDS wrote (`npx` + the bare name `uds`) for
1852
+ * the block current UDS writes, and say what was done or why not.
1853
+ *
1854
+ * Runs in every `uds update` mode that is not narrowed to something else, and in
1855
+ * `--with-hooks`, and it runs BEFORE the "already up to date" early return: an
1856
+ * adopter whose standards are current is exactly the one still carrying the old
1857
+ * line. Under `--plan` it reports and writes nothing.
1858
+ *
1859
+ * Only a line UDS can be shown to have written is changed (exact match under
1860
+ * UDS's marker comment — see legacy-hook-migration.js); any other bare-name line
1861
+ * is reported with its line number and left alone.
1862
+ *
1863
+ * @param {string} projectPath
1864
+ * @param {object} manifest - mutated only to carry a refreshed install record
1865
+ * @param {{plan?: boolean}} [opts]
1866
+ * @returns {ReturnType<typeof migrateLegacyHuskyHook>}
1867
+ */
1868
+ export function migrateLegacyPreCommitHook(projectPath, manifest, { plan = false } = {}) {
1869
+ const msg = t().commands.update;
1870
+ const r = migrateLegacyHuskyHook(projectPath, { plan, manifest });
1871
+ if (r.state === 'error') {
1872
+ console.log(chalk.yellow(` ${msg.hookMigrateFailed.replace('{error}', r.error)}`));
1873
+ console.log();
1874
+ return r;
1875
+ }
1876
+ if (r.state === 'migrated') console.log(chalk.green(` ${msg.hookMigrated}`));
1877
+ if (r.state === 'would-migrate') console.log(chalk.cyan(` ${msg.hookWouldMigrate}`));
1878
+ for (const k of r.kept) {
1879
+ console.log(chalk.yellow(` ${msg.hookLegacyKept.replace('{line}', k.line).replace('{text}', k.text)}`));
1880
+ }
1881
+ if (r.state !== 'none') console.log();
1882
+ if (r.recorder) {
1883
+ persistRecorder(projectPath, r.recorder);
1884
+ // The manifest object is written again later in most update paths; without
1885
+ // this the record persisted just above would be overwritten by the older copy.
1886
+ Object.assign(manifest, mergeRecorderInto(manifest, r.recorder));
1887
+ }
1888
+ return r;
1889
+ }
1890
+
1840
1891
  /**
1841
1892
  * `uds update --with-hooks [--ai-tool <list>] [--plan] [--force]`
1842
1893
  *
@@ -1858,6 +1909,9 @@ export async function updateHooksOnly(projectPath, manifest, options = {}) {
1858
1909
  console.log();
1859
1910
  }
1860
1911
 
1912
+ // The hook lines UDS wrote in the past belong to this command's subject too.
1913
+ migrateLegacyPreCommitHook(projectPath, manifest, { plan: !!options.plan });
1914
+
1861
1915
  const capable = HOOK_CAPABLE_TOOLS.join(', ');
1862
1916
  const resolved = resolveHookTools(projectPath, manifest, { aiTool: options.aiTool });
1863
1917
 
@@ -1887,11 +1941,16 @@ export async function updateHooksOnly(projectPath, manifest, options = {}) {
1887
1941
  console.log(chalk.gray(` ${tool}: ${resolved.sources[tool].join(', ')}`));
1888
1942
  }
1889
1943
 
1890
- const { results, scripts } = installMissingHooks(projectPath, resolved.tools, {
1944
+ const { results, scripts, artifacts } = installMissingHooks(projectPath, resolved.tools, {
1891
1945
  plan: !!options.plan,
1892
1946
  overwriteScripts: !!options.force,
1893
1947
  });
1894
1948
 
1949
+ // Record what was just written (hook scripts, and folders UDS had to create) in
1950
+ // the manifest, so `uds uninstall` can remove exactly that. Never on --plan: a
1951
+ // plan writes nothing, and the recorder is empty then anyway.
1952
+ if (!options.plan) persistRecorder(projectPath, artifacts);
1953
+
1895
1954
  let failed = false;
1896
1955
  for (const r of results) {
1897
1956
  const rel = r.path ? relative(projectPath, r.path) || r.path : '';
@@ -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
+
@@ -875,7 +875,7 @@ export const messages = {
875
875
  // Pre-commit hook wiring (XSPEC: uds init writes a hook but never
876
876
  // confirmed git would run it — this reports the gap, read-only)
877
877
  hookNotWiredTitle: '⚠ [pre-commit] {file} was installed, but git will not run it.',
878
- hookNotWiredOverride: ' git core.hooksPath is set to "{path}" — UDS will not override it. Confirm the hook there also runs `npx uds check`, or switch it yourself: git config --local core.hooksPath .husky',
878
+ hookNotWiredOverride: ' git core.hooksPath is set to "{path}" — UDS will not override it. Confirm the hook there also runs `universal-dev-standards check` (the installed CLI — not the short name "uds", which on the npm registry is an unrelated package), or switch it yourself: git config --local core.hooksPath .husky',
879
879
  hookNotWiredUnwired: ' git core.hooksPath is not set, and {file} is not on git\'s default hook path.',
880
880
  hookNotWiredFix: ' Fix (per-clone — not committed, teammates must repeat it): git config --local core.hooksPath .husky',
881
881
  hookNotWiredFixNative: ' Fix: point core.hooksPath back to the default (git config --local --unset core.hooksPath), or add `uds check` under "{path}" instead.',
@@ -892,6 +892,8 @@ export const messages = {
892
892
  // file or directory". Measured 2026-09-27 in CI (windows-latest).
893
893
  hookMissingShebangTitle: '⚠ [pre-commit] {file} has no shebang line — git cannot run it on Windows.',
894
894
  hookMissingShebangFix: ' Fix: add a `#!/bin/sh` shebang as the very first line of {file} (a husky-managed hook gets this automatically the next time `uds init` touches it), or re-run `uds init`.',
895
+ hookBareUdsTitle: '⚠ [pre-commit] {file} asks npm to run the bare name "uds", which on the npm registry is an unrelated package (not this project).',
896
+ hookBareUdsFix: ' Fix: run `uds update` — it replaces the line UDS itself wrote with one that only runs the installed UDS CLI. A line you wrote or edited yourself is left alone; change it to `universal-dev-standards check`.',
895
897
  // Summary mode (--summary)
896
898
  summary_mode: {
897
899
  title: 'UDS Status Summary',
@@ -1038,6 +1040,11 @@ export const messages = {
1038
1040
  currentVersion: 'Current version',
1039
1041
  latestVersion: 'Latest version',
1040
1042
  upToDate: '✓ Standards are up to date.',
1043
+ // Pre-commit hook migration (bare-name `uds` on the npm registry is not this project)
1044
+ hookMigrated: '✓ .husky/pre-commit: replaced the line an older UDS wrote (it asked npm to resolve the bare name "uds", which is not this project) with one that runs only the installed UDS CLI.',
1045
+ hookWouldMigrate: '+ .husky/pre-commit: would replace the line an older UDS wrote (it asks npm to resolve the bare name "uds", which is not this project) with one that runs only the installed UDS CLI.',
1046
+ hookLegacyKept: '⚠ .husky/pre-commit line {line} ("{text}") asks npm to resolve the bare name "uds", which on the npm registry is an unrelated package. UDS did not write this exact line, so it was left alone. Change it to "universal-dev-standards check" — it needs the UDS CLI installed in this project or on PATH.',
1047
+ hookMigrateFailed: '⚠ Could not rewrite .husky/pre-commit: {error}. The line that asks npm for the bare name "uds" is still there — change it to "universal-dev-standards check".',
1041
1048
  newerVersion: '(You have a newer version than the registry: {version})',
1042
1049
  updateAvailable: 'Update available: {current} → {latest}',
1043
1050
  // Files
@@ -1264,7 +1271,11 @@ export const messages = {
1264
1271
  uninstallPartial: '⚠ Uninstall completed with errors.',
1265
1272
  removed: 'Removed',
1266
1273
  skippedLabel: 'Skipped',
1267
- errorsLabel: 'Errors'
1274
+ errorsLabel: 'Errors',
1275
+ cannotPrompt: 'Cannot ask for confirmation: nothing is attached to answer the prompt (non-interactive shell, CI, or a pipe).',
1276
+ cannotPromptHint: 'Nothing has been changed. Re-run with --yes to confirm up front, or with --dry-run to preview the changes.',
1277
+ promptClosed: 'The prompt was closed before an answer was given. Nothing has been changed.',
1278
+ promptClosedMidRun: 'A prompt was closed part-way through. Steps already completed stay done; run `uds uninstall` again to finish.'
1268
1279
  }
1269
1280
  }
1270
1281
  },
@@ -2135,7 +2146,7 @@ export const messages = {
2135
2146
  missingSkillsHint: '提示:執行 `uds update` 安裝缺少的 Skills/斜線命令',
2136
2147
  // 提交前檢查是否真的會被 git 執行(uds init 寫了檢查檔,卻沒確認 git 會跑它——這裡只回報,不寫入)
2137
2148
  hookNotWiredTitle: '⚠ [pre-commit] {file} 已安裝,但 git 實際不會執行它。',
2138
- hookNotWiredOverride: ' git core.hooksPath 已設定為「{path}」——UDS 不會覆蓋它。請確認該路徑下的檔案也會執行 `npx uds check`,或自行改用:git config --local core.hooksPath .husky',
2149
+ hookNotWiredOverride: ' git core.hooksPath 已設定為「{path}」——UDS 不會覆蓋它。請確認該路徑下的檔案也會執行 `universal-dev-standards check`(要用已安裝的 CLI——不要用短名稱 uds,它在 npm registry 上是不相干的套件),或自行改用:git config --local core.hooksPath .husky',
2139
2150
  hookNotWiredUnwired: ' git core.hooksPath 未設定,而 {file} 也不在 git 預設會讀取的路徑上。',
2140
2151
  hookNotWiredFix: ' 修復方式(僅對此 clone 生效,不會進版控,其他人 clone 後要自己再跑一次):git config --local core.hooksPath .husky',
2141
2152
  hookNotWiredFixNative: ' 修復方式:把 core.hooksPath 改回預設(git config --local --unset core.hooksPath),或改在「{path}」底下也加上 `uds check`。',
@@ -2151,6 +2162,8 @@ export const messages = {
2151
2162
  // 2026-09-27 於 CI(windows-latest)實測。
2152
2163
  hookMissingShebangTitle: '⚠ [pre-commit] {file} 沒有 shebang 行——在 Windows 上 git 無法執行它。',
2153
2164
  hookMissingShebangFix: ' 修復方式:在 {file} 的第一行加上 shebang `#!/bin/sh`(由 husky 管理的 hook,下次 `uds init` 動到它時會自動處理),或重新執行 `uds init`。',
2165
+ hookBareUdsTitle: '⚠ [pre-commit] {file} 會請 npm 執行裸名稱 uds,而它在 npm registry 上是不相干的套件(不是本專案)。',
2166
+ hookBareUdsFix: ' 修復方式:執行 `uds update`——它會把 UDS 自己寫入的那一行換成只執行已安裝 UDS CLI 的寫法。你自己寫或改過的那一行不會被動,請自行改成 `universal-dev-standards check`。',
2154
2167
  // Summary mode (--summary)
2155
2168
  summary_mode: {
2156
2169
  title: 'UDS 狀態摘要',
@@ -2294,6 +2307,11 @@ export const messages = {
2294
2307
  currentVersion: '目前版本',
2295
2308
  latestVersion: '最新版本',
2296
2309
  upToDate: '✓ 標準已是最新版本。',
2310
+ // pre-commit hook 遷移(npm registry 上的裸名稱 uds 不是本專案)
2311
+ hookMigrated: '✓ .husky/pre-commit:已把舊版 UDS 寫入的那一行(它會請 npm 解析裸名稱 uds,而那不是本專案)換成只執行已安裝 UDS CLI 的寫法。',
2312
+ hookWouldMigrate: '+ .husky/pre-commit:將把舊版 UDS 寫入的那一行(它會請 npm 解析裸名稱 uds,而那不是本專案)換成只執行已安裝 UDS CLI 的寫法。',
2313
+ hookLegacyKept: '⚠ .husky/pre-commit 第 {line} 行(「{text}」)會請 npm 解析裸名稱 uds,而它在 npm registry 上是不相干的套件。這一行不是 UDS 寫入的原樣,所以沒有動它。請自行改成 universal-dev-standards check——需要在本專案或 PATH 上已安裝 UDS CLI。',
2314
+ hookMigrateFailed: '⚠ 無法改寫 .husky/pre-commit:{error}。請 npm 解析裸名稱 uds 的那一行仍在,請自行改成 universal-dev-standards check。',
2297
2315
  newerVersion: '(你的版本比登錄庫更新:{version})',
2298
2316
  updateAvailable: '有可用更新:{current} → {latest}',
2299
2317
  // Files
@@ -2520,7 +2538,11 @@ export const messages = {
2520
2538
  uninstallPartial: '⚠ 解除安裝完成但有錯誤。',
2521
2539
  removed: '已移除',
2522
2540
  skippedLabel: '已跳過',
2523
- errorsLabel: '錯誤'
2541
+ errorsLabel: '錯誤',
2542
+ cannotPrompt: '無法詢問確認:目前沒有可回答提示的終端機(非互動 shell、CI 或管線)。',
2543
+ cannotPromptHint: '尚未變更任何檔案。請加上 --yes 事先確認,或加上 --dry-run 預覽變更。',
2544
+ promptClosed: '提示在回答前被關閉。尚未變更任何檔案。',
2545
+ promptClosedMidRun: '執行途中提示被關閉。已完成的步驟維持完成;請再執行一次 `uds uninstall` 完成其餘部分。'
2524
2546
  }
2525
2547
  }
2526
2548
  },
@@ -3407,7 +3429,7 @@ export const messages = {
3407
3429
  missingSkillsHint: '提示:执行 `uds update` 安装缺少的 Skills/斜线命令',
3408
3430
  // 提交前检查是否真的会被 git 执行(uds init 写了检查文件,却没确认 git 会跑它——这里只回报,不写入)
3409
3431
  hookNotWiredTitle: '⚠ [pre-commit] {file} 已安装,但 git 实际不会执行它。',
3410
- hookNotWiredOverride: ' git core.hooksPath 已设定为“{path}”——UDS 不会覆盖它。请确认该路径下的文件也会执行 `npx uds check`,或自行改用:git config --local core.hooksPath .husky',
3432
+ hookNotWiredOverride: ' git core.hooksPath 已设定为“{path}”——UDS 不会覆盖它。请确认该路径下的文件也会执行 `universal-dev-standards check`(要用已安装的 CLI——不要用短名称 uds,它在 npm registry 上是不相干的包),或自行改用:git config --local core.hooksPath .husky',
3411
3433
  hookNotWiredUnwired: ' git core.hooksPath 未设定,而 {file} 也不在 git 默认会读取的路径上。',
3412
3434
  hookNotWiredFix: ' 修复方式(仅对此 clone 生效,不会进版控,其他人 clone 后要自己再跑一次):git config --local core.hooksPath .husky',
3413
3435
  hookNotWiredFixNative: ' 修复方式:把 core.hooksPath 改回默认(git config --local --unset core.hooksPath),或改在“{path}”下也加上 `uds check`。',
@@ -3419,6 +3441,8 @@ export const messages = {
3419
3441
  // 2026-09-27 于 CI(windows-latest)实测。
3420
3442
  hookMissingShebangTitle: '⚠ [pre-commit] {file} 没有 shebang 行——在 Windows 上 git 无法执行它。',
3421
3443
  hookMissingShebangFix: ' 修复方式:在 {file} 的第一行加上 shebang `#!/bin/sh`(由 husky 管理的 hook,下次 `uds init` 动到它时会自动处理),或重新运行 `uds init`。',
3444
+ hookBareUdsTitle: '⚠ [pre-commit] {file} 会请 npm 执行裸名称 uds,而它在 npm registry 上是不相干的包(不是本项目)。',
3445
+ hookBareUdsFix: ' 修复方式:运行 `uds update`——它会把 UDS 自己写入的那一行换成只执行已安装 UDS CLI 的写法。你自己写或改过的那一行不会被动,请自行改成 `universal-dev-standards check`。',
3422
3446
  // Summary mode (--summary)
3423
3447
  summary_mode: {
3424
3448
  title: 'UDS 状态摘要',
@@ -3474,6 +3498,11 @@ export const messages = {
3474
3498
  currentVersion: '当前版本',
3475
3499
  latestVersion: '最新版本',
3476
3500
  upToDate: '✓ 标准是最新的。',
3501
+ // pre-commit hook 迁移(npm registry 上的裸名称 uds 不是本项目)
3502
+ hookMigrated: '✓ .husky/pre-commit:已把旧版 UDS 写入的那一行(它会请 npm 解析裸名称 uds,而那不是本项目)换成只执行已安装 UDS CLI 的写法。',
3503
+ hookWouldMigrate: '+ .husky/pre-commit:将把旧版 UDS 写入的那一行(它会请 npm 解析裸名称 uds,而那不是本项目)换成只执行已安装 UDS CLI 的写法。',
3504
+ hookLegacyKept: '⚠ .husky/pre-commit 第 {line} 行(“{text}”)会请 npm 解析裸名称 uds,而它在 npm registry 上是不相干的包。这一行不是 UDS 写入的原样,所以没有动它。请自行改成 universal-dev-standards check——需要在本项目或 PATH 上已安装 UDS CLI。',
3505
+ hookMigrateFailed: '⚠ 无法改写 .husky/pre-commit:{error}。请 npm 解析裸名称 uds 的那一行仍在,请自行改成 universal-dev-standards check。',
3477
3506
  newerVersion: '(您有比注册表更新的版本:{version})',
3478
3507
  updateAvailable: '有可用更新:{current} → {latest}',
3479
3508
  // Files
@@ -3700,7 +3729,11 @@ export const messages = {
3700
3729
  uninstallPartial: '⚠ 卸载完成但有错误。',
3701
3730
  removed: '已移除',
3702
3731
  skippedLabel: '已跳过',
3703
- errorsLabel: '错误'
3732
+ errorsLabel: '错误',
3733
+ cannotPrompt: '无法询问确认:当前没有可回答提示的终端(非交互 shell、CI 或管道)。',
3734
+ cannotPromptHint: '尚未更改任何文件。请加上 --yes 提前确认,或加上 --dry-run 预览变更。',
3735
+ promptClosed: '提示在回答前被关闭。尚未更改任何文件。',
3736
+ promptClosedMidRun: '执行途中提示被关闭。已完成的步骤保持完成;请再运行一次 `uds uninstall` 完成其余部分。'
3704
3737
  }
3705
3738
  }
3706
3739
  }
@@ -31,12 +31,13 @@
31
31
  * @see core/turn-completion-integrity.md
32
32
  */
33
33
 
34
- import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, readdirSync } from 'fs';
35
- import { join, dirname, basename } from 'path';
34
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, readdirSync } from 'fs';
35
+ import { join, dirname, basename, relative } from 'path';
36
36
  import { fileURLToPath } from 'url';
37
37
  import { execFileSync } from 'child_process';
38
38
  import { load as parseYaml } from 'js-yaml';
39
39
  import { detectAITools } from '../utils/detector.js';
40
+ import { newRecorder, mkdirTracked, recordFile, RECORD_KINDS } from '../core/install-records.js';
40
41
 
41
42
  const __dirname = dirname(fileURLToPath(import.meta.url));
42
43
 
@@ -172,7 +173,41 @@ function probeLanguageLimits(hooksDir, scripts) {
172
173
  return out;
173
174
  }
174
175
 
175
- export function installHooks(projectPath, { overwriteScripts = true } = {}) {
176
+ /**
177
+ * Copy the shipped hook scripts (recursively — a hook may ship a directory beside
178
+ * it: locale packs, fixtures) into `<project>/scripts/hooks`, and RECORD every
179
+ * file this call actually wrote and every directory it had to create.
180
+ *
181
+ * `cpSync` did the copy before and returns nothing, which is why `uds uninstall`
182
+ * had no way to know which of the files under `scripts/hooks/` were UDS's — a
183
+ * directory an adopter's own hook scripts may share. A file that already exists
184
+ * and is byte-identical is not recorded (this call did not write it, so it
185
+ * proves nothing about who did); a file that exists, differs, and is not
186
+ * overwritten is left alone and not recorded.
187
+ *
188
+ * @param {string} srcDir Shipped hooks directory
189
+ * @param {string} destDir Destination directory (absolute)
190
+ * @param {{ overwrite: boolean, recorder: object, projectPath: string }} ctx
191
+ */
192
+ function copyTreeRecorded(srcDir, destDir, ctx) {
193
+ mkdirTracked(ctx.recorder, ctx.projectPath, destDir);
194
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
195
+ const src = join(srcDir, entry.name);
196
+ const dest = join(destDir, entry.name);
197
+ if (entry.isDirectory()) {
198
+ copyTreeRecorded(src, dest, ctx);
199
+ continue;
200
+ }
201
+ if (existsSync(dest)) {
202
+ if (!ctx.overwrite) continue;
203
+ if (readFileSync(src).equals(readFileSync(dest))) continue;
204
+ }
205
+ copyFileSync(src, dest);
206
+ recordFile(ctx.recorder, ctx.projectPath, relative(ctx.projectPath, dest), RECORD_KINDS.HOOK_SCRIPT);
207
+ }
208
+ }
209
+
210
+ export function installHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
176
211
  const claudeDir = join(projectPath, '.claude');
177
212
  const settingsPath = join(claudeDir, 'settings.json');
178
213
  const hooksDir = join(projectPath, 'scripts', 'hooks');
@@ -187,11 +222,8 @@ export function installHooks(projectPath, { overwriteScripts = true } = {}) {
187
222
  return { installed: false, scriptsCount: 0, settingsPath, events, skipped };
188
223
  }
189
224
 
190
- if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
191
- if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
192
-
193
- // Recursive: a hook may ship a directory beside it (locale packs, fixtures).
194
- cpSync(hookDir, hooksDir, { recursive: true, force: overwriteScripts });
225
+ mkdirTracked(recorder, projectPath, claudeDir);
226
+ copyTreeRecorded(hookDir, hooksDir, { overwrite: overwriteScripts, recorder, projectPath });
195
227
 
196
228
  let settings = {};
197
229
  if (existsSync(settingsPath)) {
@@ -217,6 +249,7 @@ export function installHooks(projectPath, { overwriteScripts = true } = {}) {
217
249
  events,
218
250
  skipped,
219
251
  languageLimits: probeLanguageLimits(hooksDir, scripts),
252
+ artifacts: recorder,
220
253
  };
221
254
  }
222
255
 
@@ -264,9 +297,8 @@ export const AGY_HOOK_COMMAND_LEGACY = `node scripts/hooks/${AGY_HOOK_SCRIPT}`;
264
297
  * `overwrite: false` keeps a script that already exists (an adopter may have
265
298
  * edited it); it is what `uds update --with-hooks` uses unless `--force` is given.
266
299
  */
267
- function copyHookScripts(hookDir, hooksDir, { overwrite = true } = {}) {
268
- if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
269
- cpSync(hookDir, hooksDir, { recursive: true, force: overwrite });
300
+ function copyHookScripts(hookDir, hooksDir, { overwrite = true, recorder, projectPath } = {}) {
301
+ copyTreeRecorded(hookDir, hooksDir, { overwrite, recorder, projectPath });
270
302
  }
271
303
 
272
304
  /**
@@ -283,7 +315,7 @@ function copyHookScripts(hookDir, hooksDir, { overwrite = true } = {}) {
283
315
  * @param {string} projectPath
284
316
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
285
317
  */
286
- export function installCodexHooks(projectPath, { overwriteScripts = true } = {}) {
318
+ export function installCodexHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
287
319
  const hooksJsonPath = join(projectPath, '.codex', 'hooks.json');
288
320
  const hookDir = hooksSourceDir();
289
321
 
@@ -291,9 +323,8 @@ export function installCodexHooks(projectPath, { overwriteScripts = true } = {})
291
323
  return { installed: false, settingsPath: hooksJsonPath, reason: `hook script not found: ${CODEX_HOOK_SCRIPT}` };
292
324
  }
293
325
 
294
- const codexDir = join(projectPath, '.codex');
295
- if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
296
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
326
+ mkdirTracked(recorder, projectPath, join(projectPath, '.codex'));
327
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
297
328
 
298
329
  let config = {};
299
330
  if (existsSync(hooksJsonPath)) {
@@ -310,7 +341,7 @@ export function installCodexHooks(projectPath, { overwriteScripts = true } = {})
310
341
  ]);
311
342
 
312
343
  writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
313
- return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
344
+ return { installed: true, settingsPath: hooksJsonPath, event: 'Stop', artifacts: recorder };
314
345
  }
315
346
 
316
347
  /**
@@ -323,7 +354,7 @@ export function installCodexHooks(projectPath, { overwriteScripts = true } = {})
323
354
  * @param {string} projectPath
324
355
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
325
356
  */
326
- export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}) {
357
+ export function installGeminiHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
327
358
  const settingsPath = join(projectPath, '.gemini', 'settings.json');
328
359
  const hookDir = hooksSourceDir();
329
360
 
@@ -331,9 +362,8 @@ export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}
331
362
  return { installed: false, settingsPath, reason: `hook script not found: ${GEMINI_HOOK_SCRIPT}` };
332
363
  }
333
364
 
334
- const geminiDir = join(projectPath, '.gemini');
335
- if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
336
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
365
+ mkdirTracked(recorder, projectPath, join(projectPath, '.gemini'));
366
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
337
367
 
338
368
  let settings = {};
339
369
  if (existsSync(settingsPath)) {
@@ -349,7 +379,7 @@ export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}
349
379
  ]);
350
380
 
351
381
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
352
- return { installed: true, settingsPath, event: 'AfterAgent' };
382
+ return { installed: true, settingsPath, event: 'AfterAgent', artifacts: recorder };
353
383
  }
354
384
 
355
385
  /**
@@ -377,7 +407,7 @@ export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}
377
407
  * @param {string} projectPath
378
408
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
379
409
  */
380
- export function installAgyHooks(projectPath, { overwriteScripts = true } = {}) {
410
+ export function installAgyHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
381
411
  const hooksJsonPath = join(projectPath, '.agents', 'hooks.json');
382
412
  const hookDir = hooksSourceDir();
383
413
 
@@ -397,9 +427,8 @@ export function installAgyHooks(projectPath, { overwriteScripts = true } = {}) {
397
427
  }
398
428
  }
399
429
 
400
- const agentsDir = join(projectPath, '.agents');
401
- if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true });
402
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
430
+ mkdirTracked(recorder, projectPath, join(projectPath, '.agents'));
431
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
403
432
 
404
433
  const command = AGY_HOOK_COMMAND;
405
434
  const entry = config[AGY_HOOK_NAME];
@@ -416,7 +445,7 @@ export function installAgyHooks(projectPath, { overwriteScripts = true } = {}) {
416
445
  config[AGY_HOOK_NAME] = { ...mine, Stop: stop };
417
446
 
418
447
  writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
419
- return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
448
+ return { installed: true, settingsPath: hooksJsonPath, event: 'Stop', artifacts: recorder };
420
449
  }
421
450
 
422
451
  // ─────────────────────────────────────────────────────────────────────────────
@@ -574,9 +603,11 @@ const INSTALLERS = {
574
603
  * @param {string} projectPath
575
604
  * @param {string[]} tools
576
605
  * @param {{ plan?: boolean, overwriteScripts?: boolean }} [opts]
577
- * @returns {{ results: Array<{tool:string, outcome:'installed'|'unchanged'|'would-install'|'blocked'|'failed', path:string, why?:string, repaired?:boolean}>, scripts: {added:number, identical:number, kept:string[]} }}
606
+ * @returns {{ results: Array<{tool:string, outcome:'installed'|'unchanged'|'would-install'|'blocked'|'failed', path:string, why?:string, repaired?:boolean}>, scripts: {added:number, identical:number, kept:string[]}, artifacts: object }}
607
+ * `artifacts` is the recorder of what this call wrote (see core/install-records.js);
608
+ * the caller persists it into the manifest so `uds uninstall` can remove exactly that.
578
609
  */
579
- export function installMissingHooks(projectPath, tools, { plan = false, overwriteScripts = false } = {}) {
610
+ export function installMissingHooks(projectPath, tools, { plan = false, overwriteScripts = false, recorder = newRecorder() } = {}) {
580
611
  // Measured BEFORE anything is copied: afterwards every script would read "identical".
581
612
  const scripts = scriptDiff(projectPath);
582
613
  const results = [];
@@ -585,9 +616,9 @@ export function installMissingHooks(projectPath, tools, { plan = false, overwrit
585
616
  if (st.status === 'present') { results.push({ tool, outcome: 'unchanged', path: st.path }); continue; }
586
617
  if (st.status === 'blocked') { results.push({ tool, outcome: 'blocked', path: st.path, why: st.why }); continue; }
587
618
  if (plan) { results.push({ tool, outcome: 'would-install', path: st.path, repaired: st.status === 'stale' }); continue; }
588
- const r = INSTALLERS[tool](projectPath, { overwriteScripts });
619
+ const r = INSTALLERS[tool](projectPath, { overwriteScripts, recorder });
589
620
  if (r.installed) results.push({ tool, outcome: 'installed', path: r.settingsPath ?? st.path, repaired: st.status === 'stale' });
590
621
  else results.push({ tool, outcome: 'failed', path: r.settingsPath ?? st.path, why: r.reason ?? 'no standard produced a usable hook' });
591
622
  }
592
- return { results, scripts };
623
+ return { results, scripts, artifacts: recorder };
593
624
  }