universal-dev-standards 6.13.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.
Files changed (34) hide show
  1. package/bin/uds.js +37 -0
  2. package/bundled/ai/standards/open-work-tracking.ai.yaml +71 -4
  3. package/bundled/ai/standards/turn-completion-integrity.ai.yaml +14 -7
  4. package/bundled/core/open-work-tracking.md +111 -8
  5. package/bundled/core/turn-completion-integrity.md +58 -11
  6. package/bundled/hooks/check-turn-completion-agy.mjs +147 -0
  7. package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
  8. package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
  9. package/bundled/locales/zh-CN/CHANGELOG.md +33 -3
  10. package/bundled/locales/zh-CN/README.md +2 -2
  11. package/bundled/locales/zh-CN/SECURITY.md +1 -0
  12. package/bundled/locales/zh-CN/core/turn-completion-integrity.md +46 -13
  13. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +6 -1
  14. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +33 -8
  15. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +17 -7
  16. package/bundled/locales/zh-TW/CHANGELOG.md +33 -3
  17. package/bundled/locales/zh-TW/README.md +2 -2
  18. package/bundled/locales/zh-TW/SECURITY.md +1 -0
  19. package/bundled/locales/zh-TW/core/open-work-tracking.md +88 -9
  20. package/bundled/locales/zh-TW/core/turn-completion-integrity.md +46 -13
  21. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +6 -1
  22. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +33 -8
  23. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +17 -7
  24. package/package.json +1 -1
  25. package/src/commands/init.js +17 -2
  26. package/src/commands/open-work.js +60 -0
  27. package/src/commands/uninstall.js +1 -1
  28. package/src/commands/update.js +91 -0
  29. package/src/i18n/messages.js +3 -0
  30. package/src/installers/hooks-installer.js +276 -11
  31. package/src/uninstallers/hook-uninstaller.js +107 -4
  32. package/src/utils/detector.js +46 -1
  33. package/src/utils/open-work-tracking.mjs +693 -0
  34. package/standards-registry.json +8 -8
@@ -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)) {
@@ -220,8 +221,9 @@ export function installHooks(projectPath) {
220
221
  }
221
222
 
222
223
  /**
223
- * turn-completion-integrity is the only standard extended to Codex and Gemini
224
- * CLI so far (2026-09-25). Unlike installHooks() above, these two functions do
224
+ * turn-completion-integrity is the only standard extended to Codex, Gemini
225
+ * CLI and Antigravity CLI so far (2026-09-25; agy 2026-09-29). Unlike
226
+ * installHooks() above, these three functions do
225
227
  * NOT walk every standard's `enforcement:` block — the other three shipped
226
228
  * standards declare Claude-Code-specific events (PreToolUse/PostToolUse with
227
229
  * a Bash/Write matcher) that Codex and Gemini CLI's hook models don't obviously
@@ -237,11 +239,34 @@ export function installHooks(projectPath) {
237
239
  // an adopter's own hook could just as easily live under.
238
240
  export const CODEX_HOOK_SCRIPT = 'check-turn-completion-codex.mjs';
239
241
  export const GEMINI_HOOK_SCRIPT = 'check-turn-completion-gemini.mjs';
242
+ export const AGY_HOOK_SCRIPT = 'check-turn-completion-agy.mjs';
243
+ // agy's hooks.json is keyed by hook NAME, and this is the one UDS owns. The
244
+ // uninstaller removes only handlers under this key that also run a script UDS
245
+ // ships, so a user's own hook (under any name, or even under this name with a
246
+ // different command) is never touched.
247
+ export const AGY_HOOK_NAME = 'uds-turn-completion-integrity';
248
+ // 🔴 agy runs a hook with the working directory set to `.agents/` (measured
249
+ // 2026-09-29 on agy 1.2.12, PC15: the project-root-relative command
250
+ // `node scripts/hooks/...` failed with "Cannot find module
251
+ // '<project>/.agents/scripts/hooks/...'", and agy lets a failed hook through
252
+ // silently). The command therefore climbs out of `.agents/` first. It is
253
+ // deliberately relative, not absolute: hooks.json is meant to be committed and
254
+ // shared, and an absolute path is one machine's. It is also deliberately not
255
+ // `sh -c` / `$(git rev-parse ...)`: whether agy runs `command` through a shell
256
+ // has no evidence behind it.
257
+ export const AGY_HOOK_COMMAND = `node ../scripts/hooks/${AGY_HOOK_SCRIPT}`;
258
+ // The first form this installer wrote (never released); recognised only so a
259
+ // re-install repairs it and uninstall still removes it.
260
+ export const AGY_HOOK_COMMAND_LEGACY = `node scripts/hooks/${AGY_HOOK_SCRIPT}`;
240
261
 
241
- /** Copy the shared hook scripts into the project, same as installHooks() does. */
242
- 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 } = {}) {
243
268
  if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
244
- cpSync(hookDir, hooksDir, { recursive: true });
269
+ cpSync(hookDir, hooksDir, { recursive: true, force: overwrite });
245
270
  }
