@nitra/cursor 3.19.0 → 3.21.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 (49) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +1 -1
  2. package/.claude-template/hooks/normalize-decisions.sh +8 -4
  3. package/CHANGELOG.md +33 -0
  4. package/bin/n-cursor.js +53 -0
  5. package/package.json +1 -1
  6. package/rules/adr/adr.mdc +5 -5
  7. package/rules/adr/js/templates/hooks/.gitignore.snippet +1 -0
  8. package/rules/changelog/changelog.mdc +1 -1
  9. package/rules/changelog/js/consistency.mjs +69 -12
  10. package/rules/ci4/ci4.mdc +2 -2
  11. package/rules/docker/docker.mdc +3 -3
  12. package/rules/docker/js/lint.mjs +1 -1
  13. package/rules/docker/lib/docker-hadolint.mjs +27 -55
  14. package/rules/ga/lint/lint.mjs +18 -54
  15. package/rules/js-run/js/runtime.mjs +32 -0
  16. package/rules/js-run/js-run.mdc +6 -0
  17. package/rules/js-run/lib/temporal-scan.mjs +52 -0
  18. package/rules/k8s/lint/lint.mjs +3 -10
  19. package/rules/nginx-default-tpl/js/template.mjs +39 -1
  20. package/rules/nginx-default-tpl/nginx-default-tpl.mdc +3 -1
  21. package/rules/npm-module/js/skill_meta.mjs +12 -0
  22. package/rules/npm-module/npm-module.mdc +1 -1
  23. package/rules/npm-module/policy/npm_publish_yml/target.json +1 -0
  24. package/rules/rego/lint/lint.mjs +10 -55
  25. package/rules/release/change.mjs +34 -5
  26. package/rules/release/lib/change-file.mjs +26 -11
  27. package/rules/text/lint/lint.mjs +11 -40
  28. package/rules/worktree/policy/vscode_settings/target.json +5 -0
  29. package/rules/worktree/policy/vscode_settings/template/settings.json.snippet.json +8 -0
  30. package/rules/worktree/policy/zed_settings/target.json +5 -0
  31. package/rules/worktree/policy/zed_settings/template/settings.json.snippet.json +12 -0
  32. package/rules/worktree/worktree.mdc +52 -0
  33. package/schemas/target.json +5 -0
  34. package/scripts/lib/assert-project-root.mjs +80 -0
  35. package/scripts/lib/ensure-tool.mjs +352 -0
  36. package/scripts/lib/root-notice.mjs +64 -0
  37. package/scripts/lib/run-conftest-batch.mjs +6 -28
  38. package/scripts/lib/run-rule.mjs +61 -5
  39. package/scripts/lib/skill-meta.mjs +16 -2
  40. package/scripts/lib/template.mjs +29 -3
  41. package/scripts/lib/worktree-notice.mjs +121 -73
  42. package/scripts/sync-claude-config.mjs +2 -2
  43. package/skills/fix/SKILL.md +4 -4
  44. package/skills/llm-patch/meta.json +1 -1
  45. package/skills/publish-telegram/meta.json +1 -1
  46. package/skills/start-check/meta.json +1 -1
  47. package/skills/worktree/meta.json +1 -1
  48. package/types/bin/n-cursor.d.ts +1 -1
  49. package/rules/npm-module/policy/npm_publish_yml/npm_publish_yml.rego +0 -87
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Вшивання root-guard preflight у синкнутий `SKILL.md` для скілів, що **мутують
3
+ * проєкт у поточному каталозі**, але виконуються **in-place** (без worktree-
4
+ * ізоляції) — `meta.json` → `requireRoot: true` і `worktree: false`.
5
+ *
6
+ * Worktree-скіли (`worktree: true`) свій root-assert уже мають у worktree-блоці
7
+ * (`worktree-notice.mjs`): корінь worktree = його toplevel. Цей модуль — для
8
+ * не-worktree-кейсу (напр. `n-start-check`, що прогоняє `start` усіх воркспейсів
9
+ * у місці й має стартувати з кореня монорепо).
10
+ *
11
+ * Блок — інструкція агенту, що читає `SKILL.md`; вставляється між стабільними
12
+ * маркерами, ре-синк ідемпотентний: наявний блок замінюється, при `false` —
13
+ * видаляється. Програмний аналог для CLI-команд — `assertCwdIsProjectRoot`.
14
+ */
15
+
16
+ /** Маркер початку root-блоку. */
17
+ export const ROOT_START = '<!-- n-cursor:root:start -->'
18
+ /** Маркер кінця root-блоку. */
19
+ export const ROOT_END = '<!-- n-cursor:root:end -->'
20
+
21
+ /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
22
+ const BLOCK_RE = /\n*<!-- n-cursor:root:start -->[\s\S]*?<!-- n-cursor:root:end -->\n*/u
23
+
24
+ /** Закриття YAML-frontmatter на початку файла. */
25
+ const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u
26
+
27
+ /** Тіло root-guard інструкції. */
28
+ const NOTICE_BODY = `> [!IMPORTANT]
29
+ > **Root-only skill.** Скіл мутує проєкт у поточному каталозі й має запускатися **з кореня репозиторію**.
30
+
31
+ **Крок 0 — preflight (обовʼязковий, перед будь-якими іншими діями).**
32
+
33
+ \`\`\`bash
34
+ pwd
35
+ git rev-parse --show-toplevel
36
+ \`\`\`
37
+
38
+ Якщо \`pwd\` **не** збігається з виводом \`git rev-parse --show-toplevel\` — ти в **піддиректорії**. **STOP**: перейди в корінь (\`cd <toplevel>\`, literal-шлях із виводу) і лише тоді виконуй наступні кроки скіла. Поза git-репо (команда без виводу) — продовжуй (корінь визначити неможливо).`
39
+
40
+ /** Канонічний блок root-інструкції (з маркерами). */
41
+ const BLOCK = `${ROOT_START}\n${NOTICE_BODY}\n${ROOT_END}`
42
+
43
+ /**
44
+ * Вставляє / оновлює / видаляє root-guard блок у вмісті `SKILL.md`.
45
+ * @param {string} content вміст `SKILL.md`
46
+ * @param {boolean} enabled чи має бути блок (`requireRoot && !worktree`)
47
+ * @returns {string} оновлений вміст (ідемпотентно)
48
+ */
49
+ export function injectRootNotice(content, enabled) {
50
+ const hadBlock = content.includes(ROOT_START)
51
+ const withoutBlock = content.replace(BLOCK_RE, '\n\n')
52
+
53
+ if (!enabled) {
54
+ return hadBlock ? withoutBlock : content
55
+ }
56
+
57
+ const fm = withoutBlock.match(FRONTMATTER_RE)
58
+ if (fm) {
59
+ const head = fm[1]
60
+ const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
61
+ return `${head}\n${BLOCK}\n\n${rest}`
62
+ }
63
+ return `${BLOCK}\n\n${withoutBlock.replace(/^\n+/u, '')}`
64
+ }
@@ -4,15 +4,13 @@
4
4
  * пер-документні правила винесені у `npm/policy/<rule>/<name>/` як rego-полісі
