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.
- package/bin/uds.js +37 -0
- package/bundled/ai/standards/open-work-tracking.ai.yaml +3 -1
- package/bundled/core/open-work-tracking.md +1 -1
- 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 +21 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
- package/bundled/locales/zh-TW/CHANGELOG.md +21 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/open-work-tracking.md +2 -2
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
- package/package.json +1 -1
- package/src/commands/init.js +2 -0
- package/src/commands/open-work.js +60 -0
- package/src/commands/update.js +91 -0
- package/src/i18n/messages.js +3 -0
- package/src/installers/hooks-installer.js +189 -11
- package/src/utils/detector.js +46 -1
- package/src/utils/open-work-tracking.mjs +693 -0
- package/standards-registry.json +7 -7
|
@@ -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
|
+
}
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
package/src/i18n/messages.js
CHANGED
|
@@ -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
|
-
/**
|
|
262
|
-
|
|
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
|
+
}
|
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
|