246
271
 
247
272
  /**
@@ -258,7 +283,7 @@ function copyHookScripts(hookDir, hooksDir) {
258
283
  * @param {string} projectPath
259
284
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
260
285
  */
261
- export function installCodexHooks(projectPath) {
286
+ export function installCodexHooks(projectPath, { overwriteScripts = true } = {}) {
262
287
  const hooksJsonPath = join(projectPath, '.codex', 'hooks.json');
263
288
  const hookDir = hooksSourceDir();
264
289
 
@@ -268,7 +293,7 @@ export function installCodexHooks(projectPath) {
268
293
 
269
294
  const codexDir = join(projectPath, '.codex');
270
295
  if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
271
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
296
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
272
297
 
273
298
  let config = {};
274
299
  if (existsSync(hooksJsonPath)) {
@@ -298,7 +323,7 @@ export function installCodexHooks(projectPath) {
298
323
  * @param {string} projectPath
299
324
  * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
300
325
  */
301
- export function installGeminiHooks(projectPath) {
326
+ export function installGeminiHooks(projectPath, { overwriteScripts = true } = {}) {
302
327
  const settingsPath = join(projectPath, '.gemini', 'settings.json');
303
328
  const hookDir = hooksSourceDir();
304
329
 
@@ -308,7 +333,7 @@ export function installGeminiHooks(projectPath) {
308
333
 
309
334
  const geminiDir = join(projectPath, '.gemini');
310
335
  if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
311
- copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
336
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
312
337
 
313
338
  let settings = {};
314
339
  if (existsSync(settingsPath)) {
@@ -326,3 +351,243 @@ export function installGeminiHooks(projectPath) {
326
351
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
327
352
  return { installed: true, settingsPath, event: 'AfterAgent' };
328
353
  }
354
+
355
+ /**
356
+ * Install the turn-completion-integrity Stop hook for Antigravity CLI (agy).
357
+ *
358
+ * Config lives at <project>/.agents/hooks.json. Its shape differs from every
359
+ * other adapter's file: a top-level map of hook NAME -> { <Event>: handler[] },
360
+ * with the handler written flat (`{type, command, timeout}`), no `hooks`
361
+ * wrapper key and no `hooks[]` nesting (https://antigravity.google/docs/hooks/,
362
+ * fetched 2026-09-29; the shape was also confirmed by a real agy 1.2.12 run).
363
+ * So neither mergeHookArray nor the uninstaller's Claude-shaped stripping can
364
+ * be reused here.
365
+ *
366
+ * - The command is `node ../scripts/hooks/...`, not `node scripts/hooks/...`:
367
+ * agy's hook working directory is `.agents/` (see AGY_HOOK_COMMAND).
368
+ * - `timeout` is seconds (agy's default is 30), like Codex, unlike Gemini CLI.
369
+ * - `enabled` is deliberately NOT written: an `enabled: false` there is a
370
+ * silent off switch, and omitting it is the documented default.
371
+ * - A hooks.json that exists but cannot be parsed is NOT overwritten (the
372
+ * other installers fall back to `{}` and would discard the adopter's file);
373
+ * the install reports why and leaves the file alone.
374
+ * - Whether agy runs a project `.agents/hooks.json` only for a registered
375
+ * Antigravity project (as it does for `.agents/skills/`) is not verified.
376
+ *
377
+ * @param {string} projectPath
378
+ * @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
379
+ */
380
+ export function installAgyHooks(projectPath, { overwriteScripts = true } = {}) {
381
+ const hooksJsonPath = join(projectPath, '.agents', 'hooks.json');
382
+ const hookDir = hooksSourceDir();
383
+
384
+ if (!hookDir || !existsSync(join(hookDir, AGY_HOOK_SCRIPT))) {
385
+ return { installed: false, settingsPath: hooksJsonPath, reason: `hook script not found: ${AGY_HOOK_SCRIPT}` };
386
+ }
387
+
388
+ let config = {};
389
+ if (existsSync(hooksJsonPath)) {
390
+ try {
391
+ config = JSON.parse(readFileSync(hooksJsonPath, 'utf-8'));
392
+ } catch {
393
+ return { installed: false, settingsPath: hooksJsonPath, reason: '.agents/hooks.json exists but is not valid JSON; left untouched' };
394
+ }
395
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
396
+ return { installed: false, settingsPath: hooksJsonPath, reason: '.agents/hooks.json is not a JSON object; left untouched' };
397
+ }
398
+ }
399
+
400
+ const agentsDir = join(projectPath, '.agents');
401
+ if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true });
402
+ copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts });
403
+
404
+ const command = AGY_HOOK_COMMAND;
405
+ const entry = config[AGY_HOOK_NAME];
406
+ const mine = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {};
407
+ // A UDS handler written by an earlier install with the pre-fix command
408
+ // (`node scripts/hooks/...`, which does not resolve from agy's cwd) is
409
+ // replaced, not left beside the new one. Only that exact stale command is
410
+ // touched; anything else the adopter put under this name stays.
411
+ const stop = (Array.isArray(mine.Stop) ? mine.Stop : [])
412
+ .filter((h) => !(h && h.command === AGY_HOOK_COMMAND_LEGACY));
413
+ if (!stop.some((h) => h && h.command === command)) {
414
+ stop.push({ type: 'command', command, timeout: 30 });
415
+ }
416
+ config[AGY_HOOK_NAME] = { ...mine, Stop: stop };
417
+
418
+ writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
419
+ return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
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
+ }
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
2
2
  import { join, basename } from 'path';
3
3
  import {
4
4
  collectHookConfigs, standardsSourceDir, hooksSourceDir,
5
- CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT,
5
+ CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
6
6
  } from '../installers/hooks-installer.js';
7
7
 
8
8
  /**
@@ -17,7 +17,7 @@ import {
17
17
  * test placing a user hook at `scripts/hooks/my-own-hook.mjs` lost it).
18
18
  * The safe signature is path *and* a script basename UDS is actually known to
19
19
  * ship right now — the same set collectHookConfigs() derives for install,
20
- * plus the two fixed Codex/Gemini script names.
20
+ * plus the three fixed Codex/Gemini/agy script names.
21
21
  *
22
22
  * 🔴 Until this file added the three functions below, uninstall only ever
23
23
  * touched .husky/pre-commit and .git/hooks/pre-commit — the settings.json /
@@ -37,7 +37,7 @@ function commandOf(entry) {
37
37
  /** Script basenames UDS currently ships and would install a hook entry for. */
38
38
  function knownUdsHookScripts() {
39
39
  const { scripts } = collectHookConfigs(standardsSourceDir(), hooksSourceDir());
40
- return new Set([...scripts.map((s) => basename(s)), CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT]);
40
+ return new Set([...scripts.map((s) => basename(s)), CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT]);
41
41
  }
42
42
 
43
43
  function isUdsHookEntry(entry, knownScripts) {
@@ -159,10 +159,112 @@ export function uninstallGeminiHooks(projectPath, options = {}) {
159
159
  });
160
160
  }
161
161
 
162
+ /**
163
+ * A bare `{ type, command }` handler (agy's shape — no `hooks[]` wrapper) that
164
+ * runs one of the scripts UDS ships.
165
+ */
166
+ function isUdsAgyHandler(handler, knownScripts) {
167
+ const cmd = handler && typeof handler.command === 'string' ? handler.command : undefined;
168
+ // agy's hook cwd is `.agents/`, so the installed command climbs out first
169
+ // (`node ../scripts/hooks/...`); the earlier `node scripts/hooks/...` form
170
+ // is still recognised so an install made before that fix can be removed.
171
+ return typeof cmd === 'string' && /^node (?:\.\.\/)?scripts\/hooks\//.test(cmd) && knownScripts.has(basename(cmd));
172
+ }
173
+
174
+ /**
175
+ * Remove UDS's Stop handler from .agents/hooks.json (installAgyHooks()'s output).
176
+ *
177
+ * agy's file is `{ "<hook name>": { "<Event>": handler[] } }`, not the
178
+ * `{ hooks: { <Event>: entry[] } }` the other three share, so the generic
179
+ * stripper cannot be used. Only handlers that run a UDS-shipped script are
180
+ * removed, and from ANY hook name (an adopter may have renamed our key); a
181
+ * user's own handler, even inside the same name or the same event array, stays.
182
+ * A name left with no event after removal is dropped, and a file left empty is
183
+ * deleted.
184
+ */
185
+ export function uninstallAgyHooks(projectPath, options = {}) {
186
+ const configPath = join(projectPath, '.agents', 'hooks.json');
187
+ const label = '.agents/hooks.json';
188
+ const dryRun = options.dryRun || false;
189
+ const result = { removed: [], skipped: [], errors: [] };
190
+ if (!existsSync(configPath)) return result;
191
+
192
+ let config;
193
+ try {
194
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
195
+ } catch (error) {
196
+ result.errors.push(`${label} — could not parse (${error.message}); left untouched`);
197
+ return result;
198
+ }
199
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
200
+ result.skipped.push(`${label} (no UDS hook entries found)`);
201
+ return result;
202
+ }
203
+
204
+ const knownScripts = knownUdsHookScripts();
205
+ let removedCount = 0;
206
+ const next = {};
207
+ for (const [name, def] of Object.entries(config)) {
208
+ if (!def || typeof def !== 'object' || Array.isArray(def)) {
209
+ next[name] = def;
210
+ continue;
211
+ }
212
+ const nextDef = {};
213
+ let touched = false;
214
+ let eventsLeft = 0;
215
+ for (const [key, value] of Object.entries(def)) {
216
+ if (!Array.isArray(value)) {
217
+ nextDef[key] = value; // e.g. `enabled`
218
+ continue;
219
+ }
220
+ const kept = value.filter((h) => {
221
+ if (isUdsAgyHandler(h, knownScripts)) {
222
+ removedCount += 1;
223
+ touched = true;
224
+ return false;
225
+ }
226
+ return true;
227
+ });
228
+ if (kept.length > 0) {
229
+ nextDef[key] = kept;
230
+ eventsLeft += 1;
231
+ }
232
+ }
233
+ // A name we emptied is dropped (nothing but `enabled` would remain);
234
+ // a name we did not touch is kept exactly as found.
235
+ if (touched && eventsLeft === 0) continue;
236
+ next[name] = touched ? nextDef : def;
237
+ }
238
+
239
+ if (removedCount === 0) {
240
+ result.skipped.push(`${label} (no UDS hook entries found)`);
241
+ return result;
242
+ }
243
+
244
+ const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
245
+ if (dryRun) {
246
+ result.removed.push(entryLabel);
247
+ return result;
248
+ }
249
+ try {
250
+ if (Object.keys(next).length === 0) {
251
+ unlinkSync(configPath);
252
+ result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
253
+ } else {
254
+ writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
255
+ result.removed.push(entryLabel);
256
+ }
257
+ } catch (error) {
258
+ result.errors.push(`${label} — ${error.message}`);
259
+ }
260
+ return result;
261
+ }
262
+
162
263
  /**
163
264
  * Remove UDS-related lines from .husky/pre-commit, the native
164
265
  * .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
165
- * into .claude/settings.json, .codex/hooks.json and .gemini/settings.json.
266
+ * into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
267
+ * .agents/hooks.json.
166
268
  * @param {string} projectPath - Project root path
167
269
  * @param {Object} options - { dryRun: boolean }
168
270
  * @returns {Object} { removed: string[], skipped: string[], errors: string[] }
@@ -231,6 +333,7 @@ export function uninstallHook(projectPath, options = {}) {
231
333
  uninstallClaudeCodeHooks(projectPath, { dryRun }),
232
334
  uninstallCodexHooks(projectPath, { dryRun }),
233
335
  uninstallGeminiHooks(projectPath, { dryRun }),
336
+ uninstallAgyHooks(projectPath, { dryRun }),
234
337
  ]) {
235
338
  result.removed.push(...sub.removed);
236
339
  result.skipped.push(...sub.skipped);
@@ -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