5
5
  * (Rego-authoritative). JS у `check-*.mjs` робить cross-file частину (walking
6
6
  * дерева, парність, kustomize-резолюція), а пер-документне валідаційне ядро
7
- * делегується сюди — один спавн `conftest` на (`namespace`, `policyDir`),
7
+ * делегується сюді — один спавн `conftest` на (`namespace`, `policyDir`),
8
8
  * незалежно від кількості файлів. Це закриває дублювання JS↔rego і прибирає
9
9
  * ризик дрифту (типу `spec.config` vs `spec.default.config` у
10
10
  * `health_check_policy.rego`, що ми ловили cross-check тестами).
11
11
  *
12
- * Hard-fail на відсутність `conftest` у PATH узгоджено з рішенням Plan B:
13
- * якщо правило делегує свою логіку до Rego, а інструмент відсутній, тиха
14
- * відмова приховує реальні порушення. Друкуємо install-hint (як `lint-rego.mjs`
15
- * робить для opa/regal).
12
+ * Hard-fail на відсутність `conftest` — через `ensureTool`, що спочатку
13
+ * намагається авто-встановити, і лише після невдачі кидає виняток.
16
14
  */
17
15
  import { spawnSync } from 'node:child_process'
18
16
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -20,7 +18,7 @@ import { tmpdir } from 'node:os'
20
18
  import { dirname, join } from 'node:path'
21
19
  import { fileURLToPath } from 'node:url'
22
20
 
23
- import { resolveCmd } from '../utils/resolve-cmd.mjs'
21
+ import { ensureTool } from './ensure-tool.mjs'
24
22
 
25
23
  /**
26
24
  Каталог пакета `@nitra/cursor`, від якого ресолвимо вшиті директорії правил.
@@ -30,23 +28,6 @@ const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
30
28
  /** Шлях до кореня правил. У npm-tarball публікується через `files: ["rules"]`. Кожне правило: `rules/<id>/policy/<name>/`. */
31
29
  const RULES_ROOT = join(PACKAGE_ROOT, 'rules')
32
30
 
