@7n/rules 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/.pi-template/extensions
4
+ resource: npm/.pi-template/extensions/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ---------------- | --------- |
9
+ | [rtk.ts](rtk.md) | TS Module |
@@ -0,0 +1,33 @@
1
+ ---
2
+ type: TS Module
3
+ title: rtk.ts
4
+ resource: npm/.pi-template/extensions/rtk.ts
5
+ docgen:
6
+ crc: f00b429f
7
+ ---
8
+
9
+ ## Огляд
10
+
11
+ Pi.dev-extension для rtk (Rust Token Killer): прозоро переписує bash-команди агента на
12
+ rtk-еквіваленти, щоб стискати вивід CLI-команд і економити токени. Vendored із rtk
13
+ (`rtk init --agent pi`), адаптований під конвенції репо; доставляється sync-ом пакета
14
+ `@7n/rules` у `.pi/extensions/rtk.ts` проєктів-споживачів, коли увімкнене правило
15
+ `local-ai`. Шлях збігається зі шляхом установки самого rtk, тож ручний
16
+ `rtk init --agent pi` поверх — ідемпотентний.
17
+
18
+ ## Поведінка
19
+
20
+ - Тонкий делегат: рішення про переписування ухвалює `rtk rewrite <cmd>` (exit 0 або 3 +
21
+ stdout → команда мутується через `event.input.command`; exit 1 — passthrough).
22
+ - При завантаженні пробує `rtk --version`: без бінарника в PATH або з rtk < 0.23.0
23
+ extension сам вимикається з `console.warn` (fail-open) — установка конфігу безпечна
24
+ до `brew install rtk-ai/tap/rtk`.
25
+ - Пропускає без змін: не-bash tool_call, порожні команди, команди, що вже починаються
26
+ з `rtk `, і будь-що при `RTK_DISABLED=1` в env.
27
+ - Виклики rtk обмежені таймаутом 2 с і сигналом скасування контексту; будь-яка
28
+ неочікувана помилка в обробнику лише логується — команда виконується без переписування.
29
+
30
+ ## Гарантії поведінки
31
+
32
+ - Fail-open: жоден збій rtk/extension не блокує виконання команди агента.
33
+ - Мутує лише `event.input.command` у pi tool_call — жодних записів у ФС чи мережевих викликів.
@@ -0,0 +1,102 @@
1
+ // RTK Pi extension — переписує bash-команди на rtk-еквіваленти для економії токенів.
2
+ // Vendored з rtk (`rtk init --agent pi`, rtk 0.43.0), адаптований під конвенції репо:
3
+ // без імпортів з '@earendil-works/pi-coding-agent' (пакет резолвиться лише в runtime pi,
4
+ // а knip/TS-server проєктів-споживачів його не бачать) — типи описані локально, як у
5
+ // n-rules-adr. Шлях установки `.pi/extensions/rtk.ts` збігається зі шляхом самого rtk —
6
+ // повторний `rtk init --agent pi` ідемпотентний. Потребує rtk >= 0.23.0 у PATH; без
7
+ // нього extension сам вимикається (fail-open).
8
+ //
9
+ // Тонкий делегат: уся rewrite-логіка живе в `rtk rewrite` (єдине джерело правди).
10
+ //
11
+ // Контракт exit-кодів `rtk rewrite`:
12
+ // 0 + stdout rewrite знайдено → мутуємо команду
13
+ // 1 rtk-еквівалента немає → passthrough
14
+ // 3 + stdout rewrite (advisory) → мутуємо команду
15
+
16
+ import { env } from 'node:process'
17
+
18
+ interface PiToolCallEvent {
19
+ toolName?: string
20
+ input: { command?: unknown }
21
+ }
22
+
23
+ interface PiContext {
24
+ signal?: AbortSignal
25
+ }
26
+
27
+ interface PiExecResult {
28
+ code: number
29
+ stdout: string
30
+ killed?: boolean
31
+ }
32
+
33
+ interface PiApi {
34
+ exec: (cmd: string, args: string[], opts?: { timeout?: number; signal?: AbortSignal }) => Promise<PiExecResult>
35
+ on: (event: string, handler: (event: PiToolCallEvent, ctx: PiContext) => Promise<void> | void) => void
36
+ }
37
+
38
+ const REWRITE_TIMEOUT_MS = 2_000
39
+ const MIN_SUPPORTED_RTK_MINOR = 23
40
+
41
+ const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/
42
+ const RTK_VERSION_PREFIX_RE = /^rtk\s+/
43
+
44
+ // Парсить "X.Y.Z" semver → [major, minor, patch] або null.
45
+ function parseSemver(raw: string): [number, number, number] | null {
46
+ const m = raw.trim().match(SEMVER_RE)
47
+ if (!m) return null
48
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
49
+ }
50
+
51
+ // Викликає `rtk rewrite`; повертає переписану команду або null (passthrough).
52
+ async function rewriteCommand(pi: PiApi, cmd: string, signal?: AbortSignal): Promise<string | null> {
53
+ const result = await pi.exec('rtk', ['rewrite', cmd], {
54
+ timeout: REWRITE_TIMEOUT_MS,
55
+ signal
56
+ })
57
+ if (result.killed) return null
58
+ if (result.code !== 0 && result.code !== 3) return null
59
+ return result.stdout.trim() || null
60
+ }
61
+
62
+ export default async function (pi: PiApi) {
63
+ // Проба rtk при завантаженні; без бінарника (або із застарим) extension вимикається.
64
+ const ver = await pi.exec('rtk', ['--version'], { timeout: REWRITE_TIMEOUT_MS })
65
+ if (ver.code !== 0) {
66
+ console.warn('[rtk] rtk binary not found in PATH — extension disabled')
67
+ return
68
+ }
69
+
70
+ // `rtk rewrite` з'явився у 0.23.0 — старіші версії не підтримуються.
71
+ const parsed = parseSemver(ver.stdout.replace(RTK_VERSION_PREFIX_RE, ''))
72
+ if (parsed) {
73
+ const [major, minor] = parsed
74
+ if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) {
75
+ console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`)
76
+ return
77
+ }
78
+ }
79
+
80
+ pi.on('tool_call', async (event, ctx) => {
81
+ try {
82
+ // В upstream це isToolCallEventType('bash', event) — просте порівняння toolName.
83
+ if (event?.toolName !== 'bash') return
84
+
85
+ const cmd = event.input.command
86
+ if (typeof cmd !== 'string' || cmd.trim() === '') return
87
+
88
+ if (cmd.startsWith('rtk ')) return
89
+ if (env.RTK_DISABLED === '1') return
90
+
91
+ // Делегуємо rewrite-рішення rtk.
92
+ const rewritten = await rewriteCommand(pi, cmd, ctx.signal)
93
+ if (rewritten && rewritten !== cmd) {
94
+ event.input.command = rewritten
95
+ }
96
+ } catch (error) {
97
+ // Fail open: ніколи не блокуємо виконання через неочікувану помилку.
98
+ console.warn('[rtk] unexpected error in tool_call handler; passing through command', error)
99
+ return
100
+ }
101
+ })
102
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-07-15
4
+
5
+ ### Fixed
6
+
7
+ - Виправлено застарілі шляхи npm/rules/js/js/data/tooling у повідомленнях перевірок tooling/check (пост-реструктуризація da05f89d)
8
+
9
+ ## [1.4.0] - 2026-07-15
10
+
11
+ ### Added
12
+
13
+ - Нове правило `local-ai` (auto: завжди): інтеграція rtk (Rust Token Killer) для стискання виводу CLI-команд агентів. Sync вставляє fail-open PreToolUse hook у `.claude/settings.json` (`rtk hook claude`), preToolUse у `.cursor/hooks.json` (`rtk hook cursor`) та vendored pi-extension `.pi/extensions/rtk.ts`; від користувача потрібен лише `brew install rtk-ai/tap/rtk`. Вимкнення — `disable-rules: ["local-ai"]`.
14
+
3
15
  ## [1.3.2] - 2026-07-15
4
16
 
5
17
  ### Fixed
package/bin/n-rules.js CHANGED
@@ -1458,6 +1458,7 @@ async function runSync() {
1458
1458
  }
1459
1459
  if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1460
1460
  if (result.piExtension) parts.push('.pi/extensions/n-rules-adr/')
1461
+ if (result.rtkPiExtension) parts.push('.pi/extensions/rtk.ts')
1461
1462
  if (parts.length > 0) {
1462
1463
  console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1463
1464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/js/check/main.mjs
5
5
  docgen:
6
- crc: 97d3723c
6
+ crc: 8858d876
7
7
  model: manual
8
8
  ---
9
9
 
@@ -285,7 +285,7 @@ async function checkKnipConfig(passFn, failFn, cwd) {
285
285
  return
286
286
  }
287
287
  await copyFile(KNIP_CANONICAL_JSON_PATH, knipPath)
288
- passFn('knip.json створено з канонічного npm/rules/js/js/data/tooling/knip-canonical.json (js.mdc)')
288
+ passFn('knip.json створено з канонічного npm/rules/js/tooling/data/tooling/knip-canonical.json (js.mdc)')
289
289
  }
290
290
 
291
291
  /**
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/knip@5/schema.json",
3
3
  "entry": [
4
+ ".pi/extensions/**/*.ts",
4
5
  "eslint.config.{js,mjs,cjs}",
5
6
  "stylelint.config.{js,cjs,mjs}",
6
7
  "oxlint.config.{js,mjs}",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/js/tooling/main.mjs
5
5
  docgen:
6
- crc: d863fb93
6
+ crc: da55182a
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -140,7 +140,7 @@ export function verifyOxlintRcAgainstCanonical(cfg, canonical) {
140
140
 
141
141
  if (!deepEqualOxlintCanonical(actual, expected)) {
142
142
  failures.push(
143
- `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @7n/rules (npm/rules/js/js/data/tooling/oxlint-canonical.json)`
143
+ `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @7n/rules (npm/rules/js/tooling/data/tooling/oxlint-canonical.json)`
144
144
  )
145
145
  }
146
146
  }
@@ -0,0 +1 @@
1
+ { "auto": "завжди" }
@@ -0,0 +1,85 @@
1
+ ---
2
+ description: Локальні AI-інструменти на машині розробника — rtk (Rust Token Killer) стискає вивід CLI-команд на 60–90% токенів; агенти застосовують rtk автоматично через hooks, установка лише `brew install rtk-ai/tap/rtk`
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ Правило `local-ai` інтегрує локальні AI-інструменти розробника. Зараз покриває **rtk** —
8
+ компресор виводу CLI-команд (тести, git, build: 60–90% економії токенів). Розраховане на
9
+ розширення іншими локальними інструментами в майбутньому.
10
+
11
+ ## Установка (єдиний крок користувача)
12
+
13
+ ```bash
14
+ brew install rtk-ai/tap/rtk
15
+ ```
16
+
17
+ Більше нічого: hook-записи і extension нижче вставляє sync пакета, і всі вони **fail-open** —
18
+ без установленого rtk мовчки пропускають команди без змін. `rtk init` запускати не треба.
19
+
20
+ ## Як агенту користуватись rtk
21
+
22
+ - Клієнти з rewrite-hook — **Claude Code, Cursor, pi** — переписують shell-команди на
23
+ rtk-еквіваленти автоматично (див. канонічні записи нижче); робити нічого не треба.
24
+ - Агенти без rewrite-hook (Codex тощо): якщо `command -v rtk` успішний — додавай префікс
25
+ `rtk` до команд: `rtk git status`, `rtk vitest`, `rtk tsc`, у ланцюжках теж
26
+ (`rtk git add … && rtk git commit …`). Невідомі rtk команди проходять без змін
27
+ (passthrough), тож префікс безпечний. Не дублюй префікс для команд, що вже починаються з `rtk`.
28
+ - Разово вимкнути переписування: `RTK_DISABLED=1` в env; запуск без фільтра —
29
+ `rtk proxy <cmd>`; статистика заощаджень — `rtk gain`.
30
+
31
+ ## Канонічні записи (вставляє sync, коли правило увімкнене)
32
+
33
+ ### `.claude/settings.json` — PreToolUse
34
+
35
+ ```json
36
+ {
37
+ "hooks": {
38
+ "PreToolUse": [
39
+ {
40
+ "matcher": "Bash",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "command -v rtk >/dev/null 2>&1 && exec rtk hook claude; exit 0",
45
+ "timeout": 30
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ ```
53
+
54
+ Без rtk hook завершується `exit 0` без виводу — Claude Code виконує команду як є.
55
+
56
+ ### `.cursor/hooks.json` — preToolUse
57
+
58
+ ```json
59
+ {
60
+ "version": 1,
61
+ "hooks": {
62
+ "preToolUse": [
63
+ {
64
+ "command": "bash -lc 'command -v rtk >/dev/null 2>&1 && exec rtk hook cursor; echo \"{}\"'",
65
+ "matcher": "Shell",
66
+ "timeout": 30
67
+ }
68
+ ]
69
+ }
70
+ }
71
+ ```
72
+
73
+ Cursor вимагає JSON-вивід на всіх шляхах — без rtk hook відповідає `{}` (passthrough).
74
+
75
+ ### `.pi/extensions/rtk.ts`
76
+
77
+ Vendored extension зі складу rtk (`rtk init --agent pi` генерує ідентичний файл у той самий
78
+ шлях — повторна установка ідемпотентна). Тонкий делегат: на `tool_call` bash викликає
79
+ `rtk rewrite <cmd>`; сам вимикається, якщо rtk відсутній або старіший за 0.23.0.
80
+
81
+ ## Вимкнення
82
+
83
+ `disable-rules: ["local-ai"]` у `.n-rules.json` — наступний sync прибере hook-записи з
84
+ `.claude/settings.json` і `.cursor/hooks.json` та видалить `.pi/extensions/rtk.ts`
85
+ (включно з установленим вручну через `rtk init --agent pi` — шлях спільний).
@@ -3,12 +3,12 @@ type: JS Module
3
3
  title: sync-claude-config.mjs
4
4
  resource: npm/scripts/sync-claude-config.mjs
5
5
  docgen:
6
- crc: 35cf216c
6
+ crc: e4513e7a
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 85
9
9
  ---
10
10
 
11
- Синхронізує конфігурацію Claude Code (`.claude/settings.json`, slash-команди з `commands/` темплейту, ADR Stop-hook) та Cursor hooks (`.cursor/hooks.json`) у поточний проєкт із темплейтів пакету `npm/.claude-template/`. Здійснює злиття конфігурацій: користувацькі поля зберігаються у `.claude/settings.json`, а дозволи (`permissions.allow`) зливаються через union. Керовані хуки, ідентифіковані командою-маркером `MANAGED_HOOK_COMMAND_MARKERS`, перезаписуються. Копіює ADR Stop-hook (`.claude/hooks/capture-decisions.sh`) та ADR normalize Stop-hook (`.claude/hooks/normalize-decisions.sh`) залежно від налаштувань у `.n-rules.json`. Також зливає фрагмент `.gitignore` з канонічного шаблону, додаючи необхідні записи для ADR.
11
+ Синхронізує конфігурацію Claude Code (`.claude/settings.json`, slash-команди з `commands/` темплейту, ADR Stop-hook) та Cursor hooks (`.cursor/hooks.json`) у поточний проєкт із темплейтів пакету `npm/.claude-template/`. Здійснює злиття конфігурацій: користувацькі поля зберігаються у `.claude/settings.json`, а дозволи (`permissions.allow`) зливаються через union. Керовані хуки, ідентифіковані командою-маркером `MANAGED_HOOK_COMMAND_MARKERS`, перезаписуються. Копіює ADR Stop-hook (`.claude/hooks/capture-decisions.sh`) та ADR normalize Stop-hook (`.claude/hooks/normalize-decisions.sh`) залежно від налаштувань у `.n-rules.json`. Також зливає фрагмент `.gitignore` з канонічного шаблону, додаючи необхідні записи для ADR. За правилом `local-ai` керує rtk-інтеграцією (fail-open, працює лише за наявності бінарника rtk): PreToolUse hook `rtk hook claude` у `.claude/settings.json`, preToolUse entry `rtk hook cursor` у `.cursor/hooks.json` і vendored pi-extension `.pi/extensions/rtk.ts`; вимкнення правила прибирає записи та видаляє extension.
12
12
 
13
13
  ## Поведінка
14
14
 
@@ -22,6 +22,8 @@ ADR_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-
22
22
  ADR_NORMALIZE_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR normalize Stop-hook.
23
23
  CURSOR_ADR_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR capture Stop-hook у `.cursor/hooks.json`.
24
24
  CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR normalize Stop-hook у `.cursor/hooks.json`.
25
+ RTK_CLAUDE_HOOK_COMMAND_MARKER — Визначає маркер rtk PreToolUse hook'а (`rtk hook claude`) у `.claude/settings.json` (правило `local-ai`).
26
+ RTK_CURSOR_HOOK_COMMAND_MARKER — Визначає маркер rtk preToolUse hook'а (`rtk hook cursor`) у `.cursor/hooks.json` (правило `local-ai`).
25
27
  MANAGED_HOOK_COMMAND_MARKERS — Містить список усіх маркерів, що ідентифікують керовані хуки пакета.
26
28
  PI_DIR — Визначає кореневу директорію для артефактів pi.dev у проєкті-споживачі.
27
29
  PI_EXTENSIONS_DIR — Визначає директорію для TS-extensions pi.dev у проєкті-споживачі.
@@ -31,8 +33,8 @@ ADR_GITIGNORE_SNIPPET_REL — Визначає відносний шлях до
31
33
  mergeAllowList — Об'єднує списки дозволених дозволів, зберігаючи користувацькі записи першими.
32
34
  mergeHooks — Зливає секцію `hooks` з `.claude/settings.json`, видаляючи керовані групи з існуючої конфігурації.
33
35
  mergeSettings — Зливає конфігурацію `.claude/settings.json` з темплейту, зберігаючи користувацькі поля та оновлюючи керовані хуки.
34
- mergeCursorHooksConfig — Зливає конфігурацію `.cursor/hooks.json`, зберігаючи користувацькі записи та додаючи/видаляючи керовані ADR stop entries.
35
- syncCursorHooksConfig — Синхронізує `.cursor/hooks.json` для Cursor Agent stop-hooks, додаючи ADR entries за умовою.
36
+ mergeCursorHooksConfig — Зливає конфігурацію `.cursor/hooks.json`, зберігаючи користувацькі записи та додаючи/видаляючи керовані ADR stop entries і rtk preToolUse entry.
37
+ syncCursorHooksConfig — Синхронізує `.cursor/hooks.json` для Cursor Agent hooks, додаючи ADR stop і rtk preToolUse entries за умовою.
36
38
  syncClaudeSettings — Синхронізує `.claude/settings.json` за темплейтом, зберігаючи користувацькі налаштування.
37
39
  syncAdrHookScript — Копіює канонічний bash-скрипт ADR capture Stop-hook з темплейту пакета у `.claude/hooks/`.
38
40
  syncAdrNormalizeHookScript — Копіює канонічний bash-скрипт ADR normalize Stop-hook з темплейту пакета у `.claude/hooks/`.
@@ -40,6 +42,9 @@ syncAdrHookLibScripts — Копіює всі bash-скрипти з `lib/` те
40
42
  removeOrphanAdrHookLib — Видаляє директорію `.claude/hooks/lib/` з проєкту, якщо ADR-хуки вимкнені.
41
43
  syncPiExtensions — Копіює bundled pi.dev TS-extension `n-rules-adr` у `.pi/extensions/n-rules-adr/` проєкту.
42
44
  removeOrphanPiExtension — Видаляє директорію `.pi/extensions/n-rules-adr/` з проєкту, якщо ADR-хуки вимкнені.
45
+ RTK_PI_EXTENSION_FILE — Визначає ім'я файлу rtk pi-extension (`rtk.ts`) — шлях спільний із `rtk init --agent pi`.
46
+ syncRtkPiExtension — Копіює vendored rtk pi-extension у `.pi/extensions/rtk.ts` проєкту (правило `local-ai`).
47
+ removeOrphanRtkPiExtension — Видаляє `.pi/extensions/rtk.ts` з проєкту, якщо правило `local-ai` вимкнене.
43
48
  syncGitignoreAdrFragment — Дописує відсутні рядки з канонічного ADR-фрагмента до кореневого `.gitignore` проєкту.
44
49
  syncClaudeCommands — Копіює всі slash-команди з `.claude-template/commands/` у `.claude/commands/` проєкту.
45
50
  syncClaudeConfig — Виконує повну синхронізацію Claude Code-конфігу, включаючи ADR-хуки, `.gitignore` та pi-extension, залежно від правил.
@@ -18,7 +18,13 @@
18
18
  * - `.claude/hooks/normalize-decisions.sh` — fully owned bash-скрипт ADR normalize
19
19
  * Stop-hook (батч-нормалізація чернеток); умови — ті самі, що для `capture`.
20
20
  * - `.cursor/hooks.json` — **merge**: користувацькі hooks зберігаються; ADR stop
21
- * entries додаються, коли правило `adr` увімкнене, і видаляються, коли вимкнене.
21
+ * entries додаються, коли правило `adr` увімкнене, і видаляються, коли вимкнене;
22
+ * rtk preToolUse entry — аналогічно за правилом `local-ai`.
23
+ * - rtk (правило `local-ai`) — fail-open інтеграція компресора виводу CLI-команд:
24
+ * PreToolUse hook у `.claude/settings.json` (`rtk hook claude`), preToolUse у
25
+ * `.cursor/hooks.json` (`rtk hook cursor`) і vendored pi-extension
26
+ * `.pi/extensions/rtk.ts`. Без установленого rtk hooks мовчки пропускають команди,
27
+ * тож конфіг безпечно потрапляє в git — установка зводиться до `brew install rtk-ai/tap/rtk`.
22
28
  * - `.gitignore` — **merge** (лише з `adr`): дописує відсутні рядки з канонічного
23
29
  * фрагмента `rules/adr/js/hooks/template/.gitignore.snippet` (`node_modules/`, `dist/`,
24
30
  * `*.secret`, логи capture/normalize, `.normalize-state`, `.normalize.lock`,
@@ -52,6 +58,10 @@ export const ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisi
52
58
  export const CURSOR_ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
53
59
  /** Маркер Cursor ADR Normalize Stop-hook'а — той самий script path, але в `.cursor/hooks.json`. */
54
60
  export const CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisions.sh'
61
+ /** Маркер rtk PreToolUse hook'а у `.claude/settings.json` (правило `local-ai`). */
62
+ export const RTK_CLAUDE_HOOK_COMMAND_MARKER = 'rtk hook claude'
63
+ /** Маркер rtk preToolUse hook'а у `.cursor/hooks.json` (правило `local-ai`). */
64
+ export const RTK_CURSOR_HOOK_COMMAND_MARKER = 'rtk hook cursor'
55
65
  /**
56
66
  * Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких.
57
67
  * Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у.
@@ -65,7 +75,8 @@ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([
65
75
  LEGACY_DOC_FILES_HOOK_COMMAND_MARKER,
66
76
  LEGACY_STOP_HOOK_COMMAND_MARKER,
67
77
  ADR_HOOK_COMMAND_MARKER,
68
- ADR_NORMALIZE_HOOK_COMMAND_MARKER
78
+ ADR_NORMALIZE_HOOK_COMMAND_MARKER,
79
+ RTK_CLAUDE_HOOK_COMMAND_MARKER
69
80
  ])
70
81
 
71
82
  const CLAUDE_DIR = '.claude'
@@ -130,6 +141,32 @@ const CURSOR_ADR_STOP_HOOK = Object.freeze({
130
141
  timeout: 180
131
142
  })
132
143
 
144
+ /**
145
+ * Канонічна група hooks для rtk PreToolUse (правило `local-ai`): переписує Bash-команди
146
+ * на rtk-еквіваленти. Fail-open guard: без установленого rtk — `exit 0` без виводу,
147
+ * Claude Code виконує команду без змін.
148
+ */
149
+ const RTK_PRE_TOOL_USE_HOOK_GROUP = Object.freeze({
150
+ matcher: 'Bash',
151
+ hooks: Object.freeze([
152
+ Object.freeze({
153
+ type: 'command',
154
+ command: `command -v rtk >/dev/null 2>&1 && exec ${RTK_CLAUDE_HOOK_COMMAND_MARKER}; exit 0`,
155
+ timeout: 30
156
+ })
157
+ ])
158
+ })
159
+
160
+ /**
161
+ * Канонічний Cursor preToolUse hook для rtk (правило `local-ai`). Cursor вимагає JSON-вивід
162
+ * на всіх code path — без установленого rtk відповідаємо `{}` (passthrough).
163
+ */
164
+ const CURSOR_RTK_PRE_TOOL_USE_HOOK = Object.freeze({
165
+ command: `bash -lc 'command -v rtk >/dev/null 2>&1 && exec ${RTK_CURSOR_HOOK_COMMAND_MARKER}; echo "{}"'`,
166
+ matcher: 'Shell',
167
+ timeout: 30
168
+ })
169
+
133
170
  /** Канонічний Cursor stop-hook для ADR normalize. */
134
171
  const CURSOR_ADR_NORMALIZE_STOP_HOOK = Object.freeze({
135
172
  command: [
@@ -188,13 +225,13 @@ function isManagedHookGroup(group) {
188
225
  /**
189
226
  * Чи Cursor hook entry належить пакету `@7n/rules`.
190
227
  * @param {CursorHookEntry} entry один entry з `.cursor/hooks.json`
191
- * @returns {boolean} `true`, якщо command містить managed ADR marker
228
+ * @returns {boolean} `true`, якщо command містить managed ADR або rtk marker
192
229
  */
193
230
  function isManagedCursorHookEntry(entry) {
194
231
  return (
195
232
  typeof entry?.command === 'string' &&
196
- [CURSOR_ADR_HOOK_COMMAND_MARKER, CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER].some(marker =>
197
- entry.command.includes(marker)
233
+ [CURSOR_ADR_HOOK_COMMAND_MARKER, CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER, RTK_CURSOR_HOOK_COMMAND_MARKER].some(
234
+ marker => entry.command.includes(marker)
198
235
  )
199
236
  )
200
237
  }
@@ -268,16 +305,36 @@ function templateWithAdrHook(template) {
268
305
  return { ...template, hooks }
269
306
  }
270
307
 
308
+ /**
309
+ * Будує копію темплейту із додатковою rtk PreToolUse hook-групою (правило `local-ai`).
310
+ * Темплейт залишається незмінним; повертається новий об'єкт з доданою групою.
311
+ * @param {ClaudeSettings} template вихідний темплейт із `.claude-template/settings.template.json`
312
+ * @returns {ClaudeSettings} копія з доданою rtk-групою у `hooks.PreToolUse`
313
+ */
314
+ function templateWithRtkHook(template) {
315
+ /** @type {Record<string, HookGroup[]>} */
316
+ const hooks = {}
317
+ for (const [event, groups] of Object.entries(template.hooks ?? {})) {
318
+ hooks[event] = Array.isArray(groups) ? [...groups] : []
319
+ }
320
+ hooks.PreToolUse = [...(hooks.PreToolUse ?? []), /** @type {HookGroup} */ (RTK_PRE_TOOL_USE_HOOK_GROUP)]
321
+ return { ...template, hooks }
322
+ }
323
+
271
324
  /**
272
325
  * Повертає об'єднаний об'єкт settings.json.
273
326
  * @param {ClaudeSettings | undefined} existing існуючий вміст `.claude/settings.json` користувача (або undefined, якщо файла нема)
274
327
  * @param {ClaudeSettings} template settings із темплейту пакета `@7n/rules`
275
328
  * @param {object} [options] опції merge-у
276
329
  * @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групу до managed-hooks (коли в `.n-rules.json` `rules` присутнє `adr`)
330
+ * @param {boolean} [options.includeLocalAiHook] чи додати rtk PreToolUse-групу (коли в `.n-rules.json` `rules` присутнє `local-ai`)
277
331
  * @returns {ClaudeSettings} результат merge-у (користувацькі поля збережено, наші перевизначено)
278
332
  */
279
333
  export function mergeSettings(existing, template, options = {}) {
280
- const effectiveTemplate = options.includeAdrHook ? templateWithAdrHook(template) : template
334
+ let effectiveTemplate = options.includeAdrHook ? templateWithAdrHook(template) : template
335
+ if (options.includeLocalAiHook) {
336
+ effectiveTemplate = templateWithRtkHook(effectiveTemplate)
337
+ }
281
338
  /** @type {ClaudeSettings} */
282
339
  const merged = { ...existing }
283
340
  const mergedAllow = mergeAllowList(existing?.permissions?.allow, effectiveTemplate.permissions?.allow)
@@ -295,10 +352,12 @@ export function mergeSettings(existing, template, options = {}) {
295
352
 
296
353
  /**
297
354
  * Зливає `.cursor/hooks.json`: користувацькі entries зберігаються, managed ADR
298
- * entries у `hooks.stop` перезаписуються або видаляються залежно від `includeAdrHook`.
355
+ * entries у `hooks.stop` перезаписуються або видаляються залежно від `includeAdrHook`;
356
+ * managed rtk entry у `hooks.preToolUse` — залежно від `includeLocalAiHook`.
299
357
  * @param {CursorHooksConfig | undefined} existing поточний Cursor hooks config
300
358
  * @param {object} [options] опції merge-у
301
359
  * @param {boolean} [options.includeAdrHook] чи додати ADR stop entries
360
+ * @param {boolean} [options.includeLocalAiHook] чи додати rtk preToolUse entry
302
361
  * @returns {CursorHooksConfig} результат злиття
303
362
  */
304
363
  export function mergeCursorHooksConfig(existing, options = {}) {
@@ -321,6 +380,15 @@ export function mergeCursorHooksConfig(existing, options = {}) {
321
380
  } else {
322
381
  delete hooks.stop
323
382
  }
383
+ const preToolUse = (hooks.preToolUse ?? []).filter(entry => !isManagedCursorHookEntry(entry))
384
+ if (options.includeLocalAiHook) {
385
+ preToolUse.push(/** @type {CursorHookEntry} */ (CURSOR_RTK_PRE_TOOL_USE_HOOK))
386
+ }
387
+ if (preToolUse.length > 0) {
388
+ hooks.preToolUse = preToolUse
389
+ } else {
390
+ delete hooks.preToolUse
391
+ }
324
392
  merged.version = typeof merged.version === 'number' ? merged.version : 1
325
393
  if (Object.keys(hooks).length > 0) {
326
394
  merged.hooks = hooks
@@ -347,17 +415,18 @@ async function readJsonOrUndefined(path) {
347
415
  }
348
416
 
349
417
  /**
350
- * Синхронізує `.cursor/hooks.json` для Cursor Agent stop-hooks. Cursor читає
351
- * project-level config з `.cursor/hooks.json`; hook scripts лишаються спільними
352
- * з Claude Code у `.claude/hooks/`.
418
+ * Синхронізує `.cursor/hooks.json` для Cursor Agent hooks (ADR stop + rtk preToolUse).
419
+ * Cursor читає project-level config з `.cursor/hooks.json`; hook scripts лишаються
420
+ * спільними з Claude Code у `.claude/hooks/`.
353
421
  * @param {string} projectRoot корінь проєкту, куди писати
354
422
  * @param {object} [options] опції merge-у
355
423
  * @param {boolean} [options.includeAdrHook] чи додавати ADR stop-hook entries
424
+ * @param {boolean} [options.includeLocalAiHook] чи додавати rtk preToolUse entry
356
425
  * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
357
426
  */
358
427
  export async function syncCursorHooksConfig(projectRoot, options = {}) {
359
428
  const hooksPath = join(projectRoot, CURSOR_HOOKS_FILE)
360
- if (!options.includeAdrHook && !existsSync(hooksPath)) {
429
+ if (!options.includeAdrHook && !options.includeLocalAiHook && !existsSync(hooksPath)) {
361
430
  return { written: false, path: '' }
362
431
  }
363
432
  const existing = /** @type {CursorHooksConfig | undefined} */ (await readJsonOrUndefined(hooksPath))
@@ -539,6 +608,44 @@ export async function removeOrphanPiExtension(projectRoot) {
539
608
  return { removed: true, path: `${PI_EXTENSIONS_DIR}/${PI_EXTENSION_NAME}` }
540
609
  }
541
610
 
611
+ /** Ім'я файлу rtk pi-extension — той самий шлях, що пише `rtk init --agent pi` (повторна установка ідемпотентна). */
612
+ export const RTK_PI_EXTENSION_FILE = 'rtk.ts'
613
+
614
+ /**
615
+ * Копіює vendored rtk pi-extension `npm/.pi-template/extensions/rtk.ts` у
616
+ * `.pi/extensions/rtk.ts` проєкту-споживача (правило `local-ai`). Файл fully-owned:
617
+ * при кожному sync-у перезаписується. Якщо bundled template відсутній — `{written: false}`.
618
+ * @param {string} projectRoot корінь проєкту-споживача
619
+ * @param {string} bundledPackageRoot корінь установленого `@7n/rules` (із `.pi-template/`)
620
+ * @returns {Promise<{ written: boolean, path: string }>} чи писали та відносний шлях файла
621
+ */
622
+ export async function syncRtkPiExtension(projectRoot, bundledPackageRoot) {
623
+ const srcPath = join(bundledPackageRoot, PI_TEMPLATE_DIR_NAME, 'extensions', RTK_PI_EXTENSION_FILE)
624
+ if (!existsSync(srcPath)) {
625
+ return { written: false, path: '' }
626
+ }
627
+ const destDir = join(projectRoot, PI_EXTENSIONS_DIR)
628
+ await mkdir(destDir, { recursive: true })
629
+ await writeFile(join(destDir, RTK_PI_EXTENSION_FILE), await readFile(srcPath, 'utf8'), 'utf8')
630
+ return { written: true, path: `${PI_EXTENSIONS_DIR}/${RTK_PI_EXTENSION_FILE}` }
631
+ }
632
+
633
+ /**
634
+ * Видаляє `.pi/extensions/rtk.ts` з проєкту-споживача. Викликається, коли правило
635
+ * `local-ai` вимкнено у `.n-rules.json` (симетрично до cleanup-у hook-записів).
636
+ * Шлях спільний з `rtk init --agent pi`, тож прибереться і встановлений вручну файл.
637
+ * @param {string} projectRoot корінь проєкту-споживача
638
+ * @returns {Promise<{ removed: boolean, path: string }>} чи було щось видалено та відносний шлях
639
+ */
640
+ export async function removeOrphanRtkPiExtension(projectRoot) {
641
+ const extPath = join(projectRoot, PI_EXTENSIONS_DIR, RTK_PI_EXTENSION_FILE)
642
+ if (!existsSync(extPath)) {
643
+ return { removed: false, path: '' }
644
+ }
645
+ await rm(extPath, { force: true })
646
+ return { removed: true, path: `${PI_EXTENSIONS_DIR}/${RTK_PI_EXTENSION_FILE}` }
647
+ }
648
+
542
649
  /**
543
650
  * Повертає змістовні (не коментар, не порожній) рядки з text-фрагмента `.gitignore`.
544
651
  * @param {string} raw вміст snippet-файлу
@@ -627,36 +734,30 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
627
734
  * @param {string} options.projectRoot корінь проєкту-споживача
628
735
  * @param {string} options.bundledPackageRoot корінь установленого `@7n/rules`
629
736
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-rules.json` `claude-config`)
630
- * @param {string[]} [options.rules] список увімкнених правил із `.n-rules.json` — впливає на ADR Stop-hook (`adr`)
631
- * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, adrHookLib: string[], gitignoreAdr: boolean, piExtension: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore`/pi-extension, перелік lib-файлів і список slash-команд
737
+ * @param {string[]} [options.rules] список увімкнених правил із `.n-rules.json` — впливає на ADR Stop-hook (`adr`) і rtk hooks (`local-ai`)
738
+ * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, adrHookLib: string[], gitignoreAdr: boolean, piExtension: boolean, rtkPiExtension: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore`/pi-extension(s), перелік lib-файлів і список slash-команд
632
739
  */
633
740
  export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
741
+ const noop = {
742
+ settings: false,
743
+ cursorHooks: false,
744
+ commands: [],
745
+ adrHook: false,
746
+ adrNormalizeHook: false,
747
+ adrHookLib: [],
748
+ gitignoreAdr: false,
749
+ piExtension: false,
750
+ rtkPiExtension: false
751
+ }
634
752
  if (!enabled) {
635
- return {
636
- settings: false,
637
- cursorHooks: false,
638
- commands: [],
639
- adrHook: false,
640
- adrNormalizeHook: false,
641
- adrHookLib: [],
642
- gitignoreAdr: false,
643
- piExtension: false
644
- }
753
+ return noop
645
754
  }
646
755
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
647
756
  if (!existsSync(templateDir)) {
648
- return {
649
- settings: false,
650
- cursorHooks: false,
651
- commands: [],
652
- adrHook: false,
653
- adrNormalizeHook: false,
654
- adrHookLib: [],
655
- gitignoreAdr: false,
656
- piExtension: false
657
- }
757
+ return noop
658
758
  }
659
759
  const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
760
+ const includeLocalAiHook = Array.isArray(rules) && rules.includes('local-ai')
660
761
  const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
661
762
  const adrNormalizeHook = includeAdrHook
662
763
  ? await syncAdrNormalizeHookScript(projectRoot, templateDir)
@@ -676,8 +777,15 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
676
777
  const removed = await removeOrphanPiExtension(projectRoot)
677
778
  piExtension = { written: false, path: removed.path }
678
779
  }
679
- const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
680
- const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook })
780
+ let rtkPiExtension
781
+ if (includeLocalAiHook) {
782
+ rtkPiExtension = await syncRtkPiExtension(projectRoot, bundledPackageRoot)
783
+ } else {
784
+ const removed = await removeOrphanRtkPiExtension(projectRoot)
785
+ rtkPiExtension = { written: false, path: removed.path }
786
+ }
787
+ const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook, includeLocalAiHook })
788
+ const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook, includeLocalAiHook })
681
789
  const commands = await syncClaudeCommands(projectRoot, templateDir)
682
790
  return {
683
791
  settings: settings.written,
@@ -687,6 +795,7 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
687
795
  adrNormalizeHook: adrNormalizeHook.written,
688
796
  adrHookLib: adrHookLibEntries.map(e => e.path),
689
797
  gitignoreAdr: gitignoreAdr.written,
690
- piExtension: piExtension.written
798
+ piExtension: piExtension.written,
799
+ rtkPiExtension: rtkPiExtension.written
691
800
  }
692
801
  }