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

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `uds open-work` — the open-work-tracking reference checks (OWT-017 / 018 / 019)
3
+ * as a command, so an adopter can run them from the npm package.
4
+ *
5
+ * This file holds no rules. The one body of them is
6
+ * `../utils/open-work-tracking.mjs`; this only turns commander's parsed
7
+ * arguments back into the argv that module's `main()` reads, so the command and
8
+ * `node scripts/check-open-work-tracking.mjs` cannot disagree.
9
+ *
10
+ * Exit codes are the module's and are not translated: 0 no violation, 1
11
+ * violation, 2 cannot decide (no structure found, git failed, or the checker's
12
+ * own self-test arms failed). 2 is NOT a pass. `process.exitCode` is set rather
13
+ * than calling `process.exit()`, so commander's post-action hook still runs.
14
+ *
15
+ * It is a reference decision procedure offered as evidence (OWT-015), not a
16
+ * gate, and its coverage is unknown and uncalibrated (OWT-011, OWT-016); the
17
+ * module prints both statements on every run.
18
+ *
19
+ * @module commands/open-work
20
+ */
21
+
22
+ import { main } from '../utils/open-work-tracking.mjs';
23
+
24
+ /** Append `--name value` when the option was given. */
25
+ function withOption(argv, name, value) {
26
+ if (value !== undefined && value !== null) argv.push(name, String(value));
27
+ return argv;
28
+ }
29
+
30
+ function run(argv) {
31
+ process.exitCode = main(argv);
32
+ }
33
+
34
+ /** uds open-work next-action [files...] [--root DIR] [--id-pattern RE] — OWT-019 */
35
+ export function openWorkNextActionCommand(files = [], options = {}) {
36
+ const argv = ['next-action'];
37
+ withOption(argv, '--root', options.root);
38
+ withOption(argv, '--id-pattern', options.idPattern);
39
+ run([...argv, ...files]);
40
+ }
41
+
42
+ /** uds open-work revision (--file PATH --base REV | --before FILE --after FILE) — OWT-018 */
43
+ export function openWorkRevisionCommand(options = {}) {
44
+ const argv = ['revision'];
45
+ withOption(argv, '--file', options.file);
46
+ withOption(argv, '--base', options.base);
47
+ withOption(argv, '--before', options.before);
48
+ withOption(argv, '--after', options.after);
49
+ run(argv);
50
+ }
51
+
52
+ /** uds open-work separation [files...] — OWT-017 */
53
+ export function openWorkSeparationCommand(files = []) {
54
+ run(['separation', ...files]);
55
+ }
56
+
57
+ /** uds open-work self-test — run the checker's own arms and nothing else */
58
+ export function openWorkSelfTestCommand() {
59
+ run(['--self-test']);
60
+ }
@@ -46,6 +46,7 @@ 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';
49
50
  import {
50
51
  promptSkillsInstallLocation,
51
52
  promptCommandsInstallation
@@ -451,6 +452,15 @@ export async function updateCommand(options) {
451
452
  return;
452
453
  }
453
454
 
455
+ // Handle --with-hooks (added 2026-09-29): install the enforcement hooks that are
456
+ // MISSING from an already-initialized project — the door `uds init --with-hooks`
457
+ // cannot be, because `uds init` refuses to run twice. Standalone like
458
+ // --claude-target and --sync-refs; and like them it honours --plan by writing nothing.
459
+ if (options.withHooks) {
460
+ await updateHooksOnly(projectPath, manifest, options);
461
+ return;
462
+ }
463
+
454
464
  // Handle --sync-refs option.
455
465
  // `--plan` is honoured here too. This branch is above the mode dispatch
456
466
  // because sync-refs is its own operation rather than a scope of the
@@ -1827,6 +1837,87 @@ async function switchClaudeTarget(projectPath, manifest, target, options) { // e
1827
1837
  * @param {Object} manifest - Manifest object (will be mutated with updated hashes)
1828
1838
  * @returns {{success: boolean, updated: string[], errors: string[]}}
1829
1839
  */
1840
+ /**
1841
+ * `uds update --with-hooks [--ai-tool <list>] [--plan] [--force]`
1842
+ *
1843
+ * Adds the enforcement hooks that are missing; a hook that is already there is not
1844
+ * touched, and the adopter's own hooks are never touched (see installMissingHooks).
1845
+ * Exit code 1 only when there is no hook-capable tool to act on, or an install failed:
1846
+ * "nothing to do" is 0, "could not tell which tool" is not.
1847
+ *
1848
+ * @param {string} projectPath
1849
+ * @param {object} manifest
1850
+ * @param {{ plan?: boolean, force?: boolean, aiTool?: string, yes?: boolean }} options
1851
+ * @returns {Promise<void>}
1852
+ */
1853
+ export async function updateHooksOnly(projectPath, manifest, options = {}) {
1854
+ const ignored = ['skills', 'commands', 'syncRefs', 'integrationsOnly', 'standardsOnly', 'apply', 'rollback', 'prune']
1855
+ .filter((k) => options[k]).map((k) => `--${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`);
1856
+ if (ignored.length) {
1857
+ console.log(chalk.yellow(` ! --with-hooks does one thing and does not compose; ${ignored.join(', ')} ignored.`));
1858
+ console.log();
1859
+ }
1860
+
1861
+ const capable = HOOK_CAPABLE_TOOLS.join(', ');
1862
+ const resolved = resolveHookTools(projectPath, manifest, { aiTool: options.aiTool });
1863
+
1864
+ if (resolved.unknown.length) {
1865
+ console.log(chalk.red(` ✗ No hook installer for: ${resolved.unknown.join(', ')}`));
1866
+ console.log(chalk.gray(` Tools with hooks: ${capable}`));
1867
+ console.log();
1868
+ process.exitCode = 1;
1869
+ return;
1870
+ }
1871
+ if (resolved.tools.length === 0) {
1872
+ // Both non-interactive and interactive get the same message: there is no prompt to
1873
+ // fall back to here, so the way to say which tool is spelled out instead.
1874
+ console.log(chalk.yellow(' ⚠ Could not tell which AI tool to install hooks for.'));
1875
+ console.log(chalk.gray(' Neither this project\'s manifest nor its files name one of: ' + capable + '.'));
1876
+ console.log(chalk.gray(' Say which one: uds update --with-hooks --ai-tool <tool>[,<tool>...]'));
1877
+ console.log(chalk.gray(' e.g. uds update --with-hooks --ai-tool antigravity'));
1878
+ console.log(chalk.gray(' What is detected: claude-code (.claude/ or CLAUDE.md) · codex (root AGENTS.md) · gemini-cli (GEMINI.md)'));
1879
+ 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)'));
1880
+ console.log();
1881
+ process.exitCode = 1;
1882
+ return;
1883
+ }
1884
+
1885
+ console.log(chalk.bold(options.plan ? 'Hooks — plan (nothing is written)' : 'Hooks'));
1886
+ for (const tool of resolved.tools) {
1887
+ console.log(chalk.gray(` ${tool}: ${resolved.sources[tool].join(', ')}`));
1888
+ }
1889
+
1890
+ const { results, scripts } = installMissingHooks(projectPath, resolved.tools, {
1891
+ plan: !!options.plan,
1892
+ overwriteScripts: !!options.force,
1893
+ });
1894
+
1895
+ let failed = false;
1896
+ for (const r of results) {
1897
+ const rel = r.path ? relative(projectPath, r.path) || r.path : '';
1898
+ if (r.outcome === 'installed') console.log(chalk.green(` ✓ ${r.tool}: installed${r.repaired ? ' (replaced an out-of-date UDS entry)' : ''} — ${rel}`));
1899
+ else if (r.outcome === 'would-install') console.log(chalk.cyan(` + ${r.tool}: would install${r.repaired ? ' (replacing an out-of-date UDS entry)' : ''} — ${rel}`));
1900
+ else if (r.outcome === 'unchanged') console.log(chalk.gray(` · ${r.tool}: already installed, not touched — ${rel}`));
1901
+ else { failed = true; console.log(chalk.yellow(` ⚠ ${r.tool}: not installed — ${r.why}`)); }
1902
+ }
1903
+ const installedAny = results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install');
1904
+ if (installedAny && scripts.kept.length) {
1905
+ 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 ? ', ...' : ''}`));
1906
+ console.log(chalk.gray(' Add --force to overwrite them with the shipped versions.'));
1907
+ }
1908
+ if (results.some((r) => r.tool === 'antigravity' && r.outcome === 'installed')) {
1909
+ 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.'));
1910
+ }
1911
+ if (results.some((r) => r.tool === 'codex' && r.outcome === 'installed')) {
1912
+ 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.'));
1913
+ }
1914
+ if (!results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install')) {
1915
+ console.log(chalk.gray(' Nothing to add.'));
1916
+ }
1917
+ console.log();
1918
+ if (failed) process.exitCode = 1;
1919
+ }
1920
+
1830
1921
  export function regenerateIntegrations(projectPath, manifest) {
1831
1922
  const aiTools = manifest.aiTools || [];
1832
1923
 
@@ -930,6 +930,7 @@ export const messages = {
930
930
  title: 'Universal Development Standards - Initialize',
931
931
  alreadyInitialized: '⚠ Standards already initialized in this project.',
932
932
  useUpdateOrDelete: 'Use `uds update` to update, or delete .standards/ to reinitialize.',
933
+ useUpdateWithHooks: 'To add enforcement hooks to this project (including for tools UDS began supporting after you initialized), run `uds update --with-hooks`.',
933
934
  // Detection
934
935
  detectingProject: 'Detecting project characteristics...',
935
936
  analysisComplete: 'Project analysis complete',
@@ -2188,6 +2189,7 @@ export const messages = {
2188
2189
  title: '通用開發標準 - 初始化',
2189
2190
  alreadyInitialized: '⚠ 此專案已初始化標準。',
2190
2191
  useUpdateOrDelete: '使用 `uds update` 更新,或刪除 .standards/ 重新初始化。',
2192
+ useUpdateWithHooks: '要為這個專案補裝執行關卡(含 UDS 在你初始化之後才支援的工具),請執行 `uds update --with-hooks`。',
2191
2193
  // Detection
2192
2194
  detectingProject: '正在偵測專案特性...',
2193
2195
  analysisComplete: '專案分析完成',
@@ -3158,6 +3160,7 @@ export const messages = {
3158
3160
  title: '通用开发标准 - 初始化',
3159
3161
  alreadyInitialized: '⚠ 此项目已初始化标准。',
3160
3162
  useUpdateOrDelete: '使用 `uds update` 更新,或删除 .standards/ 重新初始化。',
3163
+ useUpdateWithHooks: '要为这个项目补装执行关卡(含 UDS 在你初始化之后才支持的工具),请执行 `uds update --with-hooks`。',
3161
3164
  // Detection
3162
3165
  detectingProject: '正在检测项目特性...',
3163
3166
  analysisComplete: '项目分析完成',
@@ -36,6 +36,7 @@ import { join, dirname, basename } 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
+ import { detectAITools } from '../utils/detector.js';
39
40
 
40
41
  const __dirname = dirname(fileURLToPath(import.meta.url));
41
42
 
@@ -171,7 +172,7 @@ function probeLanguageLimits(hooksDir, scripts) {
171
172
  return out;
172
173
  }
173
174
 
174
- export function installHooks(projectPath) {
175
+ export function installHooks(projectPath, { overwriteScripts = true } = {}) {
175
176
  const claudeDir = join(projectPath, '.claude');
176
177
  const settingsPath = join(claudeDir, 'settings.json');
177
178
  const hooksDir = join(projectPath, 'scripts', 'hooks');
@@ -190,7 +191,7 @@ export function installHooks(projectPath) {
190
191
  if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
191
192
 
192
193
  // Recursive: a hook may ship a directory beside it (locale packs, fixtures).
193
- cpSync(hookDir, hooksDir, { recursive: true });
194
+ cpSync(hookDir, hooksDir, { recursive: true, force: overwriteScripts });
194
195
 
195
196
  let settings = {};
196
197
  if (existsSync(settingsPath)) {
@@ -258,10 +259,14 @@ export const AGY_HOOK_COMMAND = `node ../scripts/hooks/${AGY_HOOK_SCRIPT}`;
258
259
  // re-install repairs it and uninstall still removes it.
259
260
  export const AGY_HOOK_COMMAND_LEGACY = `node scripts/hooks/${AGY_HOOK_SCRIPT}`;
260
261
 
261
- /** Copy the shared hook scripts into the project, same as installHooks() does. */
262
- function copyHookScripts(hookDir, hooksDir) {
262
+ /**
263
+ * Copy the shared hook scripts into the project, same as installHooks() does.
264
+ * `overwrite: false` keeps a script that already exists (an adopter may have
265
+ * edited it); it is what `uds update --with-hooks` uses unless `--force` is given.
266
+ */
267
+ function copyHookScripts(hookDir, hooksDir, { overwrite = true } = {}) {
263
268
  if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
264
- cpSync(hookDir, hooksDir, { recursive: true });
269
+ cpSync(hookDir, hooksDir, { recursive: true, force: overwrite });
265
270
  }
266
271
 
267
272
  /**
@@ -278,7 +283,7 @@ function copyHookScripts(hookDir, hooksDir) {
278
283
  * @param {string} projectPath
279
284
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
280
285
  */
281
- export function installCodexHooks(projectPath) {
286
+ export function installCodexHooks(projectPath, { overwriteScripts = true } = {}) {
282
287
  const hooksJsonPath = join(projectPath, '.codex', 'hooks.json');
283
288
  const hookDir = hooksSourceDir();
284
289
 
@@ -288,7 +293,7 @@ export function installCodexHooks(projectPath) {
288
293
 
289
294
  const codexDir = join(projectPath, '.codex');
290
295
  if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
291
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
296
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
292
297
 
293
298
  let config = {};
294
299
  if (existsSync(hooksJsonPath)) {
@@ -318,7 +323,7 @@ export function installCodexHooks(projectPath) {
318
323
  * @param {string} projectPath
319
324
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
320
325
  */
321
- export function installGeminiHooks(projectPath) {
326
+ export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}) {
322
327
  const settingsPath = join(projectPath, '.gemini', 'settings.json');
323
328
  const hookDir = hooksSourceDir();
324
329
 
@@ -328,7 +333,7 @@ export function installGeminiHooks(projectPath) {
328
333
 
329
334
  const geminiDir = join(projectPath, '.gemini');
330
335
  if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
331
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
336
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
332
337
 
333
338
  let settings = {};
334
339
  if (existsSync(settingsPath)) {
@@ -372,7 +377,7 @@ export function installGeminiHooks(projectPath) {
372
377
  * @param {string} projectPath
373
378
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
374
379
  */
375
- export function installAgyHooks(projectPath) {
380
+ export function installAgyHooks(projectPath, { overwriteScripts = true } = {}) {
376
381
  const hooksJsonPath = join(projectPath, '.agents', 'hooks.json');
377
382
  const hookDir = hooksSourceDir();
378
383
 
@@ -394,7 +399,7 @@ export function installAgyHooks(projectPath) {
394
399
 
395
400
  const agentsDir = join(projectPath, '.agents');
396
401
  if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true });
397
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
402
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
398
403
 
399
404
  const command = AGY_HOOK_COMMAND;
400
405
  const entry = config[AGY_HOOK_NAME];
@@ -413,3 +418,176 @@ export function installAgyHooks(projectPath) {
413
418
  writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
414
419
  return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
415
420
  }
421
+
422
+ // ─────────────────────────────────────────────────────────────────────────────
423
+ // `uds update --with-hooks` — hooks for a project that is already initialized.
424
+ //
425
+ // 🔴 Found 2026-09-29 installing the 6.14.0-beta.1 package into a fresh project:
426
+ // hooks were only ever wired by `uds init --with-hooks`, and `uds init` refuses to
427
+ // run twice ("Standards already initialized"). `uds update` had no hook option.
428
+ // So an existing adopter could NEVER receive the hook for a tool UDS started
429
+ // supporting after they initialized — and could not repair a hook file that was
430
+ // never written. This is that door.
431
+ // ─────────────────────────────────────────────────────────────────────────────
432
+
433
+ /** The tools that have a hook installer, in the order they are reported. */
434
+ export const HOOK_CAPABLE_TOOLS = ['claude-code', 'codex', 'gemini-cli', 'antigravity'];
435
+
436
+ /** detectAITools() keys are camelCase; manifests and flags use the kebab-case tool names. */
437
+ const DETECTED_KEY_TO_TOOL = { claudeCode: 'claude-code', geminiCli: 'gemini-cli' };
438
+
439
+ /**
440
+ * Which tools to install hooks for.
441
+ *
442
+ * --ai-tool given → exactly those (validated); nothing is detected or guessed.
443
+ * otherwise → the manifest's tools ∪ what the project directory shows now.
444
+ *
445
+ * The union is the point: a project initialized before agy detection existed (or
446
+ * before the adopter started using agy) has no `antigravity` in its manifest, and
447
+ * detection alone finds it; a project whose marker files were deleted still has
448
+ * the tool in its manifest. Either source is enough, neither is required.
449
+ *
450
+ * @param {string} projectPath
451
+ * @param {object|null} manifest
452
+ * @param {{ aiTool?: string }} [options]
453
+ * @returns {{ tools: string[], explicit: boolean, unknown: string[], sources: Record<string,string[]> }}
454
+ */
455
+ export function resolveHookTools(projectPath, manifest, options = {}) {
456
+ if (options.aiTool) {
457
+ const asked = String(options.aiTool).split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
458
+ const unknown = asked.filter((t) => !HOOK_CAPABLE_TOOLS.includes(t));
459
+ const tools = HOOK_CAPABLE_TOOLS.filter((t) => asked.includes(t));
460
+ return { tools, explicit: true, unknown, sources: Object.fromEntries(tools.map((t) => [t, ['--ai-tool']])) };
461
+ }
462
+ const sources = {};
463
+ const add = (tool, why) => {
464
+ if (!HOOK_CAPABLE_TOOLS.includes(tool)) return;
465
+ (sources[tool] ||= []).push(why);
466
+ };
467
+ for (const t of [...(manifest?.integrations ?? []), ...(manifest?.aiTools ?? [])]) add(t, 'manifest');
468
+ const detected = detectAITools(projectPath);
469
+ for (const [k, on] of Object.entries(detected)) if (on) add(DETECTED_KEY_TO_TOOL[k] ?? k, 'detected in the project');
470
+ for (const t of Object.keys(sources)) sources[t] = [...new Set(sources[t])];
471
+ return { tools: HOOK_CAPABLE_TOOLS.filter((t) => sources[t]), explicit: false, unknown: [], sources };
472
+ }
473
+
474
+ function readJson(path) {
475
+ if (!existsSync(path)) return { state: 'absent' };
476
+ try {
477
+ const value = JSON.parse(readFileSync(path, 'utf-8'));
478
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { state: 'invalid', why: 'is not a JSON object' };
479
+ return { state: 'ok', value };
480
+ } catch {
481
+ return { state: 'invalid', why: 'is not valid JSON' };
482
+ }
483
+ }
484
+
485
+ const hasCommand = (entries, cmd) =>
486
+ Array.isArray(entries) && entries.some((e) => commandOf(e) === cmd);
487
+
488
+ /**
489
+ * Is the hook for `tool` already in the project's config? Read-only.
490
+ *
491
+ * @returns {{ status: 'present'|'missing'|'stale'|'blocked', path: string, why?: string }}
492
+ * present every entry UDS would write is there — nothing to do
493
+ * missing none (or only part) of it is there
494
+ * stale agy's pre-fix command is there (does not resolve from agy's cwd)
495
+ * blocked the config file exists but cannot be merged safely (invalid JSON)
496
+ */
497
+ export function hookStatus(projectPath, tool) {
498
+ if (tool === 'antigravity') {
499
+ const path = join(projectPath, '.agents', 'hooks.json');
500
+ const j = readJson(path);
501
+ if (j.state === 'absent') return { status: 'missing', path };
502
+ if (j.state === 'invalid') return { status: 'blocked', path, why: `.agents/hooks.json ${j.why}; left untouched` };
503
+ const stop = j.value[AGY_HOOK_NAME]?.Stop;
504
+ const cmds = Array.isArray(stop) ? stop.map((h) => h && h.command) : [];
505
+ if (cmds.includes(AGY_HOOK_COMMAND) && !cmds.includes(AGY_HOOK_COMMAND_LEGACY)) return { status: 'present', path };
506
+ return { status: cmds.includes(AGY_HOOK_COMMAND_LEGACY) ? 'stale' : 'missing', path };
507
+ }
508
+ if (tool === 'codex') {
509
+ const path = join(projectPath, '.codex', 'hooks.json');
510
+ const j = readJson(path);
511
+ if (j.state === 'absent') return { status: 'missing', path };
512
+ if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
513
+ return { status: hasCommand(j.value.hooks?.Stop, `node scripts/hooks/${CODEX_HOOK_SCRIPT}`) ? 'present' : 'missing', path };
514
+ }
515
+ if (tool === 'gemini-cli') {
516
+ const path = join(projectPath, '.gemini', 'settings.json');
517
+ const j = readJson(path);
518
+ if (j.state === 'absent') return { status: 'missing', path };
519
+ if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
520
+ return { status: hasCommand(j.value.hooks?.AfterAgent, `node scripts/hooks/${GEMINI_HOOK_SCRIPT}`) ? 'present' : 'missing', path };
521
+ }
522
+ if (tool === 'claude-code') {
523
+ const path = join(projectPath, '.claude', 'settings.json');
524
+ const { configs } = collectHookConfigs(standardsSourceDir(), hooksSourceDir());
525
+ if (Object.keys(configs).length === 0) return { status: 'blocked', path, why: 'no standard produced a usable hook' };
526
+ const j = readJson(path);
527
+ if (j.state === 'absent') return { status: 'missing', path };
528
+ if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
529
+ for (const [event, entries] of Object.entries(configs)) {
530
+ const existing = j.value.hooks?.[event] ?? [];
531
+ if (mergeHookArray(existing, entries).length !== existing.length) return { status: 'missing', path };
532
+ }
533
+ return { status: 'present', path };
534
+ }
535
+ return { status: 'blocked', path: '', why: `no hook installer for ${tool}` };
536
+ }
537
+
538
+ /** Hook scripts UDS ships vs what the project already has: how many are new, identical, or edited. */
539
+ function scriptDiff(projectPath) {
540
+ const src = hooksSourceDir();
541
+ const out = { added: 0, identical: 0, kept: [] };
542
+ if (!src) return out;
543
+ const walk = (dir, rel = '') => {
544
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
545
+ const r = rel ? `${rel}/${e.name}` : e.name;
546
+ if (e.isDirectory()) { walk(join(dir, e.name), r); continue; }
547
+ const dest = join(projectPath, 'scripts', 'hooks', r);
548
+ if (!existsSync(dest)) out.added++;
549
+ else if (readFileSync(dest).equals(readFileSync(join(dir, e.name)))) out.identical++;
550
+ else out.kept.push(r);
551
+ }
552
+ };
553
+ walk(src);
554
+ return out;
555
+ }
556
+
557
+ const INSTALLERS = {
558
+ 'claude-code': (p, o) => installHooks(p, o),
559
+ codex: (p, o) => installCodexHooks(p, o),
560
+ 'gemini-cli': (p, o) => installGeminiHooks(p, o),
561
+ antigravity: (p, o) => installAgyHooks(p, o),
562
+ };
563
+
564
+ /**
565
+ * Add the hooks that are missing to an already-initialized project.
566
+ *
567
+ * - A hook that is already there is not touched, and nothing is written for it.
568
+ * - The adopter's own hooks are never touched: every installer merges (Claude,
569
+ * Codex, Gemini) or writes only under the key UDS owns (agy).
570
+ * - A hook script the project already has and that differs from this version is
571
+ * KEPT unless `overwriteScripts` — it may have been edited. It is reported.
572
+ * - `plan: true` reads and reports, and writes nothing.
573
+ *
574
+ * @param {string} projectPath
575
+ * @param {string[]} tools
576
+ * @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[]} }}
578
+ */
579
+ export function installMissingHooks(projectPath, tools, { plan = false, overwriteScripts = false } = {}) {
580
+ // Measured BEFORE anything is copied: afterwards every script would read "identical".
581
+ const scripts = scriptDiff(projectPath);
582
+ const results = [];
583
+ for (const tool of tools) {
584
+ const st = hookStatus(projectPath, tool);
585
+ if (st.status === 'present') { results.push({ tool, outcome: 'unchanged', path: st.path }); continue; }
586
+ if (st.status === 'blocked') { results.push({ tool, outcome: 'blocked', path: st.path, why: st.why }); continue; }
587
+ if (plan) { results.push({ tool, outcome: 'would-install', path: st.path, repaired: st.status === 'stale' }); continue; }
588
+ const r = INSTALLERS[tool](projectPath, { overwriteScripts });
589
+ if (r.installed) results.push({ tool, outcome: 'installed', path: r.settingsPath ?? st.path, repaired: st.status === 'stale' });
590
+ else results.push({ tool, outcome: 'failed', path: r.settingsPath ?? st.path, why: r.reason ?? 'no standard produced a usable hook' });
591
+ }
592
+ return { results, scripts };
593
+ }
@@ -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