33
- /**
34
- * Друкує install-hint для conftest і кидає виняток, щоб викликана `check-*`
35
- * команда ясно завершилась з кодом 1.
36
- * @returns {never} завжди кидає; для точки виклику — non-returning
37
- */
38
- function failConftestMissing() {
39
- throw new Error(
40
- [
41
- '❌ conftest не знайдено в PATH.',
42
- ' Без нього не запускається пер-документна валідація через rego-полісі (npm/policy/).',
43
- ' Встанови:',
44
- ' macOS: brew install conftest',
45
- ' Universal: https://www.conftest.dev/install/'
46
- ].join('\n')
47
- )
48
- }
49
-
50
31
  /**
51
32
  * @typedef {object} ConftestViolation
52
33
  * @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
@@ -80,16 +61,13 @@ export function buildConftestArgs(p) {
80
61
  /**
81
62
  * Виконує `conftest test` для всіх файлів одним спавном і повертає масив
82
63
  * порушень. Якщо `files` порожній — повертає `[]` без спавна. Якщо `conftest`
83
- * не у PATH — кидає виняток (hard fail, див. модульний docstring).
64
+ * не у PATH і авто-встановлення не вдалось — кидає виняток (hard fail).
84
65
  * @param {ConftestBatchOptions} opts параметри запуску
85
66
  * @returns {ConftestViolation[]} масив порушень (порожній — все ок)
86
67
  */
