@nitra/cursor 1.37.0 → 1.39.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 (79) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -0
  3. package/bin/n-cursor.js +7 -1
  4. package/package.json +1 -1
  5. package/rules/abie/meta.json +1 -0
  6. package/rules/adr/meta.json +1 -0
  7. package/rules/bun/meta.json +1 -0
  8. package/rules/capacitor/meta.json +1 -0
  9. package/rules/changelog/meta.json +1 -0
  10. package/rules/ci4/meta.json +1 -0
  11. package/rules/docker/meta.json +1 -0
  12. package/rules/efes/meta.json +1 -0
  13. package/rules/feedback/meta.json +1 -0
  14. package/rules/ga/meta.json +1 -0
  15. package/rules/graphql/meta.json +1 -0
  16. package/rules/hasura/meta.json +1 -0
  17. package/rules/image-avif/meta.json +1 -0
  18. package/rules/image-compress/meta.json +1 -0
  19. package/rules/js-bun-db/meta.json +1 -0
  20. package/rules/js-bun-redis/meta.json +1 -0
  21. package/rules/js-lint/meta.json +1 -0
  22. package/rules/js-mssql/meta.json +1 -0
  23. package/rules/js-run/meta.json +1 -0
  24. package/rules/k8s/meta.json +1 -0
  25. package/rules/nginx-default-tpl/meta.json +1 -0
  26. package/rules/npm-module/js/rule_meta.mjs +63 -0
  27. package/rules/npm-module/meta.json +1 -0
  28. package/rules/php/meta.json +1 -0
  29. package/rules/rego/meta.json +1 -0
  30. package/rules/release/meta.json +1 -0
  31. package/rules/rust/meta.json +1 -0
  32. package/rules/security/meta.json +1 -0
  33. package/rules/style-lint/meta.json +1 -0
  34. package/rules/tauri/meta.json +1 -0
  35. package/rules/test/meta.json +1 -0
  36. package/rules/text/meta.json +1 -0
  37. package/rules/vue/meta.json +1 -0
  38. package/rules/worktree/fix.mjs +19 -0
  39. package/rules/worktree/meta.json +1 -0
  40. package/rules/worktree/worktree.mdc +34 -0
  41. package/schemas/rule-meta.json +39 -0
  42. package/schemas/v8r-catalog.json +5 -0
  43. package/scripts/auto-rules.mjs +151 -449
  44. package/scripts/lib/rule-meta-helpers.mjs +103 -0
  45. package/scripts/lib/rule-meta.mjs +66 -0
  46. package/scripts/lib/rule-predicates.mjs +147 -0
  47. package/scripts/lib/worktree.mjs +73 -0
  48. package/scripts/worktree-cli.mjs +200 -0
  49. package/skills/worktree/SKILL.md +38 -0
  50. package/skills/worktree/meta.json +1 -0
  51. package/rules/abie/auto.md +0 -1
  52. package/rules/adr/auto.md +0 -1
  53. package/rules/bun/auto.md +0 -1
  54. package/rules/capacitor/auto.md +0 -1
  55. package/rules/changelog/auto.md +0 -1
  56. package/rules/docker/auto.md +0 -1
  57. package/rules/efes/auto.md +0 -1
  58. package/rules/ga/auto.md +0 -1
  59. package/rules/graphql/auto.md +0 -1
  60. package/rules/hasura/auto.md +0 -1
  61. package/rules/image-avif/auto.md +0 -1
  62. package/rules/image-compress/auto.md +0 -1
  63. package/rules/js-bun-db/auto.md +0 -1
  64. package/rules/js-bun-redis/auto.md +0 -1
  65. package/rules/js-lint/auto.md +0 -1
  66. package/rules/js-mssql/auto.md +0 -1
  67. package/rules/js-run/auto.md +0 -1
  68. package/rules/k8s/auto.md +0 -1
  69. package/rules/nginx-default-tpl/auto.md +0 -1
  70. package/rules/npm-module/auto.md +0 -1
  71. package/rules/php/auto.md +0 -1
  72. package/rules/rego/auto.md +0 -1
  73. package/rules/rust/auto.md +0 -1
  74. package/rules/security/auto.md +0 -1
  75. package/rules/style-lint/auto.md +0 -1
  76. package/rules/tauri/auto.md +0 -1
  77. package/rules/test/auto.md +0 -1
  78. package/rules/text/auto.md +0 -1
  79. package/rules/vue/auto.md +0 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Чисті хелпери конфігу/репо для автодетекту правил: id-міграції, нормалізація
