@7n/rules 1.48.1 → 1.49.0

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/CHANGELOG.md +25 -0
  2. package/bin/n-rules-cli.mjs +2045 -0
  3. package/bin/n-rules.js +4 -2026
  4. package/package.json +1 -1
  5. package/rules/changelog/.changes/260724-1500.md +5 -0
  6. package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
  7. package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
  8. package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
  9. package/rules/doc-files/docgen-gen/docs/index.md +9 -0
  10. package/rules/doc-files/docgen-gen/docs/main.md +40 -29
  11. package/rules/doc-files/docgen-gen/main.mjs +79 -25
  12. package/rules/test/coverage/fix-worker.mjs +9 -1
  13. package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
  14. package/scripts/docs/skills-cli.md +18 -24
  15. package/scripts/lib/acp-runner.mjs +1 -1
  16. package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
  17. package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
  18. package/scripts/lib/lint-surface/docs/index.md +1 -0
  19. package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
  20. package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
  21. package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
  22. package/scripts/lib/lint-surface/run-fix.mjs +209 -38
  23. package/scripts/lib/lint-surface/snapshot.mjs +6 -0
  24. package/scripts/lib/lint-surface/test-gate.mjs +87 -0
  25. package/scripts/skills-cli.mjs +60 -10
  26. package/scripts/utils/docs/glob-compat.md +20 -14
  27. package/scripts/utils/glob-compat.mjs +18 -3
  28. package/skills/git-reconcile/SKILL.md +58 -0
  29. package/skills/git-reconcile/js/docs/index.md +9 -0
  30. package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
  31. package/skills/git-reconcile/js/orchestrate.mjs +776 -0
  32. package/skills/git-reconcile/main.json +1 -0
  33. package/skills/taze/js/docs/orchestrate.md +40 -18
  34. package/skills/taze/js/orchestrate.mjs +6 -3