87
68
  export function runConftestBatch(opts) {
88
69
  if (opts.files.length === 0) return []
89
- const conftestBin = resolveCmd('conftest')
90
- if (!conftestBin) {
91
- failConftestMissing()
92
- }
70
+ const conftestBin = ensureTool('conftest')
93
71
  // policyDirRel — формат `<rule>/<name>` (наприклад `abie/base_deployment_preem`).
94
72
  // Реальний шлях у новій структурі: `rules/<rule>/policy/<name>`.
95
73
  const slash = opts.policyDirRel.indexOf('/')
@@ -13,13 +13,20 @@
13
13
  * Це дає той самий 0/1 контракт, що й попередня модель «один check.mjs на правило».
14
14
  */
15
15
  import { readFile } from 'node:fs/promises'
16
- import { join } from 'node:path'
16
+ import { join, relative } from 'node:path'
17
17
 
18
18
  import { findMissingMdcRefs } from './check-mdc-template-refs.mjs'
19
19
  import { createCheckReporter } from './check-reporter.mjs'
20
20
  import { resolveTargetFiles } from './resolve-target-files.mjs'
21
21
  import { runConftestBatch } from './run-conftest-batch.mjs'
22
- import { resolveConcernTemplateData } from './template.mjs'
22
+ import {
23
+ checkContains,
24
+ checkDeny,
25
+ checkSnippet,
26
+ checkTextSubset,
27
+ parseByExt,
28
+ resolveConcernTemplateData
29
+ } from './template.mjs'
23
30
 
24
31
  const APPLIES_CONCERN_NAME = 'applies'
25
32
 
@@ -52,6 +59,46 @@ async function evaluateAppliesGate(bundledRulesDir, rule) {
52
59
  return Boolean(await mod.applies())
53
60
  }
54
61
 
62
+ /**
63
+ * Snippet-driven перевірка концерну (`target.json:"check":"template"`): канон зі
64
+ * `template/<target>.snippet|deny|contains.<ext>` звіряється з actual-файлом
65
+ * generic deep-subset-ом, без `.rego`. Семантика — subset-of: усі канонічні
66
+ * поля/елементи обовʼязкові, зайві дозволені; масиви матчаться за наявністю
67
+ * (order-insensitive). Сніпет — єдине джерело істини: його зміна одразу змінює enforce.
68
+ * @param {string} concernAbsDir абсолютний `rules/<id>/policy/<concern>/`
69
+ * @param {object} target розпарсений `target.json`
70
+ * @param {string[]} files актуальні файли-таргети (resolveTargetFiles)
71
+ * @param {string} ruleId id правила (для `source` у повідомленнях)
72
+ * @param {string} concernName імʼя концерну (для summary)
73
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
74
+ */
75
+ export async function runTemplateSubsetConcern(concernAbsDir, target, files, ruleId, concernName) {
76
+ const reporter = createCheckReporter()
77
+ const data = await resolveConcernTemplateData(concernAbsDir, target)
78
+ if (!data) {
79
+ reporter.pass(`${concernName}: немає template-сніпета — пропущено`)
80
+ return reporter.getExitCode()
81
+ }
82
+ for (const file of files) {
83
+ const rel = relative(process.cwd(), file) || file
84
+ const actual = await parseByExt(file)
85
+ const opts = { targetPath: rel, source: `${ruleId}.mdc` }
86
+ const violations = [
87
+ ...(typeof data.snippet === 'string'
88
+ ? checkTextSubset(actual, data.snippet, opts)
89
+ : checkSnippet(actual, data.snippet, opts)),
90
+ ...checkDeny(actual, data.deny, opts),
91
+ ...checkContains(actual, data.contains, opts)
92
+ ]
93
+ if (violations.length === 0) {
94
+ reporter.pass(`${concernName}: ${rel} відповідає канону (template subset)`)
95
+ } else {
96
+ for (const v of violations) reporter.fail(v)
97
+ }
98
+ }
99
+ return reporter.getExitCode()
100
+ }
101
+
55
102
  /**
56
103
  * Запускає одну policy-полісі через `runConftestBatch`. Створює локальний репортер,
57
104
  * читає `target.json`, визначає файли, фіксує fail/pass — і повертає exit-код.
@@ -63,8 +110,9 @@ async function evaluateAppliesGate(bundledRulesDir, rule) {
63
110
  */
64
111
  async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache) {
65
112
  const reporter = createCheckReporter()
66
- const targetPath = join(bundledRulesDir, ruleId, 'policy', concernName, 'target.json')
67
- /** @type {{ files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
113
+ const concernAbsDir = join(bundledRulesDir, ruleId, 'policy', concernName)
114
+ const targetPath = join(concernAbsDir, 'target.json')
115
+ /** @type {{ check?: 'template', files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
68
116
  const target = JSON.parse(await readFile(targetPath, 'utf8'))
69
117
  const files = await resolveTargetFiles(target.files, process.cwd(), walkCache)
70
118
  if (files.length === 0) {
@@ -76,10 +124,18 @@ async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache)
76
124
  }
77
125
  return reporter.getExitCode()
78
126
  }
127
+
128
+ // `"check": "template"` — концерн без власного `.rego`: канон зі `template/`
129
+ // звіряється напряму через generic deep-subset (`checkSnippet`/`checkDeny`/
130
+ // `checkContains`/`checkTextSubset`). Редагування сніпета автоматично змінює
131
+ // enforce — без правок rego й без міграторів.
132
+ if (target.check === 'template') {
133
+ return runTemplateSubsetConcern(concernAbsDir, target, files, ruleId, concernName)
134
+ }
135
+
79
136
  // Rego не дозволяє '-' в імені пакета, тому kebab-id у `.n-cursor.json:rules`
80
137
  // мапиться на snake у namespace. Файлова структура `rules/<id>/policy/` лишається kebab.
81
138
  const regoNamespace = `${ruleId.replaceAll('-', '_')}.${concernName}`
82
- const concernAbsDir = join(bundledRulesDir, ruleId, 'policy', concernName)
83
139
  const templateData = await resolveConcernTemplateData(concernAbsDir, target)
84
140
  const violations = runConftestBatch({
85
141
  policyDirRel: `${ruleId}/${concernName}`,
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * `meta.json` — єдине джерело правди для скіла замість колишнього `auto.md`:
5
5
  * - `auto` — умова автоактивації (`"завжди"` | масив id правил), опційне;
6
- * - `worktree` — boolean: чи виконувати скіл в окремому git-worktree (один інстанс).
6
+ * - `worktree` — boolean: чи виконувати скіл в окремому git-worktree (один інстанс);
7
+ * - `requireRoot` — boolean, опційне: чи скіл вимагає запуску з кореня репо.
8
+ * Worktree-скіли (`worktree:true`) вимагають кореня неявно (корінь worktree =
9
+ * його toplevel), тож для них поле зайве. Явний `requireRoot:true` — для
10
+ * in-place скілів, що мутують CWD без worktree-ізоляції (напр. `n-start-check`).
7
11
  *
8
12
  * Цим хелпером користуються `auto-skills.mjs` (автоактивація), `n-cursor.js`
9
- * (sync + вшивання worktree-блоку) і check-концерн `npm-module/js/skill_meta.mjs`,
13
+ * (sync + вшивання worktree/root-блоку) і check-концерн `npm-module/js/skill_meta.mjs`,
10
14
  * щоб не дублювати парсинг і форму валідації.
11
15
  */
12
16
  import { existsSync, readFileSync } from 'node:fs'
@@ -36,6 +40,16 @@ export function parseSkillAutoSpec(value) {
36
40
  return null
37
41
  }
38
42
 
43
+ /**
44
+ * Чи вимагає скіл запуску з кореня репо («активовано root-захист»). Єдина похідна
45
+ * ознака: `worktree:true` (корінь гарантує worktree) АБО явний `requireRoot:true`.
46
+ * @param {Record<string, unknown> | null} meta розпарсений `meta.json` (або null)
47
+ * @returns {boolean} true — скіл мутує проєкт і має стартувати з кореня
48
+ */
49
+ export function skillRequiresRoot(meta) {
50
+ return meta?.worktree === true || meta?.requireRoot === true
51
+ }
52
+
39
53
  /**
40
54
  * Читає й парсить `meta.json` одного скіла.
41
55
  * @param {string} skillDir абсолютний шлях до каталогу скіла
@@ -26,7 +26,7 @@ const LEADING_BANG_RE = /^!/
26
26
  * @param {string} path шлях до файлу
27
27
  * @returns {Promise<unknown>} розпарсений вміст
28
28
  */
29
- async function parseByExt(path) {
29
+ export async function parseByExt(path) {
30
30
  const raw = await readFile(path, 'utf8')
31
31
  const ext = extname(path).toLowerCase()
32
32
  if (ext === '.json' || ext === '.jsonc') return JSON.parse(stripJsonComments(raw))
@@ -112,6 +112,27 @@ function quote(v) {
112
112
  return typeof v === 'string' ? JSON.stringify(v) : String(v)
113
113
  }
114
114
 
115
+ /** Ключі, за якими ідентифікуємо елемент масиву обʼєктів у повідомленні (напр. workflow-крок). */
116
+ const ELEMENT_ID_KEYS = ['uses', 'name', 'id', 'run']
117
+
118
+ /**
119
+ * Людинозрозумілий опис елемента масиву для повідомлення про відсутність.
120
+ * Для скаляра — `quote`; для обʼєкта — перший наявний ідентифікуючий ключ
121
+ * (`uses`/`name`/`id`/`run`), інакше компактний JSON.
122
+ * @param {unknown} needle елемент сніпета, якого бракує в actual
123
+ * @returns {string} опис для тексту порушення
124
+ */
125
+ function describeElement(needle) {
126
+ if (needle !== null && typeof needle === 'object' && !Array.isArray(needle)) {
127
+ const obj = /** @type {Record<string, unknown>} */ (needle)
128
+ for (const k of ELEMENT_ID_KEYS) {
129
+ if (typeof obj[k] === 'string') return `елемент з ${k}: ${quote(obj[k])}`
130
+ }
131
+ return `елемент ${JSON.stringify(needle)}`
132
+ }
133
+ return quote(needle)
134
+ }
135
+
115
136
  /**
116
137
  * Deep subset-of check. Every leaf in `snippet` must equal same path in `actual`.
117
138
  * Arrays in snippet: every element must be present in actual array.
@@ -131,10 +152,15 @@ export function checkSnippet(actual, snippet, opts, path = []) {
131
152
  violations.push(`${targetPath}: ${formatPath(path)} має бути масивом (${source})`)
132
153
  return violations
133
154
  }
155
+ // Subset-of, order-insensitive: кожен елемент сніпета має структурно міститись
156
+ // хоча б в одному елементі actual. Для обʼєктів — рекурсивний subset
157
+ // (`checkSnippet` без порушень), тож порядок ключів, зайві поля й зайві елементи
158
+ // не ламають збіг. Критично для впорядкованих масивів як `steps`, де елементи
159
+ // сортувати не можна (порядок кроків семантичний) — матч лишається за наявністю.
134
160
  for (const needle of snippet) {
135
- const found = actual.some(a => JSON.stringify(a) === JSON.stringify(needle))
161
+ const found = actual.some(a => checkSnippet(a, needle, opts, [...path, '[]']).length === 0)
136
162
  if (!found) {
137
- violations.push(`${targetPath}: ${formatPath(path)} має містити ${quote(needle)} (${source})`)
163
+ violations.push(`${targetPath}: ${formatPath(path)} має містити ${describeElement(needle)} (${source})`)
138
164
  }
139
165
  }
140
166
  return violations
@@ -5,69 +5,78 @@
5
5
  * і не паралелитись. Підказка адресована агенту, який читає `SKILL.md`, тож
6
6
  * вставляється в текст між стабільними маркерами — ре-синк ідемпотентний:
7
7
  * наявний блок замінюється, при `worktree:false` — видаляється.
8
+ *
9
+ * Крок 0.1 блоку додає `bun install` у щойно створеному дереві (локальна копія
10
+ * CLI усуває гонку з CDN) і shell-обгортку `n_cursor_npx` навколо bootstrap-виклику
11
+ * `npx`: на ETARGET/notarget та мережевих помилках npm падає ДО запуску бінарника,
12
+ * тож retry мусить жити на рівні shell-інструкції, а не в JS-хендлерах CLI.
13
+ * Обгортка ретраїть лише транзитні помилки реєстру/мережі (30с інтервал, дефолт
14
+ * 5 хв, env `N_CURSOR_NPX_RETRY_MAX_MIN`, ceiling 10 хв) і віддає реальний nonzero
15
+ * CLI одразу. Команди винесені окремим кроком ПІСЛЯ worktree-створення, бо
16
+ * вимагають command substitution, заборонену у «без-expansion» preflight-снипеті
17
+ * (узгоджено з worktree.mdc).
8
18
  */
9
19
 
10
20
  /** Маркер початку worktree-блоку (стабільний, не залежить від тексту всередині). */
11
- export const WORKTREE_START = "<!-- n-cursor:worktree:start -->";
21
+ export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
12
22
  /** Маркер кінця worktree-блоку. */
13
- export const WORKTREE_END = "<!-- n-cursor:worktree:end -->";
23
+ export const WORKTREE_END = '<!-- n-cursor:worktree:end -->'
14
24
 
15
- const FALLBACK_SUFFIX = "task";
25
+ const FALLBACK_SUFFIX = 'task'
16
26
 
17
27
  /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
18
- const BLOCK_RE =
19
- /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u;
28
+ const BLOCK_RE = /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u
20
29
 
21
30
  /** Закриття YAML-frontmatter на початку файла. */
22
- const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u;
31
+ const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u
23
32
 
24
33
  /** Значення `name` з YAML-frontmatter. */
25
- const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu;
34
+ const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu
26
35
 
27
36
  /** Перший H1 як fallback, якщо frontmatter не містить `name`. */
28
- const H1_RE = /^#\s+(.+)$/mu;
37
+ const H1_RE = /^#\s+(.+)$/mu
29
38
 
30
39
  const CYRILLIC_TRANSLIT = new Map(
31
40
  Object.entries({
32
- а: "a",
33
- б: "b",
34
- в: "v",
35
- г: "h",
36
- ґ: "g",
37
- д: "d",
38
- е: "e",
39
- є: "ye",
40
- ж: "zh",
41
- з: "z",
42
- и: "y",
43
- і: "i",
44
- ї: "yi",
45
- й: "y",
46
- к: "k",
47
- л: "l",
48
- м: "m",
49
- н: "n",
50
- о: "o",
51
- п: "p",
52
- р: "r",
53
- с: "s",
54
- т: "t",
55
- у: "u",
56
- ф: "f",
57
- х: "kh",
58
- ц: "ts",
59
- ч: "ch",
60
- ш: "sh",
61
- щ: "shch",
62
- ь: "",
63
- ю: "yu",
64
- я: "ya",
65
- ы: "y",
66
- э: "e",
67
- ё: "yo",
68
- ъ: "",
69
- }),
70
- );
41
+ а: 'a',
42
+ б: 'b',
43
+ в: 'v',
44
+ г: 'h',
45
+ ґ: 'g',
46
+ д: 'd',
47
+ е: 'e',
48
+ є: 'ye',
49
+ ж: 'zh',
50
+ з: 'z',
51
+ и: 'y',
52
+ і: 'i',
53
+ ї: 'yi',
54
+ й: 'y',
55
+ к: 'k',
56
+ л: 'l',
57
+ м: 'm',
58
+ н: 'n',
59
+ о: 'o',
60
+ п: 'p',
61
+ р: 'r',
62
+ с: 's',
63
+ т: 't',
64
+ у: 'u',
65
+ ф: 'f',
66
+ х: 'kh',
67
+ ц: 'ts',
68
+ ч: 'ch',
69
+ ш: 'sh',
70
+ щ: 'shch',
71
+ ь: '',
72
+ ю: 'yu',
73
+ я: 'ya',
74
+ ы: 'y',
75
+ э: 'e',
76
+ ё: 'yo',
77
+ ъ: ''
78
+ })
79
+ )
71
80
 