3
+ * списків, repository URL, monorepo-детект.
4
+ *
5
+ * Винесені з `auto-rules.mjs`, щоб `rule-predicates.mjs` міг використати
6
+ * `getRepositoryUrl` без циклу імпортів. `auto-rules.mjs` пізніше ре-експортує їх звідси.
7
+ */
8
+
9
+ /**
10
+ * Карта міграції застарілих rule-id у `.n-cursor.json` на актуальні.
11
+ * Застосовується автоматично при читанні конфігу (як для `rules`, так і для `disable-rules`).
12
+ * Приклад: `image` → `image-compress` + `image-avif` (правило розщеплене у 1.8.197).
13
+ */
14
+ export const RULE_MIGRATIONS = Object.freeze(
15
+ /** @type {Record<string, readonly string[]>} */ ({
16
+ image: Object.freeze(['image-compress', 'image-avif'])
17
+ })
18
+ )
19
+
20
+ /**
21
+ * Розгортає застарілі rule-id у списку згідно з `RULE_MIGRATIONS`. Зберігає порядок,
22
+ * дедуплікує. Чистий хелпер: не мутує вхід, не логує.
23
+ * @param {string[]} ids нормалізований список id (як з `normalizeIdList`)
24
+ * @returns {string[]} список з legacy-id, заміненими на нові; решта без змін
25
+ */
26
+ export function migrateRuleIds(ids) {
27
+ /** @type {string[]} */
28
+ const out = []
29
+ for (const id of ids) {
30
+ const replacement = Object.hasOwn(RULE_MIGRATIONS, id) ? RULE_MIGRATIONS[id] : [id]
31
+ for (const newId of replacement) {
32
+ if (!out.includes(newId)) out.push(newId)
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * Повертає лише ті legacy rule-id зі списку, для яких є запис у `RULE_MIGRATIONS`.
40
+ * Використовується для людинозрозумілого логування міграції при синхронізації CLI.
41
+ * @param {string[]} ids нормалізований список id
42
+ * @returns {string[]} legacy id, які потребуватимуть заміни у `migrateRuleIds`
43
+ */
44
+ export function detectLegacyRuleIds(ids) {
45
+ return ids.filter(id => Object.hasOwn(RULE_MIGRATIONS, id))
46
+ }
47
+
48
+ /**
49
+ * Нормалізує список ідентифікаторів (trim + lowercase + унікальність збереженням порядку).
50
+ * @param {unknown} value вихідне значення з `.n-cursor.json`
51
+ * @returns {string[]} масив id у нормалізованому вигляді
52
+ */
53
+ export function normalizeIdList(value) {
54
+ if (!Array.isArray(value)) {
55
+ return []
56
+ }
57
+ const out = []
58
+ for (const item of value) {
59
+ const normalized = String(item).trim().toLowerCase()
60
+ if (normalized && !out.includes(normalized)) {
61
+ out.push(normalized)
62
+ }
63
+ }
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * Повертає URL репозиторію з package.json (`repository` може бути рядком або обʼєктом).
69
+ * @param {unknown} repository значення `packageJson.repository`
70
+ * @returns {string | null} URL або null
71
+ */
72
+ export function getRepositoryUrl(repository) {
73
+ if (typeof repository === 'string') {
74
+ return repository
75
+ }
76
+ if (repository && typeof repository === 'object' && !Array.isArray(repository)) {
77
+ const url = /** @type {Record<string, unknown>} */ (repository).url
78
+ if (typeof url === 'string') {
79
+ return url
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Чи package.json виглядає як монорепо (поле `workspaces`).
87
+ * @param {unknown} packageJson кореневий package.json як JS-обʼєкт
88
+ * @returns {boolean} true, якщо оголошено workspaces
89
+ */
90
+ export function isMonorepoPackage(packageJson) {
91
+ if (packageJson === null || typeof packageJson !== 'object' || Array.isArray(packageJson)) {
92
+ return false
93
+ }
94
+ const workspaces = /** @type {Record<string, unknown>} */ (packageJson).workspaces
95
+ if (Array.isArray(workspaces)) {
96
+ return workspaces.length > 0
97
+ }
98
+ if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
99
+ const packages = /** @type {Record<string, unknown>} */ (workspaces).packages
100
+ return Array.isArray(packages) && packages.length > 0
101
+ }
102
+ return false
103
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Парсер метаданих правила з `npm/rules/<id>/meta.json` (data-driven автодетект).
3
+ *
4
+ * `meta.json.auto` має один із чотирьох видів:
5
+ * - `"завжди"` → always-on;
6
+ * - `["rule", …]` → активується, коли всі правила-залежності виявлені;
7
+ * - `{ "glob": "<pat>" | [<pat>] }` → наявність файлів/каталогів за glob (OR);
8
+ * - `{ "predicate": "<name>", "arg"? }` → незводимий предикат із реєстру `rule-predicates.mjs`.
9
+ *
10
+ * Поля `worktree` правило НЕ має (це скілова вісь). Дзеркало `skill-meta.mjs`.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ /** Літерал безумовної активації (українською, як у скілах). */
16
+ export const RULE_ALWAYS = 'завжди'
17
+
18
+ /**
19
+ * @typedef {{ always: true } | { rules: string[] } | { glob: string[] } | { predicate: string, arg: unknown }} RuleAutoSpec
20
+ */
21
+
22
+ /**
23
+ * Нормалізує значення `meta.json.auto` у дискриміновану форму.
24
+ * @param {unknown} value значення поля `auto`
25
+ * @returns {RuleAutoSpec | null} `null` — формат не розпізнано (= opt-in)
26
+ */
27
+ export function parseRuleAutoSpec(value) {
28
+ if (value === RULE_ALWAYS) return { always: true }
29
+
30
+ if (Array.isArray(value)) {
31
+ const rules = value.map(s => String(s).trim()).filter(s => s.length > 0)
32
+ return rules.length > 0 ? { rules } : null
33
+ }
34
+
35
+ if (value !== null && typeof value === 'object') {
36
+ const obj = /** @type {Record<string, unknown>} */ (value)
37
+ if ('glob' in obj) {
38
+ const raw = obj.glob
39
+ const globs = (Array.isArray(raw) ? raw : [raw]).filter(g => typeof g === 'string' && g.length > 0)
40
+ return globs.length > 0 ? { glob: /** @type {string[]} */ (globs) } : null
41
+ }
42
+ if ('predicate' in obj) {
43
+ return typeof obj.predicate === 'string' && obj.predicate.length > 0
44
+ ? { predicate: obj.predicate, arg: obj.arg }
45
+ : null
46
+ }
47
+ }
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Читає й парсить `meta.json` одного правила.
53
+ * @param {string} ruleDir абсолютний шлях до каталогу правила
54
+ * @returns {Record<string, unknown> | null} обʼєкт або `null` (немає файлу / невалідний JSON / не-обʼєкт)
55
+ */
56
+ export function readRuleMetaRaw(ruleDir) {
57
+ const metaPath = join(ruleDir, 'meta.json')
58
+ if (!existsSync(metaPath)) return null
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(metaPath, 'utf8'))
61
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
62
+ return /** @type {Record<string, unknown>} */ (parsed)
63
+ } catch {
64
+ return null
65
+ }
66
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Реєстр незводимих до даних предикатів автодетекту правил.
3
+ *
4
+ * Прості умови (наявність файлів) живуть як `glob` у `meta.json`; ці предикати —
5
+ * для умов, що вимагають парсингу залежностей, сканування вмісту source чи URL repo.
6
+ * Декларація «який предикат + аргумент» — у `meta.json.auto.predicate`; тут — реалізація.
7
+ *
8
+ * Сигнатури неоднорідні (одні беруть `facts`, інші — `cwd`/`packageJson`), бо предикати
9
+ * читають різні джерела; виклик диспетчиться в `auto-rules.mjs` за іменем предиката.
10
+ */
11
+ import { readdir, readFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ import { getRepositoryUrl } from './rule-meta-helpers.mjs'
15
+
16
+ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
17
+
18
+ /**
19
+ * Чи package.json дерева містить будь-який із зазначених пакетів у dependencies.
20
+ * @param {string} root корінь репо
21
+ * @param {string[]} keys імена пакетів
22
+ * @returns {Promise<boolean>} true, якщо знайдено хоч один
23
+ */
24
+ async function anyDepInTree(root, keys) {
25
+ const wanted = new Set(keys)
26
+ let found = false
27
+ /** @param {string} dir каталог обходу @returns {Promise<void>} */
28
+ async function walk(dir) {
29
+ if (found) return
30
+ let entries
31
+ try {
32
+ entries = await readdir(dir, { withFileTypes: true })
33
+ } catch {
34
+ return
35
+ }
36
+ for (const entry of entries) {
37
+ if (found) return
38
+ const abs = join(dir, entry.name)
39
+ if (entry.isDirectory()) {
40
+ if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
41
+ } else if (entry.isFile() && entry.name === 'package.json') {
42
+ try {
43
+ const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
44
+ if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
45
+ for (const k of wanted) if (Object.hasOwn(deps, k)) found = true
46
+ }
47
+ } catch {
48
+ /* ігноруємо пошкоджені package.json */
49
+ }
50
+ }
51
+ }
52
+ }
53
+ await walk(root)
54
+ return found
55
+ }
56
+
57
+ /**
58
+ * Чи існує вкладений (не кореневий) package.json без `vite` у devDependencies.
59
+ * @param {string} root корінь репо
60
+ * @returns {Promise<boolean>} true, якщо знайдено
61
+ */
62
+ async function nestedWithoutVite(root) {
63
+ const rootPkg = join(root, 'package.json')
64
+ let result = false
65
+ /** @param {string} dir каталог @returns {Promise<void>} */
66
+ async function walk(dir) {
67
+ if (result) return
68
+ let entries
69
+ try {
70
+ entries = await readdir(dir, { withFileTypes: true })
71
+ } catch {
72
+ return
73
+ }
74
+ for (const entry of entries) {
75
+ if (result) return
76
+ const abs = join(dir, entry.name)
77
+ if (entry.isDirectory()) {
78
+ if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
79
+ } else if (entry.isFile() && entry.name === 'package.json' && abs !== rootPkg) {
80
+ try {
81
+ const dev = JSON.parse(await readFile(abs, 'utf8'))?.devDependencies
82
+ const hasVite = dev && typeof dev === 'object' && !Array.isArray(dev) && Object.hasOwn(dev, 'vite')
83
+ if (!hasVite) result = true
84
+ } catch {
85
+ /* пошкоджений package.json не вважаємо vite-проєктом */
86
+ }
87
+ }
88
+ }
89
+ }
90
+ await walk(root)
91
+ return result
92
+ }
93
+
94
+ /** Реєстр предикатів: імʼя → реалізація. Виклик за `meta.json.auto.predicate`. */
95
+ export const RULE_PREDICATES = {
96
+ /**
97
+ * @param {unknown} packageJson кореневий package.json
98
+ * @param {string} arg підрядок-маркер URL
99
+ * @returns {boolean} true, якщо repository.url містить маркер
100
+ */
101
+ repoUrlMarker(packageJson, arg) {
102
+ const url = getRepositoryUrl(
103
+ packageJson && typeof packageJson === 'object' && !Array.isArray(packageJson)
104
+ ? /** @type {Record<string, unknown>} */ (packageJson).repository
105
+ : null
106
+ )
107
+ return typeof url === 'string' && url.toLowerCase().includes(String(arg).toLowerCase())
108
+ },
109
+ /**
110
+ * @param {string} cwd корінь репо
111
+ * @param {string[]} arg імена пакетів
112
+ * @returns {Promise<boolean>} true, якщо будь-який пакет у dependencies дерева
113
+ */
114
+ depInAnyPackageJson(cwd, arg) {
115
+ return anyDepInTree(cwd, Array.isArray(arg) ? arg : [])
116
+ },
117
+ /**
118
+ * @param {{ hasGqlTaggedTemplates: boolean }} facts факти
119
+ * @returns {boolean} true, якщо є gql-літерал
120
+ */
121
+ gqlTaggedTemplate(facts) {
122
+ return facts.hasGqlTaggedTemplates === true
123
+ },
124
+ /**
125
+ * @param {{ hasHasuraConfig: boolean }} facts факти
126
+ * @returns {boolean} true, якщо config.yaml із маркером
127
+ */
128
+ hasuraConfigMarker(facts) {
129
+ return facts.hasHasuraConfig === true
130
+ },
131
+ /**
132
+ * @param {string} cwd корінь репо
133
+ * @param {{ hasBunSqlImport: boolean }} facts факти
134
+ * @returns {Promise<boolean>} true, якщо deps pg/pg-format/mysql2 або import sql з bun
135
+ */
136
+ async jsBunDbSignal(cwd, facts) {
137
+ if (facts.hasBunSqlImport === true) return true
138
+ return anyDepInTree(cwd, ['pg', 'pg-format', 'mysql2'])
139
+ },
140
+ /**
141
+ * @param {string} cwd корінь репо
142
+ * @returns {Promise<boolean>} true, якщо вкладений package.json без vite
143
+ */
144
+ nestedPackageWithoutVite(cwd) {
145
+ return nestedWithoutVite(cwd)
146
+ }
147
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Чиста логіка worktree-tool `n-cursor worktree` (без git/fs side-effects).
3
+ *
4
+ * Тут — детерміновані, тестовані без git функції:
5
+ * - `sanitizeBranch` — імʼя гілки → безпечне імʼя каталогу/файла (слеш та інші
6
+ * небезпечні для шляху символи → дефіс), щоб структура `.worktrees/` лишалась пласкою;
7
+ * - `worktreePaths` — шляхи checkout і файла-опису поруч;
8
+ * - `buildDescription` — текст інвентарного `.worktrees/<name>.md` за конвенцією worktree.mdc;
9
+ * - `findOrphanDescFiles` — `.md`-описи без зареєстрованого worktree (для `prune`).
10
+ *
11
+ * Оркестрація (виклики git, запис файлів, argv) — у `npm/scripts/worktree-cli.mjs`.
12
+ */
13
+ import { basename, join } from 'node:path'
14
+
15
+ /** Символи, безпечні для імені каталогу/файла; решта → дефіс. */
16
+ const UNSAFE_PATH_CHARS_RE = /[^a-zA-Z0-9._-]+/gu
17
+
18
+ /**
19
+ * Перетворює імʼя git-гілки на безпечне імʼя каталогу/файла для `.worktrees/`.
20
+ * @param {string} branch імʼя git-гілки (наприклад `feat/skill-meta`)
21
+ * @returns {string} пласке імʼя (наприклад `feat-skill-meta`)
22
+ */
23
+ export function sanitizeBranch(branch) {
24
+ if (typeof branch !== 'string' || branch.trim() === '') {
25
+ throw new Error('worktree: імʼя гілки обовʼязкове')
26
+ }
27
+ const sanitized = branch.trim().replace(UNSAFE_PATH_CHARS_RE, '-').replace(/^-+|-+$/gu, '')
28
+ if (sanitized === '') {
29
+ throw new Error(`worktree: імʼя гілки "${branch}" не містить допустимих символів`)
30
+ }
31
+ return sanitized
32
+ }
33
+
34
+ /**
35
+ * Детерміновані шляхи checkout і файла-опису для гілки.
36
+ * @param {string} repoRoot абсолютний корінь репозиторію
37
+ * @param {string} branch імʼя git-гілки
38
+ * @returns {{ checkout: string, descFile: string }} абсолютні шляхи
39
+ */
40
+ export function worktreePaths(repoRoot, branch) {
41
+ const name = sanitizeBranch(branch)
42
+ const dir = join(repoRoot, '.worktrees')
43
+ return { checkout: join(dir, name), descFile: join(dir, `${name}.md`) }
44
+ }
45
+
46
+ /**
47
+ * Текст інвентарного файла-опису worktree.
48
+ * @param {{ branch: string, task: string, baseCommit: string, date: string }} params поля опису
49
+ * @returns {string} markdown-вміст `.worktrees/<name>.md`
50
+ */
51
+ export function buildDescription({ branch, task, baseCommit, date }) {
52
+ return [
53
+ `# ${branch}`,
54
+ '',
55
+ `**Задача:** ${task}`,
56
+ `**Дата:** ${date}`,
57
+ `**База (коміт):** ${baseCommit}`,
58
+ '',
59
+ 'Прибрати: ' + '`' + `npx @nitra/cursor worktree remove ${branch}` + '`',
60
+ ''
61
+ ].join('\n')
62
+ }
63
+
64
+ /**
65
+ * `.md`-описи без відповідного зареєстрованого worktree-checkout.
66
+ * @param {string[]} descFiles абсолютні шляхи `.worktrees/*.md`
67
+ * @param {string[]} registeredCheckouts абсолютні шляхи зареєстрованих worktree-checkout
68
+ * @returns {string[]} осиротілі `.md` (підмножина `descFiles`)
69
+ */
70
+ export function findOrphanDescFiles(descFiles, registeredCheckouts) {
71
+ const checkoutBasenames = new Set(registeredCheckouts.map(c => basename(c)))
72
+ return descFiles.filter(md => !checkoutBasenames.has(basename(md).replace(/\.md$/u, '')))
73
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * CLI-оркестратор worktree-tool `n-cursor worktree` (виконавець конвенції `.worktrees/`).
3
+ *
4
+ * Підкоманди:
5
+ * add <branch> "<опис>" — git worktree add .worktrees/<sanit> -b <branch> (від HEAD) + .md-опис
6
+ * remove <branch> [--force] — прибрати checkout + .md (гілку лишає)
7
+ * list — git worktree list + вміст .md-описів
8
+ * prune — git worktree prune + видалити осиротілі .md
9
+ *
10
+ * Чисті функції (санітизація, шляхи, текст опису, осиротілі) — у `lib/worktree.mjs`.
11
+ * Тут лише git-виклики, запис файлів, парсинг argv і звіт.
12
+ */
13
+ import { spawnSync } from 'node:child_process'
14
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { cwd as processCwd } from 'node:process'
17
+
18
+ import { buildDescription, findOrphanDescFiles, worktreePaths } from './lib/worktree.mjs'
19
+
20
+ const USAGE = [
21
+ 'Usage:',
22
+ ' npx @nitra/cursor worktree add <branch> "<опис>"',
23
+ ' npx @nitra/cursor worktree remove <branch> [--force]',
24
+ ' npx @nitra/cursor worktree list',
25
+ ' npx @nitra/cursor worktree prune'
26
+ ].join('\n')
27
+
28
+ /**
29
+ * Запускає git, повертає { status, stdout, stderr }.
30
+ * @param {string[]} args аргументи git
31
+ * @param {string} cwd робочий каталог
32
+ * @returns {{ status: number, stdout: string, stderr: string }} результат
33
+ */
34
+ function git(args, cwd) {
35
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
36
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
37
+ }
38
+
39
+ /**
40
+ * Поточна дата YYYY-MM-DD (ін'єкція через ctx.now для тестів).
41
+ * @param {() => Date} now фабрика дати
42
+ * @returns {string} дата у форматі YYYY-MM-DD
43
+ */
44
+ function today(now) {
45
+ return now().toISOString().slice(0, 10)
46
+ }
47
+
48
+ /**
49
+ * Реєстровані worktree-checkout (абсолютні шляхи) з `git worktree list --porcelain`.
50
+ * @param {string} cwd корінь репо
51
+ * @returns {string[]} абсолютні шляхи checkout
52
+ */
53
+ function listRegisteredCheckouts(cwd) {
54
+ return git(['worktree', 'list', '--porcelain'], cwd)
55
+ .stdout.split('\n')
56
+ .filter(line => line.startsWith('worktree '))
57
+ .map(line => line.slice('worktree '.length).trim())
58
+ }
59
+
60
+ /**
61
+ * Абсолютні шляхи `.worktrees/*.md`.
62
+ * @param {string} cwd корінь репо
63
+ * @returns {string[]} шляхи файлів-описів
64
+ */
65
+ function listDescFiles(cwd) {
66
+ const dir = join(cwd, '.worktrees')
67
+ if (!existsSync(dir)) return []
68
+ return readdirSync(dir)
69
+ .filter(n => n.endsWith('.md'))
70
+ .map(n => join(dir, n))
71
+ }
72
+
73
+ /**
74
+ * add: створити worktree від HEAD + .md-опис.
75
+ * @param {string[]} rest [branch, ...descParts]
76
+ * @param {{ cwd: string, log: Function, logError: Function, now: () => Date }} ctx контекст
77
+ * @returns {number} exit code
78
+ */
79
+ function cmdAdd(rest, ctx) {
80
+ const [branch, ...descParts] = rest
81
+ const task = descParts.join(' ').trim()
82
+ if (!branch) {
83
+ ctx.logError('worktree add: потрібне імʼя гілки')
84
+ ctx.logError(USAGE)
85
+ return 1
86
+ }
87
+ if (!task) {
88
+ ctx.logError('worktree add: опис обовʼязковий — `worktree add <branch> "<опис>"`')
89
+ return 1
90
+ }
91
+ let paths
92
+ try {
93
+ paths = worktreePaths(ctx.cwd, branch)
94
+ } catch (error) {
95
+ ctx.logError(error.message)
96
+ return 1
97
+ }
98
+ const added = git(['worktree', 'add', paths.checkout, '-b', branch], ctx.cwd)
99
+ if (added.status !== 0) {
100
+ ctx.logError(`worktree add не вдався: ${added.stderr.trim()}`)
101
+ return 1
102
+ }
103
+ const baseCommit = git(['rev-parse', '--short', 'HEAD'], ctx.cwd).stdout.trim()
104
+ const md = buildDescription({ branch, task, baseCommit, date: today(ctx.now) })
105
+ writeFileSync(paths.descFile, md, 'utf8')
106
+ ctx.log(`✅ worktree: ${paths.checkout}`)
107
+ ctx.log(` опис: ${paths.descFile}`)
108
+ return 0
109
+ }
110
+
111
+ /**
112
+ * remove: прибрати checkout + .md (гілку лишає).
113
+ * @param {string[]} rest [branch, ...flags]
114
+ * @param {{ cwd: string, log: Function, logError: Function }} ctx контекст
115
+ * @returns {number} exit code
116
+ */
117
+ function cmdRemove(rest, ctx) {
118
+ const branch = rest.find(a => !a.startsWith('--'))
119
+ const force = rest.includes('--force')
120
+ if (!branch) {
121
+ ctx.logError('worktree remove: потрібне імʼя гілки')
122
+ return 1
123
+ }
124
+ let paths
125
+ try {
126
+ paths = worktreePaths(ctx.cwd, branch)
127
+ } catch (error) {
128
+ ctx.logError(error.message)
129
+ return 1
130
+ }
131
+ const args = ['worktree', 'remove', paths.checkout]
132
+ if (force) args.push('--force')
133
+ const removed = git(args, ctx.cwd)
134
+ if (removed.status !== 0) {
135
+ ctx.logError(`worktree remove не вдався: ${removed.stderr.trim()} (спробуй --force, якщо дерево брудне)`)
136
+ return 1
137
+ }
138
+ if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
139
+ ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
140
+ return 0
141
+ }
142
+
143
+ /**
144
+ * list: git worktree list + вміст .md-описів.
145
+ * @param {{ cwd: string, log: Function }} ctx контекст
146
+ * @returns {number} exit code
147
+ */
148
+ function cmdList(ctx) {
149
+ ctx.log(git(['worktree', 'list'], ctx.cwd).stdout.trimEnd())
150
+ for (const md of listDescFiles(ctx.cwd)) {
151
+ ctx.log(`\n--- ${md} ---`)
152
+ ctx.log(readFileSync(md, 'utf8').trimEnd())
153
+ }
154
+ return 0
155
+ }
156
+
157
+ /**
158
+ * prune: git worktree prune + видалити осиротілі .md.
159
+ * @param {{ cwd: string, log: Function }} ctx контекст
160
+ * @returns {number} exit code
161
+ */
162
+ function cmdPrune(ctx) {
163
+ git(['worktree', 'prune'], ctx.cwd)
164
+ const orphans = findOrphanDescFiles(listDescFiles(ctx.cwd), listRegisteredCheckouts(ctx.cwd))
165
+ for (const md of orphans) {
166
+ rmSync(md, { force: true })
167
+ ctx.log(`🧹 видалено осиротілий опис: ${md}`)
168
+ }
169
+ ctx.log(`prune завершено (осиротілих описів: ${orphans.length})`)
170
+ return 0
171
+ }
172
+
173
+ /**
174
+ * Точка входу підкоманди worktree.
175
+ * @param {string[]} argv аргументи після `worktree`
176
+ * @param {{ cwd?: string, log?: Function, logError?: Function, now?: () => Date }} [options] ін'єкція для тестів
177
+ * @returns {Promise<number>} exit code
178
+ */
179
+ export function runWorktreeCli(argv, options = {}) {
180
+ const ctx = {
181
+ cwd: options.cwd ?? processCwd(),
182
+ log: options.log ?? (line => console.log(line)),
183
+ logError: options.logError ?? (line => console.error(line)),
184
+ now: options.now ?? (() => new Date())
185
+ }
186
+ const [sub, ...rest] = argv
187
+ switch (sub) {
188
+ case 'add':
189
+ return Promise.resolve(cmdAdd(rest, ctx))
190
+ case 'remove':
191
+ return Promise.resolve(cmdRemove(rest, ctx))
192
+ case 'list':
193
+ return Promise.resolve(cmdList(ctx))
194
+ case 'prune':
195
+ return Promise.resolve(cmdPrune(ctx))
196
+ default:
197
+ ctx.logError(USAGE)
198
+ return Promise.resolve(1)
199
+ }
200
+ }
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: worktree
3
+ description: >-
4
+ Створення та керування git-worktree через n-cursor worktree CLI: ізольований
5
+ workspace у .worktrees/<branch>/ з інвентарним файлом-описом
6
+ ---
7
+
8
+ # worktree — ізольований workspace через CLI
9
+
10
+ Для роботи в окремому git-worktree використовуй CLI `n-cursor worktree` — він
11
+ однаковий у Claude і Cursor, кладе worktree у `.worktrees/` (gitignored) і сам
12
+ створює інвентарний файл-опис поруч.
13
+
14
+ ## Команди
15
+
16
+ - Створити (опис **обовʼязковий**):
17
+ `npx @nitra/cursor worktree add <branch> "<навіщо цей worktree>"`
18
+ - Список активних з описами:
19
+ `npx @nitra/cursor worktree list`
20
+ - Прибрати (гілку лишає; `--force` для брудного дерева):
21
+ `npx @nitra/cursor worktree remove <branch> [--force]`
22
+ - Прибрати осиротілі описи / метадані:
23
+ `npx @nitra/cursor worktree prune`
24
+
25
+ ## Приклад
26
+
27
+ ```bash
28
+ npx @nitra/cursor worktree add feat/skill-meta "реалізація Spec A: meta.json"
29
+ cd .worktrees/feat-skill-meta
30
+ # … робота в ізоляції …
31
+ cd -
32
+ npx @nitra/cursor worktree remove feat/skill-meta
33
+ ```
34
+
35
+ Слеш у гілці перетворюється на дефіс для пласкої структури: `feat/skill-meta`
36
+ → `.worktrees/feat-skill-meta/`. Git-гілка лишається `feat/skill-meta`.
37
+
38
+ Конвенція й заборони (де НЕ створювати worktree) — `.cursor/rules/n-worktree.mdc`.
@@ -0,0 +1 @@
1
+ { "auto": "завжди", "worktree": false }
@@ -1 +0,0 @@
1
- якщо в кореневому package.json в секції "repository" присутній текст "<https://github.com/abinbevefes/**/>"
package/rules/adr/auto.md DELETED
@@ -1 +0,0 @@
1
- завжди
package/rules/bun/auto.md DELETED
@@ -1 +0,0 @@
1
- якщо в корені проекту є package.json
@@ -1 +0,0 @@
1
- якщо в проекті є хоч один файл capacitor.config.json
@@ -1 +0,0 @@
1
- [bun]
@@ -1 +0,0 @@
1
- якщо в проекті є хоч один Dockerfile
@@ -1 +0,0 @@
1
- якщо в кореневому package.json в секції "repository" присутній текст "<https://github.com/efes-cloud/**/>"
package/rules/ga/auto.md DELETED
@@ -1 +0,0 @@
1
- якщо присутня директорія .github/workflows
@@ -1 +0,0 @@
1
- якщо хоч в одному js або vue файлі присутній gql` темплейт літерал
@@ -1 +0,0 @@
1
- якщо в директорії присутній config.yaml, який містить рядок `metadata_directory: metadata`
@@ -1 +0,0 @@
1
- [vue, image-compress]
@@ -1 +0,0 @@
1
- [bun]
@@ -1 +0,0 @@
1
- якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")