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.
- package/bin/uds.js +37 -0
- package/bundled/ai/standards/open-work-tracking.ai.yaml +71 -4
- package/bundled/ai/standards/turn-completion-integrity.ai.yaml +14 -7
- package/bundled/core/open-work-tracking.md +111 -8
- package/bundled/core/turn-completion-integrity.md +58 -11
- package/bundled/hooks/check-turn-completion-agy.mjs +147 -0
- package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
- package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
- package/bundled/locales/zh-CN/CHANGELOG.md +33 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -0
- package/bundled/locales/zh-CN/core/turn-completion-integrity.md +46 -13
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +6 -1
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +33 -8
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +17 -7
- package/bundled/locales/zh-TW/CHANGELOG.md +33 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -0
- package/bundled/locales/zh-TW/core/open-work-tracking.md +88 -9
- package/bundled/locales/zh-TW/core/turn-completion-integrity.md +46 -13
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +6 -1
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +33 -8
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +17 -7
- package/package.json +1 -1
- package/src/commands/init.js +17 -2
- package/src/commands/open-work.js +60 -0
- package/src/commands/uninstall.js +1 -1
- package/src/commands/update.js +91 -0
- package/src/i18n/messages.js +3 -0
- package/src/installers/hooks-installer.js +276 -11
- package/src/uninstallers/hook-uninstaller.js +107 -4
- package/src/utils/detector.js +46 -1
- package/src/utils/open-work-tracking.mjs +693 -0
- 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
|
|
224
|
-
* CLI so far (2026-09-25). Unlike
|
|
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
|
-
/**
|
|
242
|
-
|
|
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
|
|
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
|
|
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);
|
package/src/utils/detector.js
CHANGED
|
@@ -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
|
-
|
|
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
|