72
81
  /**
73
82
  * Транслітерує кирилицю в ASCII для короткого suffix.
@@ -75,8 +84,7 @@ const CYRILLIC_TRANSLIT = new Map(
75
84
  * @returns {string} транслітерований текст
76
85
  */
77
86
  function transliterate(value) {
78
- return Array.from(value.toLowerCase(), (char) => CYRILLIC_TRANSLIT.get(char) ?? char)
79
- .join("");
87
+ return Array.from(value.toLowerCase(), char => CYRILLIC_TRANSLIT.get(char) ?? char).join('')
80
88
  }
81
89
 
82
90
  /**
@@ -85,20 +93,16 @@ function transliterate(value) {
85
93
  * @returns {string} suffix до 10 символів
86
94
  */
87
95
  function deriveSuffix(content) {
88
- const raw =
89
- content.match(NAME_RE)?.[1] ?? content.match(H1_RE)?.[1] ?? FALLBACK_SUFFIX;
96
+ const raw = content.match(NAME_RE)?.[1] ?? content.match(H1_RE)?.[1] ?? FALLBACK_SUFFIX
90
97
  const slug = transliterate(raw)
91
98
  .trim()
92
- .replace(/^n-/u, "")
93
- .normalize("NFKD")
94
- .replaceAll(/[\u0300-\u036F]/gu, "")
95
- .replaceAll(/[^a-z0-9]+/gu, "-")
96
- .replaceAll(/^-+|-+$/gu, "");
97
-
98
- return (
99
- (slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, "") ||
100
- FALLBACK_SUFFIX
101
- );
99
+ .replace(/^n-/u, '')
100
+ .normalize('NFKD')
101
+ .replaceAll(/[\u0300-\u036F]/gu, '')
102
+ .replaceAll(/[^a-z0-9]+/gu, '-')
103
+ .replaceAll(/^-+|-+$/gu, '')
104
+
105
+ return (slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, '') || FALLBACK_SUFFIX
102
106
  }