@@ -0,0 +1,2045 @@
1
+ /**
2
+ * n-rules — CLI завантаження правил та перевірки проєкту
3
+ *
4
+ * Використання:
5
+ * `npx \@7n/rules` — завантажити cursor-правила (синк); якщо в корені вже є `.n-rules.json`,
6
+ * спочатку зчитується конфіг і за потреби дописується `$schema`
7
+ * `npx \@7n/rules rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
8
+ * `npx \@7n/rules hook --post-tool-use` — PostToolUse hook: per-file lint правила для зміненого файлу (stdin JSON `tool_input.file_path`). Прописується автоматично в `.claude/settings.json`.
9
+ * `npx \@7n/rules hook --stop` — Stop hook: per-file lint по всіх змінених файлах (git diff HEAD + untracked).
10
+ * `npx \@7n/rules lint` — data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
11
+ * за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
12
+ * весь репо (`per-file` ∪ `full`); `--no-fix` = без мутацій/LLM (CI); позиційні
13
+ * (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`);
14
+ * `--path <dir>` = перетин піддиректорії з git-дельтою (лише per-file правила; з `--full` —
15
+ * все піддерево), корінь прогону (root-guard, `.n-rules.json`) лишається поточним
16
+ * каталогом/`--cwd`; сумісний із rule-фільтром (`lint js --path run/nexus`);
17
+ * `--repo-wide` = лише full-scope правила (knip/jscpd/dep-policy) по всьому репо.
18
+ * CI = `lint --no-fix --full` (весь репо, нуль мутацій/LLM).
19
+ * `npx \@7n/rules ci plan` — skip-логіка сервіс-орієнтованого CI-канону: перетин дельти з `--path` → job outputs
20
+ * «які lint-домени запускати» (`--github` → $GITHUB_OUTPUT, `--azure` → ##vso).
21
+ * `npx \@7n/rules skill list` — скіли пакета без синку в проєкт
22
+ * `npx \@7n/rules skill taze` — промпт на stdout
23
+ * `npx \@7n/rules skill cursor taze ["task"]` — Cursor CLI (`cursor-agent -p`)
24
+ * `npx \@7n/rules skill claude taze ["task"]` — Claude Code CLI (`claude -p`)
25
+ *
26
+ * Agent інтеграція: під час синку, окрім `.cursor/rules` і `.claude/commands` (з skills), CLI ще раз
27
+ * синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються)
28
+ * і `.cursor/hooks.json` (Cursor Agent hooks; merge — користувацькі hooks зберігаються).
29
+ * Опт-аут — поле `claude-config: false` у `.n-rules.json`.
30
+ * Pi.dev інтеграція: для кожного skill у `.cursor/skills/<dir>/` CLI генерує
31
+ * `.pi/skills/<dir>/SKILL.md` із frontmatter `name`+`description` (формат pi.dev). Тіло — делегат
32
+ * на джерельний `.cursor/skills/<dir>/SKILL.md`. Always-on, симетрично до `.claude/commands/`.
33
+ *
34
+ * Якщо у корені репозиторію немає .n-rules.json, спочатку перейменовується за наявності nitra-cursor.json;
35
+ * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
36
+ * з усіма правилами з каталогу rules/ пакету (їх можна відредагувати після створення). У файлі завжди має бути
37
+ * поле `$schema` з посиланням на JSON Schema пакету (публічний URL для IDE); при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
38
+ * Масиви `rules`, `skills`, `disable-rules` і `disable-skills` при записі сортуються за алфавітом.
39
+ *
40
+ * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
41
+ * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
42
+ * Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
43
+ *
44
+ * Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
45
+ * пакетом), яких немає у списку rules у .n-rules.json. Інші .mdc у цій директорії залишаються.
46
+ *
47
+ * Composite GitHub Action `.github/actions/setup-bun-deps/action.yml` копіюється з каталогу
48
+ * `github-actions/` пакету при кожному успішному синку (workflows з правил ga / js-lint / text).
49
+ *
50
+ * Skills копіюються з npm/skills пакету лише для id з масиву «skills» у .n-rules.json
51
+ * (у JSON — без префікса, як імена каталогів у rules/ без n-). У пакеті джерело — каталоги
52
+ * skills/<id>/ (без префікса); у проєкті — .cursor/skills/n-<id>/ (префікс n-, як n-*.mdc).
53
+ * Якщо ключа skills немає, за замовчуванням підтягуються всі підкаталоги skills/ (лише імена без префікса n-).
54
+ * Зайві каталоги n-* у .cursor/skills, яких немає у списку, видаляються.
55
+ * Файл `auto.md` у скілі — джерело правди для auto-skills у CLI (`scripts/auto-skills.mjs`)
56
+ * і у проєкт не копіюється; раніше синхронізовані `auto.md` у `.cursor/skills/n-<id>/` CLI
57
+ * не чіпає — їх потрібно прибрати вручну.
58
+ *
59
+ * Якщо в корені є package.json і в ньому ще немає \@7n/rules у devDependencies (і не оголошено
60
+ * в dependencies), CLI дописує devDependencies з діапазоном ^<version> поточного пакету — зручно після npx.
61
+ *
62
+ * Перед копіюванням правил (режим без підкоманди): оновлення \@7n/rules у package.json до
63
+ * останньої версії з npm (крім workspace:/file:/link: тощо), `bun i`, далі файли беруться з
64
+ * `node_modules/@7n/rules`, якщо пакет з’явився після встановлення.
65
+ */
66
+
67
+ import { spawnSync } from 'node:child_process'
68
+ import { existsSync } from 'node:fs'
69
+ import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
70
+ import { basename, dirname, join, resolve } from 'node:path'
71
+ import { cwd, env } from 'node:process'
72
+ import { fileURLToPath } from 'node:url'
73
+
74
+ import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
75
+ import { formatGeneratedMarkdownLines, renderAgentsTemplate } from '../scripts/lib/generated-markdown.mjs'
76
+ import { appendDiscoveredMdcFiles, inlineTemplateLinks } from '../scripts/lib/inline-template-links.mjs'
77
+ import {
78
+ detectAutoRules,
79
+ detectLegacyRuleIds,
80
+ mergeConfigWithAutoDetected,
81
+ normalizeIdList,
82
+ RULE_MIGRATIONS
83
+ } from '../scripts/auto-rules.mjs'
84
+ import { detectAutoSkills } from '../scripts/auto-skills.mjs'
85
+ import {
86
+ bringChangesBackToOriginal,
87
+ ensureRunningInWorktree,
88
+ removeAutoCreatedWorktree
89
+ } from '../scripts/lib/auto-worktree.mjs'
90
+ import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
91
+ import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
92
+ import { collectSkillFragments, injectSkillFragments } from '../scripts/lib/skill-fragments.mjs'
93
+ import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
94
+ import { listProjectRulesMdcFiles } from '../scripts/lib/list-project-rules-mdc.mjs'
95
+ import { ensureNRulesInRootDevDependencies } from '../scripts/ensure-n-rules-dev-dependencies.mjs'
96
+ import { resolvePluginList, resolvePlugins, resolveRulesDirs } from '../scripts/lib/resolve-plugins.mjs'
97
+ import { assertCwdIsProjectRoot } from '../scripts/lib/assert-project-root.mjs'
98
+ import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
99
+ import { syncGitignoreWorktree } from '../scripts/lib/sync-gitignore-worktree.mjs'
100
+ import { upgradeNRulesToLatestAndBunInstall } from '../scripts/upgrade-n-rules-and-install.mjs'
101
+ import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
102
+ import { isJsOrchestratedSkillArgs, runSkillsCli } from '../scripts/skills-cli.mjs'
103
+ import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
104
+
105
+ /**
106
+ * Чи потребуватиме `lint <args>` worktree-ізоляції (той самий предикат, що й
107
+ * `needsWorktreeIsolation` у `case 'lint'`, обчислений із сирих CLI-args ДО
108
+ * парсингу `lintOpts`) — щоб self-upgrade devDependency не забруднив дерево
109
+ * ПЕРЕД гейтом чистоти `ensureRunningInWorktree` (той самий клас проблеми,
110
+ * що й `skill taze`, див. коментар над `skipDevDepsEnsure`).
111
+ * @param {string[]} args сирі аргументи після `lint`
112
+ * @returns {boolean} true, якщо `--full` без `--no-fix`/`--path`/`--repo-wide`
113
+ */
114
+ function isLintFullFixArgs(args) {
115
+ return (
116
+ args.includes('--full') && !args.includes('--path') && !args.includes('--repo-wide') && !args.includes('--no-fix')
117
+ )
118
+ }
119
+
120
+ const PACKAGE_NAME = '@7n/rules'
121
+ const CONFIG_FILE = '.n-rules.json'
122
+ /** Публічний URL JSON Schema для поля `$schema` у `.n-rules.json` (IDE); вміст правил CLI читає лише з диска пакету */
123
+ const CONFIG_SCHEMA_URL = 'https://unpkg.com/@7n/rules/schemas/n-rules.json'
124
+ const AGENTS_FILE = 'AGENTS.md'
125
+ const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
126
+ const RULES_DIR = '.cursor/rules'
127
+ const SKILLS_DIR = '.cursor/skills'
128
+ const COMMANDS_DIR = '.claude/commands'
129
+ const PI_SKILLS_DIR = '.pi/skills'
130
+ const RULE_PREFIX = 'n-'
131
+
132
+ const binDir = dirname(fileURLToPath(import.meta.url))
133
+ const BUNDLED_RULES_DIR = join(binDir, '..', 'rules')
134
+ const BUNDLED_SKILLS_DIR = join(binDir, '..', 'skills')
135
+ const BUNDLED_AGENTS_TEMPLATE_PATH = join(binDir, '..', AGENTS_TEMPLATE_FILE)
136
+ /** Корінь установленого пакету (каталог з `rules/`, `github-actions/`, …) */
137
+ const BUNDLED_PACKAGE_ROOT = join(binDir, '..')
138
+
139
+ const YAML_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
140
+ const NEWLINE_RE = /\r?\n/
141
+ const LEADING_SPACES_RE = /^\s+/
142
+
143
+ /** Ключі `.n-rules.json`, де значення — масиви id; після запуску CLI сортуються за алфавітом */
144
+ const CONFIG_SORTED_ARRAY_KEYS = /** @type {const} */ (['rules', 'skills', 'disable-rules', 'disable-skills'])
145
+
146
+ /**
147
+ * Сортує масиви id у конфігу за алфавітом (`localeCompare`), щоб порядок у файлі був стабільним після синку.
148
+ * @param {Record<string, unknown>} config об'єкт конфігу перед записом на диск
149
+ * @returns {Record<string, unknown>} копія з відсортованими масивами для відомих ключів
150
+ */
151
+ export function sortConfigIdArrays(config) {
152
+ const out = { ...config }
153
+ for (const key of CONFIG_SORTED_ARRAY_KEYS) {
154
+ const v = out[key]
155
+ if (key in out && Array.isArray(v)) {
156
+ out[key] = v.map(String).toSorted((a, b) => a.localeCompare(b))
157
+ }
158
+ }
159
+ return out
160
+ }
161
+
162
+ /**
163
+ * Імена правил з каталогу `rules/` поточної інсталяції пакету. Кожне правило — окремий
164
+ * підкаталог `rules/<id>/`, у якому має бути `main.mdc`.
165
+ * @param {string} [bundledRulesDir] каталог `rules/` у корені пакету
166
+ * @returns {Promise<string[]>} відсортовані id правил (імена підкаталогів)
167
+ */
168
+ export async function discoverBundledRuleNames(bundledRulesDir = BUNDLED_RULES_DIR) {
169
+ if (!existsSync(bundledRulesDir)) {
170
+ throw new Error(
171
+ `Не знайдено каталог правил пакету.\n` +
172
+ `Очікуваний шлях: ${bundledRulesDir}\n` +
173
+ `Перевстановіть ${PACKAGE_NAME} або створіть ${CONFIG_FILE} вручну.`
174
+ )
175
+ }
176
+ const entries = await readdir(bundledRulesDir, { withFileTypes: true })
177
+ const rules = entries
178
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
179
+ .filter(e => existsSync(join(bundledRulesDir, e.name, 'main.mdc')))
180
+ .map(e => e.name)
181
+ .toSorted((a, b) => a.localeCompare(b))
182
+ if (rules.length === 0) {
183
+ throw new Error(`У каталозі rules/ пакету немає підкаталогів з main.mdc. Створіть ${CONFIG_FILE} вручну.`)
184
+ }
185
+ return rules
186
+ }
187
+
188
+ /**
189
+ * Імена skills (id без префікса n-) з каталогу skills пакету — лише підкаталоги `<id>/` без префікса n-
190
+ * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету
191
+ * @returns {Promise<string[]>} відсортовані id
192
+ */
193
+ export async function discoverBundledSkillNames(bundledSkillsDir = BUNDLED_SKILLS_DIR) {
194
+ if (!existsSync(bundledSkillsDir)) {
195
+ return []
196
+ }
197
+ const entries = await readdir(bundledSkillsDir, { withFileTypes: true })
198
+ return entries
199
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith(RULE_PREFIX))
200
+ .map(e => e.name)
201
+ .toSorted((a, b) => a.localeCompare(b))
202
+ }
203
+
204
+ /**
205
+ * Перейменовує у каталозі правил файли `nitra-*.mdc` → `n-*.mdc`. Якщо `n-*.mdc` уже є, застарілий файл видаляється.
206
+ * @param {string} rulesDir абсолютний шлях до `.cursor/rules`
207
+ * @returns {Promise<void>}
208
+ */
209
+ export async function migrateLegacyManagedRuleFilenames(rulesDir) {
210
+ if (!existsSync(rulesDir)) {
211
+ return
212
+ }
213
+ const names = await readdir(rulesDir)
214
+ for (const name of names) {
215
+ if (!(name.endsWith('.mdc') && name.startsWith('nitra-'))) {
216
+ continue
217
+ }
218
+
219
+ const rest = name.slice('nitra-'.length)
220
+ const newName = `${RULE_PREFIX}${rest}`
221
+ const from = join(rulesDir, name)
222
+ const to = join(rulesDir, newName)
223
+ if (existsSync(to)) {
224
+ await unlink(from)
225
+ console.log(`📝 Видалено застарілий ${RULES_DIR}/${name} (вже є ${newName})\n`)
226
+ } else {
227
+ await rename(from, to)
228
+ console.log(`📝 Перейменовано ${RULES_DIR}/${name} → ${RULES_DIR}/${newName}\n`)
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Міграція legacy: `nitra-*.mdc` → `n-*.mdc` у `.cursor/rules`; якщо немає `.n-rules.json`, конфіг
235
+ * береться з legacy-імен (`.n-rules.json`, потім `nitra-cursor.json`) з переписуванням `$schema` на новий URL
236
+ * @returns {Promise<void>}
237
+ */
238
+ export async function migrateLegacyConfigIfNeeded() {
239
+ const root = cwd()
240
+ await migrateLegacyManagedRuleFilenames(join(root, RULES_DIR))
241
+
242
+ const target = join(root, CONFIG_FILE)
243
+ if (existsSync(target)) {
244
+ return
245
+ }
246
+ for (const legacyName of ['.n-cursor.json', 'nitra-cursor.json']) {
247
+ const legacyPath = join(root, legacyName)
248
+ if (!existsSync(legacyPath)) {
249
+ continue
250
+ }
251
+ await rename(legacyPath, target)
252
+ console.log(`📝 Перейменовано ${legacyName} → ${CONFIG_FILE}\n`)
253
+ try {
254
+ const parsed = JSON.parse(await readFile(target, 'utf8'))
255
+ if (typeof parsed?.$schema === 'string' && parsed.$schema.includes('@nitra/cursor')) {
256
+ parsed.$schema = CONFIG_SCHEMA_URL
257
+ await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`)
258
+ }
259
+ } catch {
260
+ /* некоректний JSON — $schema виправить подальший readConfig */
261
+ }
262
+ return
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Повертає розпарсений package.json з кореня або null, якщо файл відсутній/некоректний.
268
+ * @returns {Promise<unknown | null>} обʼєкт package.json або null
269
+ */
270
+ export async function readRootPackageJsonSafe() {
271
+ const packageJsonPath = join(cwd(), 'package.json')
272
+ if (!existsSync(packageJsonPath)) {
273
+ return null
274
+ }
275
+ try {
276
+ return JSON.parse(await readFile(packageJsonPath, 'utf8'))
277
+ } catch {
278
+ return null
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Агрегує імена правил по rules-каталогах (ядро перше, перший власник виграє)
284
+ * і будує мапу `ruleId → rulesDir` для копіювання mdc.
285
+ * @param {string[]} rulesDirs упорядковані rules-каталоги (ядро + плагіни)
286
+ * @returns {Promise<{ names: string[], sources: Map<string, string>, extras: Map<string, string[]> }>} імена правил, їх джерела і mixin-теки
287
+ */
288
+ export async function aggregateRuleSources(rulesDirs) {
289
+ /** @type {Map<string, string>} */
290
+ const sources = new Map()
291
+ /** @type {Map<string, string[]>} каталоги `rules/<id>/` mixin-джерел (не власник main.mdc) */
292
+ const extras = new Map()
293
+ const perDirNames = await listRuleNamesPerDir(rulesDirs)
294
+ for (const [i, dir] of rulesDirs.entries()) {
295
+ for (const name of perDirNames[i]) {
296
+ if (!sources.has(name)) sources.set(name, dir)
297
+ }
298
+ if (i > 0) await collectMixinDirs(dir, perDirNames[i], extras)
299
+ }
300
+ // Тека з main.mdc у плагіні для правила, яким володіє інше джерело, — теж mixin (дублікат id).
301
+ for (const [i, dir] of rulesDirs.entries()) {
302
+ if (i === 0) continue
303
+ for (const name of perDirNames[i]) {
304
+ if (sources.get(name) === dir) continue
305
+ const list = extras.get(name) ?? []
306
+ list.push(join(dir, name))
307
+ extras.set(name, list)
308
+ }
309
+ }
310
+ return { names: sources.keys().toArray(), sources, extras }
311
+ }
312
+
313
+ /**
314
+ * Імена правил (теки з `main.mdc`) кожного rules-каталогу. Для ядра (перший елемент)
315
+ * відсутність каталогу — фатальна помилка; для плагінів — порожній список
316
+ * (уже повідомлено у resolve-plugins).
317
+ * @param {string[]} rulesDirs упорядковані rules-каталоги
318
+ * @returns {Promise<string[][]>} імена правил per-dir у тому ж порядку
319
+ */
320
+ export async function listRuleNamesPerDir(rulesDirs) {
321
+ /** @type {string[][]} */
322
+ const out = []
323
+ for (const [i, dir] of rulesDirs.entries()) {
324
+ if (i === 0) {
325
+ out.push(await discoverBundledRuleNames(dir))
326
+ continue
327
+ }
328
+ try {
329
+ out.push(await discoverBundledRuleNames(dir))
330
+ } catch {
331
+ out.push([])
332
+ }
333
+ }
334
+ return out
335
+ }
336
+
337
+ /**
338
+ * Додає у `extras` mixin-теки плагіна: підкаталоги `rules/<id>/` БЕЗ `main.mdc`
339
+ * (плагін доповнює правило іншого джерела концернами).
340
+ * @param {string} dir rules-каталог плагіна
341
+ * @param {string[]} ownNames імена повних правил цього каталогу (з main.mdc)
342
+ * @param {Map<string, string[]>} extras акумулятор mixin-тек (мутується)
343
+ * @returns {Promise<void>}
344
+ */
345
+ export async function collectMixinDirs(dir, ownNames, extras) {
346
+ let entries
347
+ try {
348
+ entries = await readdir(dir, { withFileTypes: true })
349
+ } catch {
350
+ return // нечитабельний rules/ плагіна — вже пропущений вище
351
+ }
352
+ for (const e of entries) {
353
+ if (!e.isDirectory() || e.name.startsWith('.') || ownNames.includes(e.name)) continue
354
+ const list = extras.get(e.name) ?? []
355
+ list.push(join(dir, e.name))
356
+ extras.set(e.name, list)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Зчитує конфіг .n-rules.json з поточної директорії
362
+ * @param {{ bundledRulesDir?: string, bundledSkillsDir?: string }} [paths] каталоги з пакету-джерела (після `bun i` — зазвичай `node_modules/@7n/rules`)
363
+ * @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-); поле version у файлі за наявності ігнорується при синхронізації правил
364
+ */
365
+ export async function readConfig(paths = {}) {
366
+ const bundledRulesDir = paths.bundledRulesDir ?? BUNDLED_RULES_DIR
367
+ const bundledSkillsDir = paths.bundledSkillsDir ?? BUNDLED_SKILLS_DIR
368
+ await migrateLegacyConfigIfNeeded()
369
+ const configPath = join(cwd(), CONFIG_FILE)
370
+
371
+ /** @type {Record<string, unknown> | null} */
372
+ let rawConfig = null
373
+ if (existsSync(configPath)) {
374
+ try {
375
+ rawConfig = JSON.parse(await readFile(configPath, 'utf8'))
376
+ } catch {
377
+ throw new Error(`Невірний JSON у файлі ${CONFIG_FILE}`)
378
+ }
379
+ }
380
+
381
+ // Плагіни: явне поле plugins конфігу (з per-категорійним backfill автодетекту — ADR
382
+ // 260719-2154) або повний автодетект, коли поля нема; sync-контекст — з установкою devDep.
383
+ // Результат записуємо у конфіг (нижче), щоб hook/lint не залежали від детекту й мережі.
384
+ const declaredPlugins = Array.isArray(rawConfig?.plugins) ? rawConfig.plugins : null
385
+ const resolvedPlugins = resolvePluginList(cwd(), rawConfig)
386
+ const rulesDirs = resolveRulesDirs(cwd(), rawConfig, bundledRulesDir).map(d => d.rulesDir)
387
+ const { names: availableRules } = await aggregateRuleSources(rulesDirs)
388
+ const availableSkills = await discoverBundledSkillNames(bundledSkillsDir)
389
+
390
+ /**
391
+ * Автодописує правила/skills за `rules/<rule>/auto.md` і синхронізує `$schema`.
392
+ * @param {Record<string, unknown>} parsedConfig сирий обʼєкт конфігу
393
+ * @returns {Promise<Record<string, unknown>>} нормалізований конфіг
394
+ */
395
+ async function normalizeConfigWithAutoRules(parsedConfig) {
396
+ const currentRules = parsedConfig.rules
397
+ if (!Array.isArray(currentRules)) {
398
+ throw new TypeError(`У ${CONFIG_FILE} поле "rules" має бути масивом рядків`)
399
+ }
400
+ if ('skills' in parsedConfig && !Array.isArray(parsedConfig.skills)) {
401
+ throw new Error(`У ${CONFIG_FILE} поле "skills" має бути масивом рядків`)
402
+ }
403
+ const { ignore } = parsedConfig
404
+ if (ignore !== undefined && (!Array.isArray(ignore) || ignore.some(p => typeof p !== 'string'))) {
405
+ throw new Error(`У ${CONFIG_FILE} поле "ignore" має бути масивом рядків (шляхів до директорій)`)
406
+ }
407
+
408
+ const rootPkg = await readRootPackageJsonSafe()
409
+ const disableRules = normalizeIdList(parsedConfig['disable-rules'])
410
+ const disableSkills = normalizeIdList(parsedConfig['disable-skills'])
411
+ const autoDetectedRules = await detectAutoRules({
412
+ root: cwd(),
413
+ availableRules,
414
+ packageJsonParsed: rootPkg,
415
+ disableRules,
416
+ rulesDirs
417
+ })
418
+ // Skills залежать від ефективного списку правил, який буде у конфізі після merge:
419
+ // вже існуючі (опт-ін вручну) + auto-detected, мінус `disable-rules`. Без цього
420
+ // правило, додане вручну (напр. `adr` без auto.md-умови), не активувало б залежні
421
+ // скіли (`adr-normalize`).
422
+ const disableRulesSet = new Set(disableRules)
423
+ const effectiveRulesForSkills = [
424
+ ...new Set([...normalizeIdList(parsedConfig.rules), ...autoDetectedRules.rules]).difference(disableRulesSet)
425
+ ]
426
+ const autoDetectedSkills = detectAutoSkills({
427
+ availableSkills,
428
+ detectedRules: effectiveRulesForSkills,
429
+ disableSkills
430
+ })
431
+
432
+ const merged = mergeConfigWithAutoDetected({
433
+ config: parsedConfig,
434
+ detectedRules: autoDetectedRules.rules,
435
+ detectedSkills: autoDetectedSkills.skills,
436
+ availableRules,
437
+ availableSkills
438
+ })
439
+
440
+ if (merged.pruned) {
441
+ const parts = []
442
+ if (merged.pruned.rules.length > 0) parts.push(`rules: ${merged.pruned.rules.join(', ')}`)
443
+ if (merged.pruned.skills.length > 0) parts.push(`skills: ${merged.pruned.skills.join(', ')}`)
444
+ console.log(`🧹 Прибрано з ${CONFIG_FILE} неактуальні (немає у пакеті) — ${parts.join('; ')}\n`)
445
+ }
446
+
447
+ const rest = Object.fromEntries(Object.entries(parsedConfig).filter(([k]) => k !== '$schema'))
448
+ const normalized = {
449
+ $schema: CONFIG_SCHEMA_URL,
450
+ ...rest,
451
+ rules: merged.rules,
452
+ skills: merged.skills
453
+ }
454
+ if (merged['disable-rules']?.length) {
455
+ normalized['disable-rules'] = merged['disable-rules']
456
+ }
457
+ if (merged['disable-skills']?.length) {
458
+ normalized['disable-skills'] = merged['disable-skills']
459
+ }
460
+ if (!('plugins' in parsedConfig)) {
461
+ if (resolvedPlugins.length > 0) {
462
+ normalized.plugins = resolvedPlugins
463
+ console.log(`🔌 Автодетект плагінів: ${resolvedPlugins.join(', ')} — записано у ${CONFIG_FILE}\n`)
464
+ }
465
+ } else if (declaredPlugins && JSON.stringify(resolvedPlugins) !== JSON.stringify(declaredPlugins)) {
466
+ // per-категорійний backfill (resolvePluginList, ADR 260719-2154) додав плагін
467
+ // категорії, відсутньої в явному plugins — фіксуємо результат у конфізі.
468
+ normalized.plugins = resolvedPlugins
469
+ const added = resolvedPlugins.filter(p => !declaredPlugins.includes(p))
470
+ console.log(`🔌 Доповнено plugins у ${CONFIG_FILE} (${added.join(', ')}) — категорія не була задекларована\n`)
471
+ }
472
+ return sortConfigIdArrays(normalized)
473
+ }
474
+
475
+ if (rawConfig === null) {
476
+ const rootPkg = await readRootPackageJsonSafe()
477
+ const autoDetectedRules = await detectAutoRules({
478
+ root: cwd(),
479
+ availableRules,
480
+ packageJsonParsed: rootPkg,
481
+ rulesDirs
482
+ })
483
+ const autoDetectedSkills = detectAutoSkills({
484
+ availableSkills,
485
+ detectedRules: autoDetectedRules.rules
486
+ })
487
+ const defaultConfig = sortConfigIdArrays({
488
+ $schema: CONFIG_SCHEMA_URL,
489
+ rules: autoDetectedRules.rules,
490
+ skills: autoDetectedSkills.skills,
491
+ ...(resolvedPlugins.length > 0 && { plugins: resolvedPlugins })
492
+ })
493
+ await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
494
+ console.log(
495
+ `📝 Створено ${CONFIG_FILE} з автоаналізом правил (${defaultConfig.rules.length}) і skills (${defaultConfig.skills.length}).\n`
496
+ )
497
+ return defaultConfig
498
+ }
499
+ const config = rawConfig
500
+ logRuleMigrationsIfAny(config)
501
+ const normalized = await normalizeConfigWithAutoRules(config)
502
+ if (JSON.stringify(normalized) !== JSON.stringify(config)) {
503
+ await writeFile(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
504
+ console.log(`📝 Оновлено ${CONFIG_FILE}: синхронізовано $schema та авто-додані rules/skills\n`)
505
+ }
506
+ return normalized
507
+ }
508
+
509
+ /**
510
+ * Якщо у `rules` чи `disable-rules` є застарілі rule-id з `RULE_MIGRATIONS`,
511
+ * виводить пояснювальний лог про автоматичну заміну (саму заміну виконує
512
+ * `migrateRuleIds` у `mergeConfigWithAutoDetected` — тут лише користувацька комунікація).
513
+ * @param {Record<string, unknown>} parsedConfig сирий обʼєкт `.n-rules.json` після `JSON.parse`
514
+ * @returns {void}
515
+ */
516
+ function logRuleMigrationsIfAny(parsedConfig) {
517
+ /** @type {Set<string>} */
518
+ const seen = new Set()
519
+ for (const key of /** @type {const} */ (['rules', 'disable-rules'])) {
520
+ const list = parsedConfig[key]
521
+ if (!Array.isArray(list)) continue
522
+ const legacy = detectLegacyRuleIds(normalizeIdList(list))
523
+ for (const id of legacy) seen.add(id)
524
+ }
525
+ if (seen.size === 0) return
526
+ console.log(`📦 Авто-міграція ${CONFIG_FILE}:`)
527
+ for (const id of seen) {
528
+ const replacement = RULE_MIGRATIONS[id].join(', ')
529
+ console.log(` • ${id} → ${replacement}`)
530
+ }
531
+ console.log('')
532
+ }
533
+
534
+ /**
535
+ * Витягує чистий id правила без шляху і без .mdc.
536
+ * "npm/rules/text/text.mdc" → "text"
537
+ * "text.mdc" → "text"
538
+ * "text" → "text"
539
+ * @param {string} ruleName шлях або базове ім'я, з суфіксом .mdc або без
540
+ * @returns {string} id правила (без .mdc, без шляху)
541
+ */
542
+ export function normalizeRuleName(ruleName) {
543
+ const name = basename(String(ruleName).trim())
544
+ return name.endsWith('.mdc') ? name.slice(0, -'.mdc'.length) : name
545
+ }
546
+
547
+ /**
548
+ * Читає вміст правила з каталогу `rules/<id>/main.mdc` установленого пакету
549
+ * (наприклад `node_modules/@7n/rules/rules/<id>/main.mdc` або кеш npx).
550
+ * Mixin-концерни: `extraRuleDirs` — каталоги `rules/<id>/` того самого правила з ІНШИХ
551
+ * джерел (плагінів); їхні concern-mdc доінлайнюються після концернів власника, тож
552
+ * `.cursor/rules/n-<id>.mdc` містить і провайдер-специфічні розділи (напр. lint_js_yml
553
+ * з `@7n/rules-ci-github` чи lint_pipeline_js з `@7n/rules-ci-azure`).
554
+ * @param {string} rule елемент масиву rules з `.n-rules.json`
555
+ * @param {string} [bundledRulesDir] каталог `rules/` у корені пакету-джерела (власник main.mdc)
556
+ * @param {string[]} [extraRuleDirs] каталоги `rules/<id>/` mixin-джерел (без main.mdc)
557
+ * @returns {Promise<string>} текст правила для запису в `.cursor/rules/n-*.mdc`
558
+ */
559
+ export async function readBundledRuleContent(rule, bundledRulesDir = BUNDLED_RULES_DIR, extraRuleDirs = []) {
560
+ const id = normalizeRuleName(rule)
561
+ const bundledPath = join(bundledRulesDir, id, 'main.mdc')
562
+ if (!existsSync(bundledPath)) {
563
+ throw new Error(
564
+ `Немає файлу ${id}/main.mdc у ${bundledRulesDir}. Оновіть ${PACKAGE_NAME} або приберіть "${rule}" з rules у ${CONFIG_FILE}.`
565
+ )
566
+ }
567
+ const text = await readFile(bundledPath, 'utf8')
568
+ let out = await inlineTemplateLinks(text, dirname(bundledPath))
569
+ out = await appendDiscoveredMdcFiles(out, dirname(bundledPath))
570
+ for (const extraDir of extraRuleDirs) {
571
+ out = await appendDiscoveredMdcFiles(out, extraDir)
572
+ }
573
+ return out
574
+ }
575
+
576
+ /**
577
+ * Нормалізує id skill з конфігу до форми без префікса n- (як «fix»)
578
+ * @param {string} skillName елемент масиву skills або ім'я каталогу
579
+ * @returns {string} id без префікса n-
580
+ */
581
+ export function normalizeSkillId(skillName) {
582
+ let s = basename(String(skillName).trim())
583
+ if (s.startsWith(RULE_PREFIX)) {
584
+ s = s.slice(RULE_PREFIX.length)
585
+ }
586
+ return s
587
+ }
588
+
589
+ /**
590
+ * Ім'я керованого каталогу skill у .cursor/skills (префікс n-)
591
+ * @param {string} skillId id без префікса (або з префіксом n- у конфігу — нормалізується)
592
+ * @returns {string} наприклад n-fix
593
+ */
594
+ export function managedSkillDirName(skillId) {
595
+ return `${RULE_PREFIX}${normalizeSkillId(skillId)}`
596
+ }
597
+
598
+ /**
599
+ * Витягує текст description з YAML frontmatter SKILL.md (формат description: >-)
600
+ * @param {string} text повний вміст SKILL.md
601
+ * @returns {string | null} один рядок опису або null
602
+ */
603
+ export function extractSkillDescription(text) {
604
+ const fm = text.match(YAML_FRONTMATTER_RE)
605
+ if (!fm) {
606
+ return null
607
+ }
608
+ const lines = fm[1].split(NEWLINE_RE)
609
+ const start = lines.findIndex(line => line.trim() === 'description: >-')
610
+ if (start === -1) {
611
+ return null
612
+ }
613
+ const descLines = []
614
+ for (const line of lines.slice(start + 1)) {
615
+ if (!LEADING_SPACES_RE.test(line)) {
616
+ break
617
+ }
618
+ descLines.push(line.replace(LEADING_SPACES_RE, '').trimEnd())
619
+ }
620
+ if (descLines.length === 0) {
621
+ return null
622
+ }
623
+ return descLines.join(' ').trim()
624
+ }
625
+
626
+ /**
627
+ * Підготовка опису skill для вставки в звичайний markdown (заголовок H1, bullet без code fence).
628
+ * Послідовність `<id>` сприймається markdownlint (MD033) як inline HTML — замінюємо на `{id}`.
629
+ * @param {string} desc один рядок з YAML frontmatter SKILL.md
630
+ * @returns {string} той самий рядок після заміни літералу з кутовими дужками навколо id на плейсхолдер у фігурних дужках (MD033).
631
+ */
632
+ export function skillDescriptionSafeForMarkdownInline(desc) {
633
+ return desc.replaceAll('<id>', '{id}')
634
+ }
635
+
636
+ /**
637
+ * YAML frontmatter для `.claude/commands/*.md`: поле `description` потрібне розширенню VSCode,
638
+ * щоб команди з’являлись у списку. Текст збігається з полем `description` у frontmatter `SKILL.md`.
639
+ * @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
640
+ * @returns {string} блок `---` … `---` і порожній рядок після
641
+ */
642
+ export function formatClaudeCommandFrontmatter(descriptionRaw) {
643
+ let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
644
+ if (!text) {
645
+ text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
646
+ }
647
+ return `---\ndescription: >-\n ${text}\n---\n\n`
648
+ }
649
+
650
+ /**
651
+ * YAML frontmatter для `.pi/skills/<dir>/SKILL.md` згідно зі специфікацією pi.dev:
652
+ * обов'язкові поля `name` (1-64, `[a-z0-9-]`) і `description` (≤ 1024). Текст description збігається
653
+ * з полем `description` у frontmatter джерельного `SKILL.md`.
654
+ * @param {string} skillName ім'я скілу (наприклад `n-fix`); має бути валідним pi-name
655
+ * @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
656
+ * @returns {string} блок `---` … `---` і порожній рядок після
657
+ */
658
+ export function formatPiSkillFrontmatter(skillName, descriptionRaw) {
659
+ let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
660
+ if (!text) {
661
+ text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
662
+ }
663
+ return `---\nname: ${skillName}\ndescription: >-\n ${text}\n---\n\n`
664
+ }
665
+
666
+ /**
667
+ * Базові імена файлів .mdc, які очікуються згідно з .n-rules.json (префікс n-).
668
+ * @param {string[]} configRules елементи масиву rules з конфігу
669
+ * @returns {Set<string>} множина очікуваних імен файлів (наприклад n-bun.mdc)
670
+ */
671
+ export function expectedManagedRuleBasenames(configRules) {
672
+ return new Set(configRules.map(rule => `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`))
673
+ }
674
+
675
+ /**
676
+ * Видаляє з каталогу правил файли *.mdc з префіксом n-, яких немає у конфігурації.
677
+ * Файли без префікса n- не змінює.
678
+ * @param {string} rulesDir абсолютний шлях до .cursor/rules
679
+ * @param {string[]} configRules елементи масиву rules з .n-rules.json
680
+ * @returns {Promise<string[]>} відсортовані імена видалених файлів
681
+ */
682
+ export async function removeOrphanManagedRuleFiles(rulesDir, configRules) {
683
+ if (!existsSync(rulesDir)) {
684
+ return []
685
+ }
686
+ const expected = expectedManagedRuleBasenames(configRules)
687
+ const names = await readdir(rulesDir)
688
+ const removed = []
689
+ for (const name of names) {
690
+ if (!(name.endsWith('.mdc') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
691
+ continue
692
+ }
693
+
694
+ await unlink(join(rulesDir, name))
695
+ removed.push(name)
696
+ }
697
+ return removed.toSorted((a, b) => a.localeCompare(b))
698
+ }
699
+
700
+ /**
701
+ * Повертає відсортований список директорій skills у `.cursor/skills`.
702
+ * Директорія вважається skill-каталогом, якщо це підкаталог (без префікса `.`).
703
+ * @returns {Promise<string[]>} імена директорій (наприклад `n-fix`, `custom-skill`)
704
+ */
705
+ export async function listProjectSkillDirNames() {
706
+ const skillsRoot = join(cwd(), SKILLS_DIR)
707
+ if (!existsSync(skillsRoot)) {
708
+ return []
709
+ }
710
+ const entries = await readdir(skillsRoot, { withFileTypes: true })
711
+ return entries
712
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
713
+ .map(entry => entry.name)
714
+ .toSorted((a, b) => a.localeCompare(b))
715
+ }
716
+
717
+ /**
718
+ * Формує markdown-рядки для секції Skills у AGENTS.md з усіх skill-директорій на диску.
719
+ * @returns {Promise<{ name: string }[]>} елементи з полем name для Mustache-секції skills
720
+ */
721
+ export async function buildSkillBulletItems() {
722
+ const skillsRoot = join(cwd(), SKILLS_DIR)
723
+ const skillDirNames = await listProjectSkillDirNames()
724
+ const items = []
725
+ for (const dirName of skillDirNames) {
726
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
727
+ let desc = ''
728
+ if (existsSync(skillMdPath)) {
729
+ const text = await readFile(skillMdPath, 'utf8')
730
+ const parsed = extractSkillDescription(text)
731
+ if (parsed) {
732
+ desc = skillDescriptionSafeForMarkdownInline(parsed)
733
+ }
734
+ }
735
+ const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
736
+ const line = desc ? `${pathLine} — ${desc}` : pathLine
737
+ items.push({ name: line })
738
+ }
739
+ return items
740
+ }
741
+
742
+ /**
743
+ * Видаляє каталоги n-* у .cursor/skills, яких немає у конфігурації skills
744
+ * @param {string} skillsRoot абсолютний шлях до .cursor/skills
745
+ * @param {string[]} configSkills елементи масиву skills з .n-rules.json
746
+ * @returns {Promise<string[]>} імена видалених каталогів
747
+ */
748
+ export async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
749
+ if (!existsSync(skillsRoot)) {
750
+ return []
751
+ }
752
+ const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
753
+ const entries = await readdir(skillsRoot, { withFileTypes: true })
754
+ const removed = []
755
+ for (const e of entries) {
756
+ const isManagedDir = e.isDirectory() && e.name.startsWith(RULE_PREFIX)
757
+ const isOrphan = isManagedDir && !expected.has(e.name)
758
+ if (isOrphan) {
759
+ await rm(join(skillsRoot, e.name), { recursive: true, force: true })
760
+ removed.push(e.name)
761
+ }
762
+ }
763
+ return removed.toSorted((a, b) => a.localeCompare(b))
764
+ }
765
+
766
+ /**
767
+ * Рендерить коротку секцію для CLAUDE.md: full-прогони lint мають вбудовану
768
+ * глобальну чергу з видимим прогресом, дельта-запуски йдуть паралельно без лока.
769
+ * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
770
+ */
771
+ function buildClaudeLintParallelismSectionLines() {
772
+ return [
773
+ '',
774
+ '## Лінт і ESLint (паралелізм)',
775
+ '',
776
+ 'Дельта-`lint` (типовий задачний прогін) — **без черги**, паралельні запуски по різних файлах дозволені. `npx @7n/rules lint --full` має **вбудовану глобальну чергу** (spec 2026-07-03): один full-прогін на машину; запуск у черзі показує свою позицію, решту черги і живий прогрес-бар активного прогону (`⏳ lint --full у черзі #… · працює pid … [██…] 5/12 · …` — штатна черга, не зависання; fail-closed таймаут 45 хв). Ідентичний повтор --full на незміненому дереві дедуплікується (`♻️ … пропускаю`). Координувати запуски вручну не треба. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
777
+ ''
778
+ ]
779
+ }
780
+
781
+ /**
782
+ * Рендерить секцію для CLAUDE.md: скіли з `main.json.worktree === true` запускаються
783
+ * лише в окремому git-worktree (дублює fail-fast банер `SKILL.md` як завжди-читане правило).
784
+ * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
785
+ */
786
+ function buildClaudeWorktreeEnforcementSectionLines() {
787
+ return [
788
+ '',
789
+ '## Worktree-only skills (`main.json` → `worktree: true`)',
790
+ '',
791
+ 'Скіл із **`worktree: true`** у `main.json` запускається **виключно** в окремому git-worktree (`.worktrees/<current-branch>-<suffix>/`) — **не** в основному дереві й **не** паралельно. Перший крок такого скіла (блок `n-rules:worktree:start` у його `SKILL.md`) — **preflight**: якщо `git rev-parse --show-toplevel` не вказує під `.worktrees/`, **STOP** і не питай користувача про назву гілки; створи worktree від поточної гілки готовим snippet з `SKILL.md` за конвенцією `<current-branch>-<suffix>` і без shell expansion (без command substitution, variable expansion чи backticks). Чисте робоче дерево — **не** привід пропустити preflight.',
792
+ ''
793
+ ]
794
+ }
795
+
796
+ /**
797
+ * Рендерить секцію для CLAUDE.md: doc-files — обовʼязковий крок задачі (як lint).
798
+ * Після зміни кодового файлу його дока має бути перегенерована; застарілість
799
+ * детермінується за CRC і контролюється Stop-hook'ом.
800
+ * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
801
+ */
802
+ function buildClaudeDocFilesSectionLines() {
803
+ return [
804
+ '',
805
+ '## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
806
+ '',
807
+ 'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`hook --post-tool-use`) **сигналить** про дрейф після правки через per-file lint правила. Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
808
+ ''
809
+ ]
810
+ }
811
+
812
+ /**
813
+ * Рендерить секцію Skills для CLAUDE.md з урахуванням наявних slash-команд.
814
+ * @returns {Promise<string[]>} готові рядки секції (або порожній масив)
815
+ */
816
+ export async function buildClaudeSkillsSectionLines() {
817
+ const skillDirNames = await listProjectSkillDirNames()
818
+ if (skillDirNames.length === 0) {
819
+ return []
820
+ }
821
+
822
+ const lines = ['', '## Skills', '']
823
+ const skillsRoot = join(cwd(), SKILLS_DIR)
824
+ const commandsRoot = join(cwd(), COMMANDS_DIR)
825
+ for (const dirName of skillDirNames) {
826
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
827
+ const commandPath = join(commandsRoot, `${dirName}.md`)
828
+ let desc = ''
829
+ if (existsSync(skillMdPath)) {
830
+ const text = await readFile(skillMdPath, 'utf8')
831
+ const parsed = extractSkillDescription(text)
832
+ if (parsed) {
833
+ desc = skillDescriptionSafeForMarkdownInline(parsed)
834
+ }
835
+ }
836
+ const ref = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
837
+ lines.push(desc ? `${ref} — ${desc}` : ref)
838
+ if (existsSync(commandPath)) {
839
+ lines.push(` Команда: \`/${dirName}\``)
840
+ }
841
+ }
842
+ return lines
843
+ }
844
+
845
+ /**
846
+ * Генерує CLAUDE.md у корені cwd з at-імпортами всіх .mdc-правил та посиланнями на skills.
847
+ * Завдяки цьому Claude Code автоматично завантажує вміст кожного правила при старті.
848
+ * @returns {Promise<void>}
849
+ */
850
+ /**
851
+ * @param {string[]} [ignore] директорії заборонені для редагування
852
+ */
853
+ export async function syncClaudeMd(ignore) {
854
+ const lines = [`<!-- Цей файл генерується автоматично через \`npx ${PACKAGE_NAME}\`. Не редагуй вручну. -->`, '']
855
+
856
+ if (Array.isArray(ignore) && ignore.length > 0) {
857
+ lines.push('## Захищені директорії', '', 'Ніколи не змінюй, не видаляй і не створюй файли у цих директоріях:', '')
858
+ for (const dir of ignore) {
859
+ let d = dir
860
+ while (d.endsWith('/')) d = d.slice(0, -1)
861
+ lines.push(`- \`${d}/\``)
862
+ }
863
+ lines.push('')
864
+ }
865
+
866
+ const mdcFiles = await listProjectRulesMdcFiles()
867
+ for (const mdcFile of mdcFiles) {
868
+ lines.push(`@${RULES_DIR}/${mdcFile}`)
869
+ }
870
+
871
+ lines.push(
872
+ ...buildClaudeLintParallelismSectionLines(),
873
+ ...buildClaudeWorktreeEnforcementSectionLines(),
874
+ ...buildClaudeDocFilesSectionLines()
875
+ )
876
+
877
+ const skillsSectionLines = await buildClaudeSkillsSectionLines()
878
+ lines.push(...skillsSectionLines)
879
+ const claudeMdPath = join(cwd(), 'CLAUDE.md')
880
+ const hadFile = existsSync(claudeMdPath)
881
+ await writeFile(claudeMdPath, formatGeneratedMarkdownLines(lines), 'utf8')
882
+ console.log(hadFile ? `📝 Оновлено CLAUDE.md` : `📝 Створено CLAUDE.md`)
883
+ }
884
+
885
+ /**
886
+ * Повністю перезаписує AGENTS.md у корені cwd з npm/AGENTS.template.md
887
+ * @param {string} [agentsTemplatePath] шлях до AGENTS.template.md у корені пакету-джерела
888
+ * @returns {Promise<void>} завершення запису файлу
889
+ */
890
+ export async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
891
+ if (!existsSync(agentsTemplatePath)) {
892
+ throw new Error(
893
+ `Не знайдено шаблон ${AGENTS_TEMPLATE_FILE} у пакеті.\n` +
894
+ `Очікуваний шлях: ${agentsTemplatePath}\n` +
895
+ `Перевстановіть ${PACKAGE_NAME}.`
896
+ )
897
+ }
898
+ const templateText = await readFile(agentsTemplatePath, 'utf8')
899
+ const mdcFiles = await listProjectRulesMdcFiles()
900
+ const skillItems = await buildSkillBulletItems()
901
+ const commandItems = await buildAgentsCommandBulletItems(cwd())
902
+ const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
903
+ const agentsPath = join(cwd(), AGENTS_FILE)
904
+ const hadFile = existsSync(agentsPath)
905
+ await writeFile(agentsPath, body.endsWith('\n') ? body : `${body}\n`, 'utf8')
906
+ console.log(
907
+ hadFile
908
+ ? `📝 Оновлено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
909
+ : `📝 Створено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
910
+ )
911
+ }
912
+
913
+ /**
914
+ * Копіює лише skills зі списку configSkills (джерело: skills/<id>/ у пакеті)
915
+ * @param {string[]} configSkills id без префікса n-
916
+ * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
917
+ * @param {{ plugins?: unknown } | null} [config] конфіг `.n-rules.json` — активні плагіни для SKILL-фрагментів
918
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
919
+ */
920
+ export async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR, config = null) {
921
+ if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
922
+ return { success: 0, fail: 0 }
923
+ }
924
+
925
+ const skillsRoot = join(cwd(), SKILLS_DIR)
926
+ await mkdir(skillsRoot, { recursive: true })
927
+ // Активні плагіни — джерело конвенційних фрагментів skills/<id>/SKILL.fragment.md
928
+ // (фаза 4b spec lang-plugins-extraction): мовні гілки скіла їдуть з плагіном.
929
+ const activePlugins = resolvePlugins(cwd(), config, { allowInstall: false, quiet: true })
930
+
931
+ let success = 0
932
+ let fail = 0
933
+
934
+ for (const skillId of configSkills) {
935
+ const id = normalizeSkillId(skillId)
936
+ const srcDir = join(bundledSkillsDir, id)
937
+ const destDirName = managedSkillDirName(skillId)
938
+ const destDir = join(skillsRoot, destDirName)
939
+
940
+ process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
941
+ if (existsSync(srcDir)) {
942
+ try {
943
+ await mkdir(destDir, { recursive: true })
944
+ const meta = readSkillMetaRaw(srcDir)
945
+ const worktree = meta?.worktree === true
946
+ // root-guard для in-place скілів (мутують CWD без worktree-ізоляції).
947
+ // Worktree-скіли root-assert уже мають у worktree-блоці, тож тут лише !worktree.
948
+ const rootOnly = !worktree && meta?.requireRoot === true
949
+ const entries = await readdir(srcDir, { withFileTypes: true })
950
+ for (const entry of entries) {
951
+ // Лише top-level файли скіла. `main.json` — метадані (не для споживача);
952
+ // підкаталоги (`js/` — скіл-специфічний код) виконуються з пакета через
953
+ // `npx`, у проєкт не копіюються (як `npm/rules/<id>/js/`). Див. scripts.mdc.
954
+ if (!entry.isFile() || entry.name === 'main.json') continue
955
+ let content = await readFile(join(srcDir, entry.name), 'utf8')
956
+ if (entry.name === 'SKILL.md') {
957
+ content = injectWorktreeNotice(content, worktree)
958
+ content = injectRootNotice(content, rootOnly)
959
+ content = injectSkillFragments(content, collectSkillFragments(id, activePlugins))
960
+ }
961
+ await writeFile(join(destDir, entry.name), content, 'utf8')
962
+ }
963
+ console.log(`✅`)
964
+ success++
965
+ } catch (error) {
966
+ console.log(`❌`)
967
+ console.error(` Помилка: ${error.message}`)
968
+ fail++
969
+ }
970
+ } else {
971
+ console.log(`❌`)
972
+ console.error(` Немає каталогу в пакеті: skills/${id}`)
973
+ fail++
974
+ }
975
+ }
976
+ return { success, fail }
977
+ }
978
+
979
+ /**
980
+ * Синхронізує .claude/commands/n-<id>.md зі skills пакету.
981
+ * У кожному файлі обов’язково YAML frontmatter з `description` (як у `SKILL.md`), інакше команди
982
+ * не з’являються у розширенні VSCode; далі — заголовок H1 лише з імені команди (без повтору опису) і посилання на `.cursor/skills/…/SKILL.md`.
983
+ * @param {string[]} configSkills id без префікса n-
984
+ * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
985
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
986
+ */
987
+ export async function syncCommands(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
988
+ if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
989
+ return { success: 0, fail: 0 }
990
+ }
991
+
992
+ const commandsDir = join(cwd(), COMMANDS_DIR)
993
+ await mkdir(commandsDir, { recursive: true })
994
+
995
+ let success = 0
996
+ let fail = 0
997
+
998
+ for (const skillId of configSkills) {
999
+ const id = normalizeSkillId(skillId)
1000
+ const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
1001
+ const destDirName = managedSkillDirName(skillId)
1002
+ const destFile = join(commandsDir, `${RULE_PREFIX}${id}.md`)
1003
+
1004
+ process.stdout.write(` ⬇ ${id} → ${COMMANDS_DIR}/${RULE_PREFIX}${id}.md ... `)
1005
+ if (existsSync(srcSkillMd)) {
1006
+ try {
1007
+ const raw = await readFile(srcSkillMd, 'utf8')
1008
+ const descRaw = extractSkillDescription(raw)
1009
+ const frontmatter = formatClaudeCommandFrontmatter(descRaw || '')
1010
+ const header = `# ${RULE_PREFIX}${id}\n\n`
1011
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
1012
+ await writeFile(destFile, body, 'utf8')
1013
+ console.log(`✅`)
1014
+ success++
1015
+ } catch (error) {
1016
+ console.log(`❌`)
1017
+ console.error(` Помилка: ${error.message}`)
1018
+ fail++
1019
+ }
1020
+ } else {
1021
+ console.log(`❌`)
1022
+ console.error(` Немає SKILL.md у пакеті: skills/${id}`)
1023
+ fail++
1024
+ }
1025
+ }
1026
+ return { success, fail }
1027
+ }
1028
+
1029
+ /**
1030
+ * Видаляє файли n-*.md у .claude/commands, яких немає у конфігурації skills
1031
+ * @param {string} commandsDir абсолютний шлях до .claude/commands
1032
+ * @param {string[]} configSkills id без префікса n-
1033
+ * @returns {Promise<string[]>} імена видалених файлів
1034
+ */
1035
+ export async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
1036
+ if (!existsSync(commandsDir)) {
1037
+ return []
1038
+ }
1039
+ const expected = new Set(configSkills.map(s => `${RULE_PREFIX}${normalizeSkillId(s)}.md`))
1040
+ const names = await readdir(commandsDir)
1041
+ const removed = []
1042
+ for (const name of names) {
1043
+ if (!(name.endsWith('.md') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
1044
+ continue
1045
+ }
1046
+
1047
+ await unlink(join(commandsDir, name))
1048
+ removed.push(name)
1049
+ }
1050
+ return removed.toSorted((a, b) => a.localeCompare(b))
1051
+ }
1052
+
1053
+ /**
1054
+ * Синхронізує .claude/commands/{dirName}.md для всіх локальних скілів з .cursor/skills/
1055
+ * що не керуються пакетом (відсутні в configSkills). Frontmatter `description` — як у відповідному SKILL.md.
1056
+ * @param {string[]} configSkills id керованих skills (вже оброблені syncCommands)
1057
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1058
+ */
1059
+ export async function syncLocalOnlySkillCommands(configSkills) {
1060
+ const skillsRoot = join(cwd(), SKILLS_DIR)
1061
+ if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
1062
+
1063
+ const commandsDir = join(cwd(), COMMANDS_DIR)
1064
+ await mkdir(commandsDir, { recursive: true })
1065
+
1066
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1067
+ const allDirNames = await listProjectSkillDirNames()
1068
+ const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
1069
+
1070
+ let success = 0
1071
+ let fail = 0
1072
+
1073
+ for (const dirName of localOnly) {
1074
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
1075
+ const destFile = join(commandsDir, `${dirName}.md`)
1076
+
1077
+ process.stdout.write(` ⬇ ${dirName} → ${COMMANDS_DIR}/${dirName}.md ... `)
1078
+ try {
1079
+ let descRaw = ''
1080
+ if (existsSync(skillMdPath)) {
1081
+ const raw = await readFile(skillMdPath, 'utf8')
1082
+ const parsed = extractSkillDescription(raw)
1083
+ if (parsed) descRaw = parsed
1084
+ }
1085
+ const frontmatter = formatClaudeCommandFrontmatter(descRaw)
1086
+ const header = `# ${dirName}\n\n`
1087
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
1088
+ await writeFile(destFile, body, 'utf8')
1089
+ console.log(`✅`)
1090
+ success++
1091
+ } catch (error) {
1092
+ console.log(`❌`)
1093
+ console.error(` Помилка: ${errorMessage(error)}`)
1094
+ fail++
1095
+ }
1096
+ }
1097
+ return { success, fail }
1098
+ }
1099
+
1100
+ /**
1101
+ * Видаляє .claude/commands/{dirName}.md файли локальних скілів, яких більше немає в .cursor/skills/
1102
+ * @param {string} commandsDir абсолютний шлях до .claude/commands
1103
+ * @param {string[]} configSkills id керованих skills
1104
+ * @returns {Promise<string[]>} імена видалених файлів
1105
+ */
1106
+ export async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
1107
+ if (!existsSync(commandsDir)) return []
1108
+
1109
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1110
+ const allDirNames = new Set(await listProjectSkillDirNames())
1111
+ const names = await readdir(commandsDir)
1112
+ const removed = []
1113
+
1114
+ for (const name of names) {
1115
+ if (!name.endsWith('.md') || name.startsWith(RULE_PREFIX)) continue
1116
+ const dirName = name.slice(0, -3)
1117
+ if (!managedDirNames.has(dirName) && !allDirNames.has(dirName)) {
1118
+ await unlink(join(commandsDir, name))
1119
+ removed.push(name)
1120
+ }
1121
+ }
1122
+ return removed.toSorted((a, b) => a.localeCompare(b))
1123
+ }
1124
+
1125
+ /**
1126
+ * Синхронізує .pi/skills/n-<id>/SKILL.md зі skills пакету для pi.dev-сумісності.
1127
+ * Pi-skill — це директорія з SKILL.md (frontmatter `name`+`description`), тіло-делегат на джерельний
1128
+ * `.cursor/skills/<dir>/SKILL.md`. Симетрично до `syncCommands`, але дир замість `.md`-файлу.
1129
+ * @param {string[]} configSkills id без префікса n-
1130
+ * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
1131
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1132
+ */
1133
+ export async function syncPiSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
1134
+ if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
1135
+ return { success: 0, fail: 0 }
1136
+ }
1137
+
1138
+ const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
1139
+ await mkdir(piSkillsRoot, { recursive: true })
1140
+
1141
+ let success = 0
1142
+ let fail = 0
1143
+
1144
+ for (const skillId of configSkills) {
1145
+ const id = normalizeSkillId(skillId)
1146
+ const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
1147
+ const destDirName = managedSkillDirName(skillId)
1148
+ const destDir = join(piSkillsRoot, destDirName)
1149
+ const destFile = join(destDir, 'SKILL.md')
1150
+
1151
+ process.stdout.write(` ⬇ ${id} → ${PI_SKILLS_DIR}/${destDirName}/SKILL.md ... `)
1152
+ if (existsSync(srcSkillMd)) {
1153
+ try {
1154
+ const raw = await readFile(srcSkillMd, 'utf8')
1155
+ const descRaw = extractSkillDescription(raw)
1156
+ await mkdir(destDir, { recursive: true })
1157
+ const frontmatter = formatPiSkillFrontmatter(destDirName, descRaw || '')
1158
+ const header = `# ${destDirName}\n\n`
1159
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
1160
+ await writeFile(destFile, body, 'utf8')
1161
+ console.log(`✅`)
1162
+ success++
1163
+ } catch (error) {
1164
+ console.log(`❌`)
1165
+ console.error(` Помилка: ${errorMessage(error)}`)
1166
+ fail++
1167
+ }
1168
+ } else {
1169
+ console.log(`❌`)
1170
+ console.error(` Немає SKILL.md у пакеті: skills/${id}`)
1171
+ fail++
1172
+ }
1173
+ }
1174
+ return { success, fail }
1175
+ }
1176
+
1177
+ /**
1178
+ * Синхронізує .pi/skills/{dirName}/SKILL.md для всіх локальних скілів з .cursor/skills/
1179
+ * що не керуються пакетом. Симетрично до `syncLocalOnlySkillCommands`.
1180
+ * @param {string[]} configSkills id керованих skills (уже оброблені syncPiSkills)
1181
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1182
+ */
1183
+ export async function syncLocalOnlyPiSkills(configSkills) {
1184
+ const skillsRoot = join(cwd(), SKILLS_DIR)
1185
+ if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
1186
+
1187
+ const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
1188
+ await mkdir(piSkillsRoot, { recursive: true })
1189
+
1190
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1191
+ const allDirNames = await listProjectSkillDirNames()
1192
+ const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
1193
+
1194
+ let success = 0
1195
+ let fail = 0
1196
+
1197
+ for (const dirName of localOnly) {
1198
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
1199
+ const destDir = join(piSkillsRoot, dirName)
1200
+ const destFile = join(destDir, 'SKILL.md')
1201
+
1202
+ process.stdout.write(` ⬇ ${dirName} → ${PI_SKILLS_DIR}/${dirName}/SKILL.md ... `)
1203
+ try {
1204
+ let descRaw = ''
1205
+ if (existsSync(skillMdPath)) {
1206
+ const raw = await readFile(skillMdPath, 'utf8')
1207
+ const parsed = extractSkillDescription(raw)
1208
+ if (parsed) descRaw = parsed
1209
+ }
1210
+ await mkdir(destDir, { recursive: true })
1211
+ const frontmatter = formatPiSkillFrontmatter(dirName, descRaw)
1212
+ const header = `# ${dirName}\n\n`
1213
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
1214
+ await writeFile(destFile, body, 'utf8')
1215
+ console.log(`✅`)
1216
+ success++
1217
+ } catch (error) {
1218
+ console.log(`❌`)
1219
+ console.error(` Помилка: ${errorMessage(error)}`)
1220
+ fail++
1221
+ }
1222
+ }
1223
+ return { success, fail }
1224
+ }
1225
+
1226
+ /**
1227
+ * Видаляє n-* директорії у .pi/skills, яких немає у конфігурації skills.
1228
+ * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1229
+ * @param {string[]} configSkills id без префікса n-
1230
+ * @returns {Promise<string[]>} імена видалених директорій
1231
+ */
1232
+ export async function removeOrphanManagedPiSkillDirs(piSkillsDir, configSkills) {
1233
+ if (!existsSync(piSkillsDir)) return []
1234
+ const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
1235
+ const entries = await readdir(piSkillsDir, { withFileTypes: true })
1236
+ const removed = []
1237
+ for (const entry of entries) {
1238
+ if (!(entry.isDirectory() && entry.name.startsWith(RULE_PREFIX) && !expected.has(entry.name))) {
1239
+ continue
1240
+ }
1241
+
1242
+ await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1243
+ removed.push(entry.name)
1244
+ }
1245
+ return removed.toSorted((a, b) => a.localeCompare(b))
1246
+ }
1247
+
1248
+ /**
1249
+ * Видаляє .pi/skills/{dirName} директорії локальних скілів, яких більше немає в .cursor/skills/.
1250
+ * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1251
+ * @param {string[]} configSkills id керованих skills
1252
+ * @returns {Promise<string[]>} імена видалених директорій
1253
+ */
1254
+ export async function removeOrphanLocalPiSkillDirs(piSkillsDir, configSkills) {
1255
+ if (!existsSync(piSkillsDir)) return []
1256
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1257
+ const allDirNames = new Set(await listProjectSkillDirNames())
1258
+ const entries = await readdir(piSkillsDir, { withFileTypes: true })
1259
+ const removed = []
1260
+ for (const entry of entries) {
1261
+ if (!entry.isDirectory() || entry.name.startsWith(RULE_PREFIX)) continue
1262
+ if (!managedDirNames.has(entry.name) && !allDirNames.has(entry.name)) {
1263
+ await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1264
+ removed.push(entry.name)
1265
+ }
1266
+ }
1267
+ return removed.toSorted((a, b) => a.localeCompare(b))
1268
+ }
1269
+
1270
+ /**
1271
+ * Людинозрозумілий текст винятку для логів.
1272
+ * @param {unknown} error виняток із catch
1273
+ * @returns {string} текст повідомлення
1274
+ */
1275
+ export function errorMessage(error) {
1276
+ return error instanceof Error ? error.message : String(error)
1277
+ }
1278
+
1279
+ /**
1280
+ * Виконує крок синхронізації з уніфікованим логуванням помилки.
1281
+ * @template T
1282
+ * @param {string} prefix префікс повідомлення про помилку
1283
+ * @param {() => Promise<T>} action операція
1284
+ * @returns {Promise<T>} результат операції
1285
+ */
1286
+ export async function runSyncStep(prefix, action) {
1287
+ try {
1288
+ return await action()
1289
+ } catch (error) {
1290
+ console.error(`${prefix}${errorMessage(error)}`)
1291
+ throw error
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Виконує `action`, буферизуючи весь його stdout/console-вивід.
1297
+ *
1298
+ * Мотивація: за успішного прогону sync-блоку рядки `⬇ … ✅` і підсумок
1299
+ * (`🧩 Skills: N скопійовано, 0 з помилками`) не несуть користі й лише
1300
+ * захаращують термінал. Тому буфер скидається в реальний stdout **лише**
1301
+ * коли крок повернув `fail > 0` (або кинув виняток); за `fail === 0` —
1302
+ * відкидається мовчки.
1303
+ * @template T
1304
+ * @param {() => Promise<T>} action крок синку, що повертає обʼєкт із лічильником помилок `fail`
1305
+ * @returns {Promise<T>} результат `action` без змін
1306
+ */
1307
+ export async function captureOutput(action) {
1308
+ const buffer = []
1309
+ const realStdoutWrite = process.stdout.write
1310
+ const realLog = console.log
1311
+ const realError = console.error
1312
+ const flush = () => realStdoutWrite.call(process.stdout, buffer.join(''))
1313
+ process.stdout.write = (...args) => {
1314
+ const [chunk] = args
1315
+ buffer.push(typeof chunk === 'string' ? chunk : chunk.toString())
1316
+ const cb = args.find(arg => typeof arg === 'function')
1317
+ if (cb) cb()
1318
+ return true
1319
+ }
1320
+ console.log = (...args) => {
1321
+ buffer.push(`${args.map(String).join(' ')}\n`)
1322
+ }
1323
+ console.error = (...args) => {
1324
+ buffer.push(`${args.map(String).join(' ')}\n`)
1325
+ }
1326
+ try {
1327
+ const result = await action()
1328
+ if (result?.fail > 0) flush()
1329
+ return result
1330
+ } catch (error) {
1331
+ flush()
1332
+ throw error
1333
+ } finally {
1334
+ process.stdout.write = realStdoutWrite
1335
+ console.log = realLog
1336
+ console.error = realError
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Копіює керовані `.mdc` файли з пакету до `.cursor/rules`.
1342
+ * @param {string[]} rules список rules з конфігу
1343
+ * @param {string} bundledRulesDir каталог `rules` пакету-джерела (fallback для правил без явного джерела)
1344
+ * @param {string} rulesDir абсолютний шлях до `.cursor/rules`
1345
+ * @param {Map<string, string>} [ruleSources] мапа `ruleId → rulesDir` власника правила (ядро/плагін)
1346
+ * @param {Map<string, string[]>} [ruleExtras] мапа `ruleId → rules/<id>-теки` mixin-джерел (concern-mdc доінлайнюються)
1347
+ * @returns {Promise<{ successCount: number, failCount: number }>} статистика копіювання
1348
+ */
1349
+ export async function syncManagedRuleFiles(
1350
+ rules,
1351
+ bundledRulesDir,
1352
+ rulesDir,
1353
+ ruleSources = new Map(),
1354
+ ruleExtras = new Map()
1355
+ ) {
1356
+ let successCount = 0
1357
+ let failCount = 0
1358
+ for (const rule of rules) {
1359
+ const fileName = `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`
1360
+ const destPath = join(rulesDir, fileName)
1361
+ try {
1362
+ process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
1363
+ const id = normalizeRuleName(rule)
1364
+ const sourceDir = ruleSources.get(id) ?? bundledRulesDir
1365
+ const content = await readBundledRuleContent(rule, sourceDir, ruleExtras.get(id) ?? [])
1366
+ await writeFile(destPath, content, 'utf8')
1367
+ console.log(`✅`)
1368
+ successCount++
1369
+ } catch (error) {
1370
+ console.log(`❌`)
1371
+ console.error(` Помилка: ${errorMessage(error)}`)
1372
+ failCount++
1373
+ }
1374
+ }
1375
+ return { successCount, failCount }
1376
+ }
1377
+
1378
+ /**
1379
+ * Логує видалені керовані правила/skills/commands у єдиному форматі.
1380
+ * @param {string} title назва сутностей
1381
+ * @param {string} basePath базовий шлях для виводу
1382
+ * @param {string[]} names перелік елементів
1383
+ * @returns {void}
1384
+ */
1385
+ export function logRemovedManagedItems(title, basePath, names) {
1386
+ if (names.length === 0) {
1387
+ return
1388
+ }
1389
+ console.log(`\n🧹 Видалено ${title} поза списком ${CONFIG_FILE} (${names.length}):`)
1390
+ for (const name of names) {
1391
+ console.log(` − ${basePath}/${name}`)
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Читає поле `version` з `package.json` пакету за абсолютним шляхом до його кореня.
1397
+ * @param {string} packageRoot корінь пакету (тека з `package.json`)
1398
+ * @returns {Promise<string | null>} semver рядком або null, якщо файлу/поля немає або JSON некоректний
1399
+ */
1400
+ export async function readBundledVersionAt(packageRoot) {
1401
+ const p = join(packageRoot, 'package.json')
1402
+ if (!existsSync(p)) {
1403
+ return null
1404
+ }
1405
+ try {
1406
+ const pkg = JSON.parse(await readFile(p, 'utf8'))
1407
+ return typeof pkg.version === 'string' ? pkg.version : null
1408
+ } catch {
1409
+ return null
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Якщо `upgradeNRulesToLatestAndBunInstall` встановив у `node_modules/@7n/rules` версію,
1415
+ * відмінну від тієї, з якої стартував поточний процес (наприклад, з npx-кешу), запускає бінар нової
1416
+ * версії через `spawnSync` і завершує поточний процес із успадкованим exit-кодом. Re-exec потрібен,
1417
+ * бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRules тощо) і нова логіка
1418
+ * без повної заміни процесу не підхопиться. Захист від нескінченного циклу — env `NITRA_CURSOR_REEXEC=1`.
1419
+ *
1420
+ * Порівнює версії, **не** шляхи: коли `npx` резолвиться в локальний
1421
+ * `<projectRoot>/node_modules/@7n/rules` (проєкт уже має пакет у devDependencies),
1422
+ * `effectivePackageRoot` до і після `upgradeNRulesToLatestAndBunInstall` — той самий шлях,
1423
+ * `bun i` лише перезаписує файли за ним in-place. Читати "поточну" версію з цього шляху
1424
+ * ПІСЛЯ апгрейду означало б читати вже НОВУ версію — порівняння завжди збігалося б і
1425
+ * re-exec ніколи не спрацьовував би, попри те що в памʼяті процесу лишається старий код.
1426
+ * Тому `startVersion` фіксується викликачем ДО апгрейду.
1427
+ * @param {string} effectivePackageRoot шлях, повернутий `upgradeNRulesToLatestAndBunInstall`
1428
+ * @param {string | null} startVersion версія `@7n/rules`, з якою стартував процес (прочитана
1429
+ * з `BUNDLED_PACKAGE_ROOT` до виклику `upgradeNRulesToLatestAndBunInstall`)
1430
+ * @returns {Promise<void>} повертається лише якщо re-exec не потрібен; інакше кидає `ReexecHandoff`,
1431
+ * який ловить top-level catch і прокидає exit-код у `process.exitCode`
1432
+ */
1433
+ export async function reexecIfPackageVersionChanged(effectivePackageRoot, startVersion) {
1434
+ if (env.NITRA_CURSOR_REEXEC === '1') {
1435
+ return
1436
+ }
1437
+ const installedVersion = await readBundledVersionAt(effectivePackageRoot)
1438
+ if (!startVersion || !installedVersion || startVersion === installedVersion) {
1439
+ return
1440
+ }
1441
+ const newBinPath = join(effectivePackageRoot, 'bin', 'n-rules.js')
1442
+ if (!existsSync(newBinPath)) {
1443
+ return
1444
+ }
1445
+ console.log(
1446
+ `🔁 Перезапуск ${PACKAGE_NAME}: процес стартував на ${startVersion}, ` +
1447
+ `після self-upgrade встановлено ${installedVersion}.\n` +
1448
+ ` Re-exec свіжого бінаря, щоб підхопити нову логіку (RULE_MIGRATIONS, auto-detect тощо).\n`
1449
+ )
1450
+ const result = spawnSync(process.execPath, [newBinPath, ...process.argv.slice(2)], {
1451
+ stdio: 'inherit',
1452
+ env: { ...env, NITRA_CURSOR_REEXEC: '1' }
1453
+ })
1454
+ if (result.error) {
1455
+ throw result.error
1456
+ }
1457
+ throw new ReexecHandoff(typeof result.status === 'number' ? result.status : 1)
1458
+ }
1459
+
1460
+ /**
1461
+ * Сентинельна помилка, яку кидає `reexecIfPackageVersionChanged` після успішного re-exec.
1462
+ * Top-level catch розпізнає її й виставляє `process.exitCode = code` без stack-trace —
1463
+ * процес тоді коректно завершується з тим самим кодом, що й child re-exec-у.
1464
+ */
1465
+ export class ReexecHandoff extends Error {
1466
+ /**
1467
+ * @param {number} code exit-код, який повернув child-процес
1468
+ */
1469
+ constructor(code) {
1470
+ super('reexec-handoff')
1471
+ this.name = 'ReexecHandoff'
1472
+ this.code = code
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
1478
+ * @returns {Promise<void>}
1479
+ */
1480
+ export async function runSync() {
1481
+ console.log(`\n🔧 ${PACKAGE_NAME} — завантаження cursor-правил\n`)
1482
+
1483
+ const projectRoot = cwd()
1484
+ // Фіксуємо ДО апгрейду — див. коментар над reexecIfPackageVersionChanged.
1485
+ const startVersion = await readBundledVersionAt(BUNDLED_PACKAGE_ROOT)
1486
+ const effectivePackageRoot = await runSyncStep(`❌ Не вдалося оновити ${PACKAGE_NAME} або виконати bun i: `, () =>
1487
+ upgradeNRulesToLatestAndBunInstall(projectRoot, BUNDLED_PACKAGE_ROOT)
1488
+ )
1489
+
1490
+ await reexecIfPackageVersionChanged(effectivePackageRoot, startVersion)
1491
+
1492
+ const bundledRulesDir = join(effectivePackageRoot, 'rules')
1493
+ const bundledSkillsDir = join(effectivePackageRoot, 'skills')
1494
+ const bundledAgentsTemplatePath = join(effectivePackageRoot, AGENTS_TEMPLATE_FILE)
1495
+
1496
+ const config = await runSyncStep('❌ ', () => readConfig({ bundledRulesDir, bundledSkillsDir }))
1497
+
1498
+ const { rules, skills, version, ignore } = config
1499
+ const claudeConfigEnabled = config['claude-config'] !== false
1500
+ const bundledVer = await readBundledVersionAt(effectivePackageRoot)
1501
+ if (bundledVer) {
1502
+ const line =
1503
+ effectivePackageRoot === BUNDLED_PACKAGE_ROOT
1504
+ ? `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer}`
1505
+ : `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer} (шлях: ${effectivePackageRoot})`
1506
+ console.log(`${line}\n`)
1507
+ }
1508
+ if (version) {
1509
+ console.log(`⚠️ Поле "version" у ${CONFIG_FILE} ігнорується; правила беруться з установленого пакету.\n`)
1510
+ }
1511
+ console.log(`📋 Правил до завантаження: ${rules.length}`)
1512
+ console.log(`📋 Skills до синхронізації: ${skills.length}`)
1513
+
1514
+ await runSyncStep('❌ Не вдалося записати setup-bun-deps action: ', () =>
1515
+ captureOutput(async () => {
1516
+ const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
1517
+ console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
1518
+ })
1519
+ )
1520
+
1521
+ const rulesDir = join(cwd(), RULES_DIR)
1522
+ await mkdir(rulesDir, { recursive: true })
1523
+ // Джерела правил: ядро + rules/ плагінів (резолв кешований після readConfig).
1524
+ const allRulesDirs = resolveRulesDirs(projectRoot, config, bundledRulesDir).map(d => d.rulesDir)
1525
+ const { sources: ruleSources, extras: ruleExtras } = await aggregateRuleSources(allRulesDirs)
1526
+ const { successCount, failCount } = await captureOutput(async () => {
1527
+ const stats = await syncManagedRuleFiles(rules, bundledRulesDir, rulesDir, ruleSources, ruleExtras)
1528
+ return { ...stats, fail: stats.failCount }
1529
+ })
1530
+
1531
+ await runSyncStep(`❌ Не вдалося прибрати зайві файли в ${RULES_DIR}: `, async () => {
1532
+ const removed = await removeOrphanManagedRuleFiles(rulesDir, rules)
1533
+ logRemovedManagedItems('правила', RULES_DIR, removed)
1534
+ })
1535
+
1536
+ await runSyncStep('❌ Skills: ', async () => {
1537
+ const { fail: skillFail } = await captureOutput(async () => {
1538
+ const { success: skillOk, fail } = await syncSkills(skills, bundledSkillsDir, config)
1539
+ if (skills.length > 0) {
1540
+ console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${fail} з помилками`)
1541
+ }
1542
+ return { fail }
1543
+ })
1544
+ const removedSkills = await removeOrphanManagedSkillDirs(join(cwd(), SKILLS_DIR), skills)
1545
+ logRemovedManagedItems('skills', SKILLS_DIR, removedSkills)
1546
+ if (skillFail > 0) {
1547
+ throw new Error(`Не вдалося скопіювати ${skillFail} з ${skills.length} skills`)
1548
+ }
1549
+ })
1550
+
1551
+ await runSyncStep('❌ Commands: ', async () => {
1552
+ const { fail: totalFail } = await captureOutput(async () => {
1553
+ const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
1554
+ const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
1555
+ const totalOk = cmdOk + localOk
1556
+ const fail = cmdFail + localFail
1557
+ if (totalOk + fail > 0) {
1558
+ console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${fail} з помилками`)
1559
+ }
1560
+ return { fail }
1561
+ })
1562
+ const commandsDir = join(cwd(), COMMANDS_DIR)
1563
+ const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
1564
+ logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
1565
+ const removedLocalCmds = await removeOrphanLocalSkillCommandFiles(commandsDir, skills)
1566
+ logRemovedManagedItems('commands (local)', COMMANDS_DIR, removedLocalCmds)
1567
+ if (totalFail > 0) {
1568
+ throw new Error(`Не вдалося скопіювати ${totalFail} commands`)
1569
+ }
1570
+ })
1571
+
1572
+ await runSyncStep('❌ Pi skills: ', async () => {
1573
+ const { fail: totalFail } = await captureOutput(async () => {
1574
+ const { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
1575
+ const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
1576
+ const totalOk = piOk + piLocalOk
1577
+ const fail = piFail + piLocalFail
1578
+ if (totalOk + fail > 0) {
1579
+ console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${fail} з помилками`)
1580
+ }
1581
+ return { fail }
1582
+ })
1583
+ const piSkillsDir = join(cwd(), PI_SKILLS_DIR)
1584
+ const removedPi = await removeOrphanManagedPiSkillDirs(piSkillsDir, skills)
1585
+ logRemovedManagedItems('pi skills', PI_SKILLS_DIR, removedPi)
1586
+ const removedLocalPi = await removeOrphanLocalPiSkillDirs(piSkillsDir, skills)
1587
+ logRemovedManagedItems('pi skills (local)', PI_SKILLS_DIR, removedLocalPi)
1588
+ if (totalFail > 0) {
1589
+ throw new Error(`Не вдалося скопіювати ${totalFail} pi skills`)
1590
+ }
1591
+ })
1592
+
1593
+ await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () =>
1594
+ captureOutput(() => syncAgentsMd(bundledAgentsTemplatePath))
1595
+ )
1596
+ await runSyncStep('❌ Не вдалося оновити CLAUDE.md: ', () =>
1597
+ captureOutput(() => syncClaudeMd(/** @type {string[] | undefined} */ (ignore)))
1598
+ )
1599
+
1600
+ await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ', () =>
1601
+ captureOutput(async () => {
1602
+ const result = await syncClaudeConfig({
1603
+ projectRoot: cwd(),
1604
+ bundledPackageRoot: effectivePackageRoot,
1605
+ enabled: claudeConfigEnabled,
1606
+ rules
1607
+ })
1608
+ if (!claudeConfigEnabled) {
1609
+ console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-rules.json)')
1610
+ return
1611
+ }
1612
+ const parts = []
1613
+ if (result.settings) parts.push('.claude/settings.json')
1614
+ if (result.cursorHooks) parts.push('.cursor/hooks.json')
1615
+ if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
1616
+ if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1617
+ if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
1618
+ if (result.adrHookLib?.length > 0) {
1619
+ for (const libPath of result.adrHookLib) {
1620
+ parts.push(libPath)
1621
+ }
1622
+ }
1623
+ if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1624
+ if (result.piExtension) parts.push('.pi/extensions/n-rules-adr/')
1625
+ if (result.rtkPiExtension) parts.push('.pi/extensions/rtk.ts')
1626
+ if (parts.length > 0) {
1627
+ console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1628
+ }
1629
+ })
1630
+ )
1631
+
1632
+ await runSyncStep('❌ Не вдалося оновити .gitignore (worktree): ', async () => {
1633
+ const { written } = await syncGitignoreWorktree(cwd())
1634
+ if (written) console.log('🌳 .gitignore (worktree): додано .worktrees/')
1635
+ })
1636
+
1637
+ console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
1638
+ if (failCount > 0) {
1639
+ throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
1640
+ }
1641
+ }
1642
+
1643
+ /**
1644
+ * Команди, що мутують проєкт у CWD і вимагають кореня репо. `undefined`/`''` —
1645
+ * дефолтний sync; `check` — deprecated-alias `fix`. Решта (read-only,
1646
+ * `--root`-команди `doc-aggregate`/`rename-yaml-extensions`,
1647
+ * sub-лінтери) гард не зачіпає.
1648
+ */
1649
+ const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'release'])
1650
+
1651
+ /**
1652
+ * Короткий опис дії для тексту root-guard помилки за іменем команди.
1653
+ * @param {string | undefined} cmd підкоманда CLI (або undefined для дефолтного sync)
1654
+ * @returns {string} фраза «що саме мутує CWD»
1655
+ */
1656
+ export function describeRootGuardedAction(cmd) {
1657
+ switch (cmd) {
1658
+ case undefined:
1659
+ case '': {
1660
+ return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-rules.json і робить bun install у поточному каталозі'
1661
+ }
1662
+ case 'lint': {
1663
+ return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
1664
+ }
1665
+ case 'release': {
1666
+ return '`release` бампає version і переписує CHANGELOG у поточному каталозі'
1667
+ }
1668
+ default: {
1669
+ return 'Команда @7n/rules мутує проєкт у поточному каталозі'
1670
+ }
1671
+ }
1672
+ }
1673
+
1674
+ /**
1675
+ * Довідка для `n-rules lint --help`: прапори unified lint surface
1676
+ * (spec 2026-06-29 fix-by-default, spec 2026-07-03 глобальна черга --full).
1677
+ */
1678
+ export function printLintHelp() {
1679
+ console.log(`Використання: npx @7n/rules lint [rule|concern ...] [прапори]
1680
+
1681
+ За замовчуванням лінт іде дельтою (змінені файли vs origin) і одразу
1682
+ виправляє (auto-fix: oxfmt/eslint --fix/stylelint --fix + LLM-ladder).
1683
+
1684
+ Прапори:
1685
+ --full Прогін по всьому репозиторію (конформність), не лише дельта.
1686
+ Один --full на машину: паралельні запуски стають у чергу
1687
+ й бачать живий прогрес активного прогону.
1688
+ --no-fix Лише детекція, без мутацій.
1689
+ --cwd <path> Робочий каталог (корінь прогону) замість поточного —
1690
+ root-guard, .n-rules.json і devDependencies читаються
1691
+ з нього.
1692
+ --path <dir> Звузити файловий набір до піддиректорії, лишивши корінь
1693
+ прогону незмінним (config/root-guard — з поточного каталогу
1694
+ чи --cwd). Дефолт — ПЕРЕТИН піддерева з git-дельтою
1695
+ (vs merge-base main/origin/main або --base), лише per-file
1696
+ правила (сервіс-орієнтований CI-канон). З --full — все
1697
+ піддерево, full-scope правила при спрацюванні йдуть по
1698
+ всьому репо (історична поведінка; без machine-wide локу).
1699
+ Сумісний із позиційним rule/concern-фільтром
1700
+ (lint js --path run/nexus).
1701
+ --base <ref> Явна база дельти (merge-base HEAD <ref>) замість каскаду
1702
+ main → origin/main — для CI-чекаутів.
1703
+ --repo-wide ЛИШЕ full-scope правила (knip, jscpd, dep-policy тощо),
1704
+ весь репозиторій — окремий CI-workflow, що не гейтить
1705
+ деплой сервісів. Не поєднується з --path/фільтром.
1706
+ --verbose Розширений вивід детекції та виправлення.
1707
+ --help, -h Ця довідка.
1708
+
1709
+ Позиційні аргументи — фільтр за назвою правила чи concern, наприклад:
1710
+ npx @7n/rules lint ga rego k8s docker text
1711
+
1712
+ Приклади:
1713
+ npx @7n/rules lint
1714
+ npx @7n/rules lint --full
1715
+ npx @7n/rules lint --no-fix eslint
1716
+ npx @7n/rules lint --cwd ./packages/foo --verbose
1717
+ npx @7n/rules lint js --path run/nexus --no-fix --base origin/main
1718
+ npx @7n/rules lint --repo-wide --no-fix
1719
+
1720
+ Skip-логіка CI (яка джоба взагалі потрібна): npx @7n/rules ci plan
1721
+ ci plan [--path <dir>] [--base <ref>] [--github|--azure|--json]
1722
+ --github → outputs у $GITHUB_OUTPUT; --azure → ##vso-рядки в stdout.
1723
+ `)
1724
+ }
1725
+
1726
+ /* eslint-disable sonarjs/cognitive-complexity -- runCli: плаский CLI-роутер на ~10 команд, кожен
1727
+ case одразу делегує у свій модуль; не нова складність від рефакторингу (той самий код раніше
1728
+ жив на топ-рівні модуля, не вимірювався як функція, до guard-обгортки за isRunAsCli).
1729
+ Розбиття на per-case хендлер-функції додало б непрямий стан (command/args/effectiveRoot/
1730
+ root-guard) без реального зниження складності читання. */
1731
+ /**
1732
+ * CLI: маршрутизація команд. Виконує повний дельта/full lint, sync, release тощо
1733
+ * залежно від `argv` (типово `process.argv.slice(2)`), включно з фінальним
1734
+ * встановленням exit-коду (`process.exitCode`/`process.emit('exit', …)`/`process.reallyExit`).
1735
+ * @param {string[]} argv аргументи CLI без `node`/шляху скрипта (типово `process.argv.slice(2)`)
1736
+ * @returns {Promise<void>}
1737
+ */
1738
+ export async function runCli(argv) {
1739
+ let [command, ...args] = argv
1740
+
1741
+ // Deprecated-аліаси до уніфікації lint-поверхні (spec 2026-06-29): старі
1742
+ // підкоманди `lint-<scope>` замінені на `lint <scope>`. Аліас тут — щоб
1743
+ // існуючі package.json/CI споживачів (lint-text/lint-ga тощо) не ламались
1744
+ // мовчки з непрозорим "Невідома команда" при мажорному бампі пакету.
1745
+ const LEGACY_LINT_COMMAND_ALIASES = {
1746
+ 'lint-ga': 'ga',
1747
+ 'lint-text': 'text',
1748
+ 'lint-rego': 'rego',
1749
+ 'lint-k8s': 'k8s',
1750
+ 'lint-docker': 'docker'
1751
+ }
1752
+ if (command in LEGACY_LINT_COMMAND_ALIASES) {
1753
+ const scope = LEGACY_LINT_COMMAND_ALIASES[command]
1754
+ console.error(`⚠️ "${command}" застаріла назва команди — використовуйте "n-rules lint ${scope}"`)
1755
+ args = [scope, ...args]
1756
+ command = 'lint'
1757
+ }
1758
+
1759
+ // `lint --help`/`-h` — чиста довідка, без root-guard і без мутації devDependencies.
1760
+ const isLintHelp = command === 'lint' && (args.includes('--help') || args.includes('-h'))
1761
+
1762
+ try {
1763
+ if (isLintHelp) {
1764
+ printLintHelp()
1765
+ } else {
1766
+ // Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
1767
+ // .n-rules.json + bun install, а lint/release переписують файли в CWD —
1768
+ // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1769
+ // `bun npm/bin/n-rules.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1770
+ // `--root`-команди (doc-aggregate, rename-yaml-extensions) не зачіпаємо.
1771
+ // `lint` з явним `--cwd` — свідомий вибір кореня (MT ганяє `## Check` вузла з
1772
+ // node-dir усередині worktree, live e2e 2026-07-12): guard і devDeps-ensure
1773
+ // цілять у ЦІЛЬОВИЙ каталог, а не процесний cwd.
1774
+ const lintCwdIdx = command === 'lint' ? args.indexOf('--cwd') : -1
1775
+ const effectiveRoot = lintCwdIdx !== -1 && args[lintCwdIdx + 1] ? resolve(args[lintCwdIdx + 1]) : cwd()
1776
+ if (ROOT_GUARDED_COMMANDS.has(command)) {
1777
+ assertCwdIsProjectRoot(effectiveRoot, describeRootGuardedAction(command))
1778
+ }
1779
+ // `ci` (plan) — read-only гейт-команда для CI-джоб: не мутує package.json
1780
+ // (ensure дописав би devDependency прямо в чекауті pipeline-агента).
1781
+ // `skill <runner> taze|git-reconcile` — JS-оркестровані шляхи: самі
1782
+ // виконують Git/worktree preflight до мутацій. Self-upgrade тут
1783
+ // забруднив би дерево до їхнього власного гейту.
1784
+ // `lint --full` (без --no-fix/--path/--repo-wide) — той самий клас ризику:
1785
+ // `needsWorktreeIsolation` нижче в `case 'lint'` гейтить на чистоту дерева
1786
+ // ЧЕРЕЗ `ensureRunningInWorktree`; self-upgrade тут забруднив би те саме
1787
+ // дерево прямо перед тим гейтом. Відкладаємо ensure до ПІСЛЯ
1788
+ // `ensureRunningInWorktree` (виклик у `case 'lint'`, спрямований на runCwd).
1789
+ const skipDevDepsEnsure =
1790
+ (command === 'skill' && isJsOrchestratedSkillArgs(args)) || (command === 'lint' && isLintFullFixArgs(args))
1791
+ if (command !== 'ci' && !skipDevDepsEnsure) await ensureNRulesInRootDevDependencies(effectiveRoot)
1792
+ // Підкоманди-оркестратори (hook/lint/skill/adr-normalize-local/taze/release тощо)
1793
+ // можуть спавнити внутрішню agent/LLM-сесію — ADR Stop-hooks (capture/normalize)
1794
+ // мають пропустити її як технічний шум, не людську думку (spec 2026-06-30).
1795
+ env.ADR_HOOKS_SKIP = '1'
1796
+ switch (command) {
1797
+ case 'rename-yaml-extensions': {
1798
+ const code = await runRenameYamlExtensionsCli(args)
1799
+ if (code !== 0) {
1800
+ process.exitCode = 1
1801
+ }
1802
+
1803
+ break
1804
+ }
1805
+ case 'hook': {
1806
+ // Thin hook entrypoint для Claude Code hooks. Делегує в runLint, перекодовує exit 1→2.
1807
+ // --post-tool-use PostToolUse: file_path зі stdin JSON
1808
+ // --stop Stop: робоче дерево vs HEAD
1809
+ const { runHookCli } = await import('../scripts/hook.mjs')
1810
+ process.exitCode = await runHookCli(args)
1811
+
1812
+ break
1813
+ }
1814
+ case 'lint': {
1815
+ // Unified lint surface (spec 2026-06-29). Осі: --full (весь репо vs дельта) ×
1816
+ // --no-fix (detect-only). Позиційні аргументи — scoped rule/concern фільтр.
1817
+ // Fix-by-default: detect → T0 → LLM-ladder (run-fix). --no-fix: лише detect.
1818
+ const cwdIdx = args.indexOf('--cwd')
1819
+ const cwdArg = cwdIdx === -1 ? cwd() : resolve(args[cwdIdx + 1])
1820
+ // --path: на відміну від --cwd (підміняє корінь), лишає корінь/config
1821
+ // незмінними й лише звужує файловий набір до заданої піддиректорії
1822
+ // (той самий explicitFiles-шлях, що вже годує hook --post-tool-use/--stop).
1823
+ const pathIdx = args.indexOf('--path')
1824
+ const pathArg = pathIdx === -1 ? null : args[pathIdx + 1]
1825
+ // --base <ref>: явна база дельти для CI (merge-base HEAD ↔ ref замість
1826
+ // каскаду main→origin/main) — надійний diff у checkout-ах з fetch.
1827
+ const baseIdx = args.indexOf('--base')
1828
+ const baseRef = baseIdx === -1 ? null : args[baseIdx + 1]
1829
+ const repoWide = args.includes('--repo-wide')
1830
+ const rules = args.filter(
1831
+ (a, i) =>
1832
+ !a.startsWith('-') &&
1833
+ !(cwdIdx !== -1 && i === cwdIdx + 1) &&
1834
+ !(pathIdx !== -1 && i === pathIdx + 1) &&
1835
+ !(baseIdx !== -1 && i === baseIdx + 1)
1836
+ )
1837
+ if (repoWide && (pathArg !== null || rules.length > 0)) {
1838
+ throw new Error('--repo-wide не поєднується з --path чи scoped rule/concern фільтром — оберіть щось одне')
1839
+ }
1840
+ // --path (сервіс-канон): дефолт — перетин піддерева з git-дельтою, лише
1841
+ // per-file concerns (pathMode). --path --full — історична поведінка: все
1842
+ // піддерево, full-scope concerns при збігу glob ідуть whole-repo. База
1843
+ // дельти не резолвиться → fail-open на повне піддерево (не мовчазний скіп).
1844
+ let pathFiles = null
1845
+ let pathMode = false
1846
+ if (pathArg !== null) {
1847
+ const { collectPathScopedFiles, collectPathScopedChangedFiles } =
1848
+ await import('../scripts/lib/lint-surface/path-scope.mjs')
1849
+ if (args.includes('--full')) {
1850
+ pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1851
+ } else {
1852
+ const scoped = await collectPathScopedChangedFiles(cwdArg, pathArg, { baseRef })
1853
+ if (scoped.baseResolved) {
1854
+ pathFiles = scoped.files
1855
+ pathMode = true
1856
+ } else {
1857
+ console.warn(
1858
+ '⚠️ lint --path: база дельти не резолвиться (немає main/origin/main чи --base-ref) — fail-open: лінтиться все піддерево'
1859
+ )
1860
+ pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1861
+ }
1862
+ }
1863
+ }
1864
+ // --full + --path: buildPlan ігнорує full, коли передано explicitFiles (той самий
1865
+ // шлях), тож і тут full-вісь вважаємо неактивною — інакше глобальний machine-wide
1866
+ // лок --full брався б для швидкого scoped-прогону без причини.
1867
+ const full = args.includes('--full') && pathArg === null && !repoWide
1868
+ const lintOpts = {
1869
+ cwd: cwdArg,
1870
+ full,
1871
+ rules,
1872
+ verbose: args.includes('--verbose'),
1873
+ files: pathFiles,
1874
+ pathMode,
1875
+ repoWide,
1876
+ baseRef
1877
+ }
1878
+ const noFix = args.includes('--no-fix')
1879
+ // `--full` без `--no-fix` мутує ВЕСЬ репо (не лише дельту) — той самий клас
1880
+ // ризику, що й taze: якщо запущено поза .worktrees/, треба ізолювати. `--no-fix`
1881
+ // (detect-only, нуль мутацій) і не-full (дельта, за дизайном працює на живому
1882
+ // дереві задачі, worktree-ізоляція зламала б саму суть дельти) — пропускаємо.
1883
+ // (той самий предикат, що й `isLintFullFixArgs` вище — тримаємо єдиним джерелом,
1884
+ // інакше `skipDevDepsEnsure` і цей гейт можуть розійтись.)
1885
+ const needsWorktreeIsolation = full && !noFix
1886
+ const worktree = needsWorktreeIsolation
1887
+ ? await ensureRunningInWorktree(cwdArg, spawnSync, line => console.log(line), {
1888
+ suffix: 'lint',
1889
+ description: 'n-rules lint --full: worktree-only full-repo run'
1890
+ })
1891
+ : { cwd: cwdArg, autoCreated: false, branchArg: null }
1892
+ const runCwd = worktree.cwd
1893
+ // Ensure відкладений із root-guard-блоку (skipDevDepsEnsure вище) саме для
1894
+ // цього шляху — self-upgrade тепер усередині (auto-created) worktree, ПІСЛЯ
1895
+ // гейту чистоти, а не до нього.
1896
+ if (needsWorktreeIsolation) await ensureNRulesInRootDevDependencies(runCwd)
1897
+ // Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
1898
+ // `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
1899
+ // живий прогрес активного прогону; не-full запуски йдуть без лока
1900
+ // (див. lint-lock.mjs). Publisher пише знімки прогресу для черги.
1901
+ const { withGlobalLintLock, createProgressPublisher } =
1902
+ await import('../scripts/lib/lint-surface/lint-lock.mjs')
1903
+ try {
1904
+ process.exitCode = await withGlobalLintLock({ ...lintOpts, cwd: runCwd, noFix }, async () => {
1905
+ const runOpts = { ...lintOpts, cwd: runCwd }
1906
+ const publisher = runOpts.full ? createProgressPublisher() : null
1907
+ if (publisher) runOpts.onProgress = publisher.onUpdate
1908
+ try {
1909
+ if (noFix) {
1910
+ const { detectAll } = await import('../scripts/lib/lint-surface/run-detectors.mjs')
1911
+ // окрема змінна замість (await detectAll(...)).exitCode — no-await-expression-member (oxlint)
1912
+ const detectResult = await detectAll(runOpts)
1913
+ return detectResult.exitCode
1914
+ }
1915
+ const { runFixPipeline } = await import('../scripts/lib/lint-surface/run-fix.mjs')
1916
+ return await runFixPipeline(runOpts)
1917
+ } finally {
1918
+ publisher?.stop()
1919
+ }
1920
+ })
1921
+ } finally {
1922
+ // Лише для АВТОстворених worktree (лінт уже сидів у своєму — не наш, не чіпаємо).
1923
+ if (worktree.autoCreated) {
1924
+ let bringBackFailed = true
1925
+ try {
1926
+ const bringBackResult = await bringChangesBackToOriginal(runCwd, cwdArg, spawnSync, line =>
1927
+ console.log(line)
1928
+ )
1929
+ bringBackFailed = bringBackResult.failed
1930
+ } catch (error) {
1931
+ console.log(
1932
+ `⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`
1933
+ )
1934
+ }
1935
+ // Прибираємо worktree лише якщо перенесення точно вдалось — інакше
1936
+ // не перенесені зміни згорять разом з деревом.
1937
+ if (bringBackFailed) {
1938
+ console.log(
1939
+ `⚠️ Перенесення назад не підтверджено — worktree "${worktree.branchArg}" лишається для ручного розбору.`
1940
+ )
1941
+ } else {
1942
+ removeAutoCreatedWorktree(worktree.branchArg, cwdArg, spawnSync, line => console.log(line))
1943
+ }
1944
+ }
1945
+ }
1946
+
1947
+ break
1948
+ }
1949
+ case 'ci': {
1950
+ // n-rules ci plan — skip-логіка сервіс-орієнтованого CI-канону: рахує
1951
+ // перетин git-дельти з --path (каталог сервісу) і віддає job outputs
1952
+ // «які lint-домени запускати» для GitHub Actions (--github →
1953
+ // $GITHUB_OUTPUT) та Azure Pipelines (--azure → ##vso-рядки).
1954
+ const { runCiPlanCli } = await import('../scripts/lib/lint-surface/ci-plan.mjs')
1955
+ process.exitCode = await runCiPlanCli(args)
1956
+
1957
+ break
1958
+ }
1959
+ case 'taze': {
1960
+ // n-rules taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
1961
+ // (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
1962
+ // агент отримує готовий список замість ручного порівняння бекапів.
1963
+ // Живе у плагіні @7n/rules-lang-js (фаза 5a spec lang-plugins-extraction) —
1964
+ // резолвимо його taze-handler і беремо named-експорт runTazeCli.
1965
+ const { getHandlers } = await import('../scripts/lib/resolve-plugins.mjs')
1966
+ const { readNRulesConfigLite } = await import('../scripts/lib/read-n-rules-config-lite.mjs')
1967
+ const config = await readNRulesConfigLite(cwd())
1968
+ const handler = getHandlers(cwd(), config, 'taze').find(h => h.pluginName === '@7n/rules-lang-js')
1969
+ if (!handler) {
1970
+ console.error(
1971
+ '❌ taze diff потребує плагін @7n/rules-lang-js (npm/bun-гілка) — запусти npx @7n/rules для авто-встановлення'
1972
+ )
1973
+ process.exitCode = 1
1974
+ break
1975
+ }
1976
+ const { pathToFileURL } = await import('node:url')
1977
+ // eslint-disable-next-line no-unsanitized/method
1978
+ const { runTazeCli } = await import(pathToFileURL(handler.modulePath).href)
1979
+ process.exitCode = await runTazeCli(args)
1980
+
1981
+ break
1982
+ }
1983
+ case 'release': {
1984
+ const { runReleaseCli } = await import('../rules/release/release.mjs')
1985
+ process.exitCode = await runReleaseCli(args)
1986
+
1987
+ break
1988
+ }
1989
+ case 'skill': {
1990
+ process.exitCode = await runSkillsCli(args)
1991
+
1992
+ break
1993
+ }
1994
+ case 'adr-normalize-local': {
1995
+ // Local-backend ADR-нормалізації: викликається з .claude/hooks/normalize-decisions.sh
1996
+ // як заміна single-shot LLM-виклику. Проганяє конвеєр (retrieval→edge-judge→
1997
+ // cluster→gen) на малій локальній моделі й друкує `{operations}` JSON у stdout.
1998
+ const { runAdrNormalizeLocalCli } = await import('../scripts/lib/adr/normalize-cli.mjs')
1999
+ process.exitCode = await runAdrNormalizeLocalCli(args)
2000
+
2001
+ break
2002
+ }
2003
+ case undefined:
2004
+ case '': {
2005
+ await runSync()
2006
+
2007
+ break
2008
+ }
2009
+ default: {
2010
+ console.error(`❌ Невідома команда: ${command}`)
2011
+ console.error(
2012
+ ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), ci plan, taze, release, skill, doc-aggregate`
2013
+ )
2014
+ process.exitCode = 1
2015
+ }
2016
+ }
2017
+ }
2018
+ } catch (error) {
2019
+ // TypeError/RangeError/ReferenceError сигналять баг у самому коді (не навмисне
2020
+ // user-facing повідомлення) — друкуємо stack одразу, інакше діагностика вимагає
2021
+ // патчити node_modules вручну (як під час діагностики цього ж класу вад).
2022
+ const isProgrammerError =
2023
+ error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError
2024
+ if (error instanceof ReexecHandoff) {
2025
+ process.exitCode = error.code
2026
+ } else if (isProgrammerError && error.stack) {
2027
+ console.error(error.stack)
2028
+ process.exitCode = 1
2029
+ } else if (error instanceof Error && error.message) {
2030
+ console.error(error.message)
2031
+ process.exitCode = 1
2032
+ } else {
2033
+ console.error(error)
2034
+ process.exitCode = 1
2035
+ }
2036
+ }
2037
+
2038
+ // Pi-агент тримає TCP keep-alive сокет відкритим — явний вихід, щоб не висіти.
2039
+ // Відтворюємо семантику process.exit() без самого виклику (n/no-process-exit):
2040
+ // фіксуємо код, віддаємо 'exit'-слухачам, далі — примусове завершення через reallyExit.
2041
+ const exitCode = process.exitCode ?? 0
2042
+ process.exitCode = exitCode
2043
+ process.emit('exit', exitCode)
2044
+ process.reallyExit(exitCode)
2045
+ }