103
107
 
104
108
  /**
@@ -113,18 +117,62 @@ function buildNoticeBody(suffix) {
113
117
  **Крок 0 — preflight (обовʼязковий, перед будь-якими іншими діями).** Якщо перевірка падає — **STOP**: не питай користувача про назву гілки, а сам створи worktree від поточної гілки за конвенцією \`<current-branch>-${suffix}\`. Суфікс \`${suffix}\` — коротка (до 10 символів) транслітерація задачі. Не виконуй **жоден** наступний крок скіла, поки preflight не завершився успіхом.
114
118
 
115
119
  \`\`\`bash
120
+ pwd
116
121
  git rev-parse --show-toplevel
117
122
  git branch --show-current
118
123
  \`\`\`
119
124
 
120
- Якщо перша команда показала, що ти **не** в \`.worktrees/\`, візьми вивід другої команди як \`<current-branch>\` і виконай **literal-команди без shell expansion** (без command substitution, variable expansion чи backticks). Наприклад, якщо поточна гілка \`feature/x\`:
125
+ **Root-assert.** Якщо \`pwd\` **не** збігається з виводом \`git rev-parse --show-toplevel\` — ти в **піддиректорії** робочого дерева (worktree-шляхи нижче відносні до кореня репо). Спершу перейди в корінь: \`cd <toplevel>\` (literal-шлях із виводу), і лише тоді продовжуй preflight. Не створюй worktree з піддиректорії — \`cd .worktrees/<…>\` звідти впаде.
126
+
127
+ Якщо \`git rev-parse --show-toplevel\` показав, що ти **не** в \`.worktrees/\`, візьми вивід \`git branch --show-current\` як \`<current-branch>\` і виконай **literal-команди без shell expansion** (без command substitution, variable expansion чи backticks). Наприклад, якщо поточна гілка \`feature/x\`:
121
128
 
122
129
  \`\`\`bash
123
130
  npx @nitra/cursor worktree add "feature/x-${suffix}" "n-${suffix}: worktree-only skill"
124
131
  cd ".worktrees/feature-x-${suffix}"
125
132
  \`\`\`
126
133
 
127
- Тобто branch-argument лишає slash як у git-гілці, а шлях для \`cd\` бере sanitized форму: slash → \`-\`.`;
134
+ Тобто branch-argument лишає slash як у git-гілці, а шлях для \`cd\` бере sanitized форму: slash → \`-\`.
135
+
136
+ **Крок 0.1 — bootstrap у новому дереві (після \`cd\`, окремий крок — поза «без-expansion» блоком вище).** Дерево щойно створене й **без** \`node_modules\`. Спершу постав залежності локально: тоді \`npx\` бере локальну копію \`@nitra/cursor\` і гонки з CDN немає взагалі. Retry-обгортка нижче — safety-net на випадок, коли версію щойно опубліковано, але edge-кеш CDN ще її не має: \`npm\` тоді падає з \`ETARGET\`/\`notarget\` **до** запуску бінарника (внутрішній JS-retry у \`n-cursor\` для цього кейсу марний — бінарник ще не стартував).
137
+
138
+ \`\`\`bash
139
+ # Локальна копія @nitra/cursor (девзалежність споживача) — npx бере її, без походу в реєстр.
140
+ bun install
141
+
142
+ # n_cursor_npx <args> — обгортка bootstrap-виклику "npx @nitra/cursor <args>".
143
+ # Ретраїмо ЛИШЕ транзитні помилки реєстру/мережі (CDN ще не пропагував щойно
144
+ # опубліковану версію). Реальний nonzero від CLI (fix повернув ❌, lint-помилка) —
145
+ # віддаємо одразу, без ретраю. Інтервал 30с; дефолт-ліміт 5 хв
146
+ # (env N_CURSOR_NPX_RETRY_MAX_MIN), hard-ceiling 10 хв.
147
+ # Чому 5 хв: CDN-пропагація npm зазвичай < 2 хв, 5 хв — запас; довше → ймовірно
148
+ # реальна проблема (невірна версія / аутейдж), краще віддати помилку, ніж висіти.
149
+ n_cursor_npx() {
150
+ max_min="\${N_CURSOR_NPX_RETRY_MAX_MIN:-5}"
151
+ case "$max_min" in '' | *[!0-9]*) max_min=5 ;; esac
152
+ [ "$max_min" -gt 10 ] && max_min=10
153
+ deadline=$(( $(date +%s) + max_min * 60 ))
154
+ attempt=1
155
+ transient='ETARGET|notarget|No matching version|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|50[0-9] |502 Bad Gateway|503 Service Unavailable|504 Gateway'
156
+ while :; do
157
+ err=$(mktemp)
158
+ npx @nitra/cursor "$@" 2>"$err"
159
+ code=$?
160
+ cat "$err" >&2
161
+ [ "$code" -eq 0 ] && { rm -f "$err"; return 0; }
162
+ if grep -Eq "$transient" "$err" && [ "$(date +%s)" -lt "$deadline" ]; then
163
+ rm -f "$err"
164
+ echo "n-cursor: очікую пропагації версії по CDN… спроба $attempt, повтор через 30с" >&2
165
+ attempt=$((attempt + 1))
166
+ sleep 30
167
+ else
168
+ rm -f "$err"
169
+ return "$code"
170
+ fi
171
+ done
172
+ }
173
+ \`\`\`
174
+
175
+ Усі подальші bootstrap-виклики \`npx @nitra/cursor <cmd>\` у цій сесії роби через \`n_cursor_npx <cmd>\`. Якщо опинився у свіжому shell без цієї функції — спершу повтори блок вище (\`bun install\` + визначення \`n_cursor_npx\`).`
128
176
  }
129
177
 
130
178
  /**
@@ -133,7 +181,7 @@ cd ".worktrees/feature-x-${suffix}"
133
181
  * @returns {string} текст блоку від START до END
134
182
  */
135
183
  function buildBlock(content) {
136
- return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`;
184
+ return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`
137
185
  }
138
186
 
139
187
  /**
@@ -143,19 +191,19 @@ function buildBlock(content) {
143
191
  * @returns {string} оновлений вміст (ідемпотентно)
144
192
  */
145
193
  export function injectWorktreeNotice(content, enabled) {
146
- const hadBlock = content.includes(WORKTREE_START);
147
- const withoutBlock = content.replace(BLOCK_RE, "\n\n");
194
+ const hadBlock = content.includes(WORKTREE_START)
195
+ const withoutBlock = content.replace(BLOCK_RE, '\n\n')
148
196
 
149
197
  if (!enabled) {
150
- return hadBlock ? withoutBlock : content;
198
+ return hadBlock ? withoutBlock : content
151
199
  }
152
200
 
153
- const block = buildBlock(withoutBlock);
154
- const fm = withoutBlock.match(FRONTMATTER_RE);
201
+ const block = buildBlock(withoutBlock)
202
+ const fm = withoutBlock.match(FRONTMATTER_RE)
155
203
  if (fm) {
156
- const head = fm[1];
157
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, "");
158
- return `${head}\n${block}\n\n${rest}`;
204
+ const head = fm[1]
205
+ const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
206
+ return `${head}\n${block}\n\n${rest}`
159
207
  }
160
- return `${block}\n\n${withoutBlock.replace(/^\n+/u, "")}`;
208
+ return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
161
209
  }
@@ -21,8 +21,8 @@
21
21
  * entries додаються, коли правило `adr` увімкнене, і видаляються, коли вимкнене.
22
22
  * - `.gitignore` — **merge** (лише з `adr`): дописує відсутні рядки з канонічного
23
23
  * фрагмента `rules/adr/js/hooks/template/.gitignore.snippet` (`node_modules/`, `dist/`,
24
- * `*.secret`, логи capture/normalize, `.normalize-state`, `.normalize.lock`); існуючі
25
- * рядки не перезаписуються.
24
+ * `*.secret`, логи capture/normalize, `.normalize-state`, `.normalize.lock`,
25
+ * `.claude/scheduled_tasks.lock`); існуючі рядки не перезаписуються.
26
26
  *
27
27
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
28
28
  */
@@ -12,10 +12,10 @@ description: >-
12
12
 
13
13
  ## Workflow
14
14
 
15
- 1. **Діагностика** — запусти перевірку (за замовчуванням лише правила з `.cursor/rules/*.mdc`, для яких у пакеті є programmatic check; повний набір — явні аргументи: `npx @nitra/cursor fix bun ga …`):
15
+ 1. **Діагностика** — запусти перевірку через retry-обгортку `n_cursor_npx` (визначена у worktree-preflight, крок 0.1: переживає транзитну CDN-гонку щойно опублікованої версії, а реальний `❌` від `fix` віддає одразу). За замовчуванням лише правила з `.cursor/rules/*.mdc`, для яких у пакеті є programmatic check; повний набір — явні аргументи: `n_cursor_npx fix bun ga …`:
16
16
 
17
17
  ```bash
18
- npx @nitra/cursor fix
18
+ n_cursor_npx fix
19
19
  ```
20
20
 
21
21
  2. **Аналіз** — зчитай вивід, знайди всі `❌` та визнач які правила порушено
@@ -40,10 +40,10 @@ bun i
40
40
  oxfmt .
41
41
  ```
42
42
 
43
- 6. **Верифікація** — перевір що все виправлено:
43
+ 6. **Верифікація** — перевір що все виправлено (та сама retry-обгортка `n_cursor_npx`):
44
44
 
45
45
  ```bash
46
- npx @nitra/cursor fix
46
+ n_cursor_npx fix
47
47
  ```
48
48
 
49
49
  7. **Результат** — всі `❌` від `npx @nitra/cursor fix` мають стати `✅`. Якщо залишились `❌` — повтори кроки 3-6. Лінт-помилки від `bun run lint` тут **не виправляй** — вони на скіл `/n-lint`.
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": false }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": false }