@nitra/cursor 3.20.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.
@@ -199,7 +199,7 @@ if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
199
199
  exit 0
200
200
  fi
201
201
 
202
- TS=$(date +%Y%m%d-%H%M%S)
202
+ TS=$(date +%y%m%d-%H%M)
203
203
 
204
204
  # Slug із першого `## [ADR|Runbook|Knowledge] <heading>`-рядка відповіді.
205
205
  # Логіка локальна (без додаткового LLM-виклику): використовуємо вже згенерований heading.
@@ -407,11 +407,15 @@ while IFS= read -r op_json; do
407
407
  continue
408
408
  ;;
409
409
  esac
410
- # Keep the draft's `YYYYMMDD-HHMMSS-` prefix on the clean file: the name
410
+ # Keep the draft's timestamp prefix on the clean file: the name
411
411
  # stays anchored to capture time, only the slug part changes between draft
412
- # and clean, and docs/adr/ keeps sorting chronologically. Drafts without a
413
- # timestamp prefix fall back to a bare `<slug>.md`.
412
+ # and clean, and docs/adr/ keeps sorting chronologically. New drafts use
413
+ # `YYMMDD-HHMM-`; older `YYYYMMDD-HHMMSS-` drafts are still supported.
414
+ # Drafts without a timestamp prefix fall back to a bare `<slug>.md`.
414
415
  case "$FILE" in
416
+ [0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]-*)
417
+ DEST_SLUG="$(printf '%s' "$FILE" | cut -c1-11)-$SLUG"
418
+ ;;
415
419
  [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
416
420
  DEST_SLUG="$(printf '%s' "$FILE" | cut -c1-15)-$SLUG"
417
421
  ;;
@@ -444,7 +448,7 @@ while IFS= read -r op_json; do
444
448
  ;;
445
449
  esac
446
450
  # Resolve the target clean file. The LLM gives a bare `<slug>.md`, but the
447
- # real file usually carries a `YYYYMMDD-HHMMSS-` prefix. Try, in order:
451
+ # real file usually carries a timestamp prefix. Try, in order:
448
452
  # 1. exact name in docs/adr/,
449
453
  # 2. a rewrite of this batch that produced that slug (SLUG_MAP),
450
454
  # 3. a unique existing clean file whose name ends with `-<slug>.md`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.21.0] - 2026-06-04
4
+
5
+ ### Added
6
+
7
+ - Root-guard для всіх деструктивних точок: CLI-команди fix/lint/coverage/change/release + дефолтний sync хардом перевіряють cwd===git-toplevel (assertCwdIsProjectRoot); worktree-preflight підсилено root-assert (pwd vs toplevel); новий meta.json-флаг requireRoot + injectRootNotice для in-place root-only скілів (n-start-check); skillRequiresRoot як явна похідна ознака захисту; валідація requireRoot у npm-module
8
+ - sync .gitignore: ігнорувати .claude/scheduled_tasks.lock (runtime-lock планувальника)
9
+
10
+ ### Changed
11
+
12
+ - Скорочено timestamp-префікси ADR і change-файлів до YYMMDD-HHMM; додано атомарний suffix для локальних колізій та заборону Temporal у Bun runtime.
13
+
14
+ ### Fixed
15
+
16
+ - n-changelog: directional version-drift — лише version ВПЕРЕД (> опублікованої/git-бази) валить як ручний bump; version ПОЗАДУ (локаль відстала від CI-релізу, ще не git pull) більше не блокує коміт (compareSemverCore/versionIsAhead)
17
+
3
18
  ## [3.20.0] - 2026-06-03
4
19
 
5
20
  ### Added
package/bin/n-cursor.js CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  import { detectAutoSkills } from '../scripts/auto-skills.mjs'
93
93
  import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
94
94
  import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
95
+ import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
95
96
  import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
96
97
  import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
97
98
  import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
@@ -787,6 +788,9 @@ async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
787
788
  await mkdir(destDir, { recursive: true })
788
789
  const meta = readSkillMetaRaw(srcDir)
789
790
  const worktree = meta?.worktree === true
791
+ // root-guard для in-place скілів (мутують CWD без worktree-ізоляції).
792
+ // Worktree-скіли root-assert уже мають у worktree-блоці, тож тут лише !worktree.
793
+ const rootOnly = !worktree && meta?.requireRoot === true
790
794
  const entries = await readdir(srcDir, { withFileTypes: true })
791
795
  for (const entry of entries) {
792
796
  // Лише top-level файли скіла. `meta.json` — метадані (не для споживача);
@@ -796,6 +800,7 @@ async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
796
800
  let content = await readFile(join(srcDir, entry.name), 'utf8')
797
801
  if (entry.name === 'SKILL.md') {
798
802
  content = injectWorktreeNotice(content, worktree)
803
+ content = injectRootNotice(content, rootOnly)
799
804
  }
800
805
  await writeFile(join(destDir, entry.name), content, 'utf8')
801
806
  }
@@ -1459,15 +1464,51 @@ async function runSync() {
1459
1464
  }
1460
1465
  }
1461
1466
 
1467
+ /**
1468
+ * Команди, що мутують проєкт у CWD і вимагають кореня репо. `undefined`/`''` —
1469
+ * дефолтний sync; `check` — deprecated-alias `fix`. Решта (read-only `trace`/
1470
+ * `graph`, `--root`-команди `docgen`/`rename-yaml-extensions`, `worktree`,
1471
+ * sub-лінтери) гард не зачіпає.
1472
+ */
1473
+ const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'fix', 'check', 'lint', 'coverage', 'change', 'release'])
1474
+
1475
+ /**
1476
+ * Короткий опис дії для тексту root-guard помилки за іменем команди.
1477
+ * @param {string | undefined} cmd підкоманда CLI (або undefined для дефолтного sync)
1478
+ * @returns {string} фраза «що саме мутує CWD»
1479
+ */
1480
+ function describeRootGuardedAction(cmd) {
1481
+ switch (cmd) {
1482
+ case undefined:
1483
+ case '':
1484
+ return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json і робить bun install у поточному каталозі'
1485
+ case 'fix':
1486
+ case 'check':
1487
+ return '`fix` запускає programmatic-перевірки правил, що переписують конфіги в поточному каталозі'
1488
+ case 'lint':
1489
+ return '`lint` запускає авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) у поточному каталозі'
1490
+ case 'coverage':
1491
+ return '`coverage` генерує COVERAGE.md і Stryker-артефакти в поточному каталозі'
1492
+ case 'change':
1493
+ return '`change` пише change-файл у .changes/ поточного каталогу'
1494
+ case 'release':
1495
+ return '`release` бампає version і переписує CHANGELOG у поточному каталозі'
1496
+ default:
1497
+ return 'Команда @nitra/cursor мутує проєкт у поточному каталозі'
1498
+ }
1499
+ }
1500
+
1462
1501
  // CLI: маршрутизація команд
1463
1502
  const [command, ...args] = process.argv.slice(2)
1464
1503
 
1465
1504
  try {
1466
- // Дефолтний sync (без підкоманди) скаффолдить .cursor/.claude/CLAUDE.md/.n-cursor.json
1467
- // і робить bun install у cwd(). Guard до перших мутацій: заборона запуску з піддиректорії
1468
- // git-репо (типово прямий `bun npm/bin/n-cursor.js` не з кореня). Підкоманди не зачіпає.
1469
- if (command === undefined || command === '') {
1470
- assertCwdIsProjectRoot()
1505
+ // Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
1506
+ // .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD
1507
+ // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1508
+ // `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1509
+ // `--root`-команди (trace, graph, docgen, rename-yaml-extensions) не зачіпаємо.
1510
+ if (ROOT_GUARDED_COMMANDS.has(command)) {
1511
+ assertCwdIsProjectRoot(cwd(), describeRootGuardedAction(command))
1471
1512
  }
1472
1513
  await ensureNitraCursorInRootDevDependencies(cwd())
1473
1514
  switch (command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -18,8 +18,8 @@ ADR живуть у єдиному каталозі **`docs/adr/`**. Clean ADR-
18
18
 
19
19
  Є два стани файлу, які відрізняються YAML frontmatter:
20
20
 
21
- - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
22
- - **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `YYYYMMDD-HHMMSS-<slug>.md` (наприклад `20260518-092807-ланцюжок-запуску-abie.md`); створений руками clean-файл може мати просто `<slug>.md`.
21
+ - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYMMDD-HHMM-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
22
+ - **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `YYMMDD-HHMM-<slug>.md` (наприклад `260518-0928-ланцюжок-запуску-abie.md`); створений руками clean-файл може мати просто `<slug>.md`.
23
23
 
24
24
  `normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
25
25
 
@@ -50,7 +50,7 @@ LLM повертає масив операцій:
50
50
  | `rewrite` | Чернетка стає окремим clean-файлом MADR v4 minimal: frontmatter знімається, ім'я → `<timestamp>-<slug>.md` (timestamp-префікс чернетки збережено), додаються `**Status:** Accepted`, `**Date:**` з `captured` і canonical MADR headings. | `file`, `slug`, `content` |
51
51
  | `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
52
52
 
53
- `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. До імені clean-файлу скрипт додає `YYYYMMDD-HHMMSS-` чернетки, тож запис лишається прив'язаним до часу capture, а `docs/adr/` сортується хронологічно. Колізія імен обробляється детермінованим суфіксом `-2`, `-3`.
53
+ `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. До імені clean-файлу скрипт додає `YYMMDD-HHMM-` чернетки, тож запис лишається прив'язаним до часу capture, а `docs/adr/` сортується хронологічно. Колізія імен обробляється детермінованим суфіксом `-2`, `-3`. Старі чернетки з `YYYYMMDD-HHMMSS-` prefix нормалізатор також розпізнає, щоб не ламати наявний inbox.
54
54
 
55
55
  ### Жодних git-операцій
56
56
 
@@ -86,8 +86,8 @@ LLM повертає масив операцій:
86
86
 
87
87
  ```text
88
88
  docs/adr/
89
- ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
90
- └── YYYYMMDD-HHMMSS-<slug>.md # clean ADR-и (без frontmatter, timestamp-префікс чернетки збережено)
89
+ ├── YYMMDD-HHMM-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
90
+ └── YYMMDD-HHMM-<slug>.md # clean ADR-и (без frontmatter, timestamp-префікс чернетки збережено)
91
91
  .claude/hooks/
92
92
  ├── capture-decisions.sh # auto-synced з пакета
93
93
  ├── normalize-decisions.sh # auto-synced з пакета
@@ -6,3 +6,4 @@ dist/
6
6
  .claude/hooks/*.log
7
7
  .claude/hooks/.normalize-state
8
8
  .claude/hooks/.normalize.lock
9
+ .claude/scheduled_tasks.lock
@@ -8,7 +8,7 @@ alwaysApply: true
8
8
 
9
9
  > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
10
 
11
- 1. **Поклади change-файл** `<ws>/.changes/<timestamp>-<rand>.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`.
11
+ 1. **Поклади change-файл** `<ws>/.changes/YYMMDD-HHMM.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`. Якщо файл за ту саму хвилину вже існує, CLI атомарно створює `YYMMDD-HHMM-2.md`, потім `-3` тощо.
12
12
  2. **Ніколи** не редагуй `version` і `CHANGELOG.md` вручну — навіть для hotfix. Єдиний артефакт зміни — change-файл; `version`/CHANGELOG формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`). Будь-яка зміна `version` поза CI (drift від бази чи опублікованої) завалює `check changelog` — навіть якщо поряд є change-файл.
13
13
  3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (достатньо наявності change-файлу; `version` лишається незмінним).
14
14
 
@@ -3,10 +3,12 @@
3
3
  * change-файл `<ws>/.changes/*.md` — єдиний дозволений артефакт зміни. Bump `version`
4
4
  * і генерацію `CHANGELOG.md` робить виключно `n-cursor release` у CI на `main`.
5
5
  *
6
- * Інваріант (на будь-якій гілці): `version` не має відхилятися від бази. Будь-який drift
7
- * `version` (vs опублікована в реєстрі або vs git-база) — ручний bump поза CI — завалює
8
- * перевірку, навіть якщо присутній change-файл. Pass лише коли є change-файл, а version
9
- * не зрушено; зміни без change-файлу fail.
6
+ * Інваріант (на будь-якій гілці): `version` у дереві не має **випереджати** базу. Лише
7
+ * drift **уперед** (`version` > опублікованої в реєстрі / > git-бази) — ручний bump поза
8
+ * CI — завалює перевірку, навіть із change-файлом. Version **позаду** бази (локаль відстала
9
+ * від уже опублікованого CI-релізу, ще не зроблено `git pull`)НЕ порушення: це не ручний
10
+ * bump, а git і так не дасть запушити non-fast-forward. Pass лише коли є change-файл, а
11
+ * version не випереджає базу; зміни без change-файлу — fail.
10
12
  *
11
13
  * Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
12
14
  *
@@ -318,6 +320,48 @@ function resolvePublishedVersion(manifest, getPublishedVersion) {
318
320
  return getPublishedVersion(manifest.name, manifest.kind)
319
321
  }
320
322
 
323
+ /** Числове ядро semver (`x.y.z`); хвіст (prerelease/build) ігнорується. */
324
+ const SEMVER_CORE_RE = /^(\d+)\.(\d+)\.(\d+)/
325
+
326
+ /**
327
+ * Парсить числове ядро semver-рядка.
328
+ * @param {unknown} v версія
329
+ * @returns {{ major: number, minor: number, patch: number } | null} ядро або null
330
+ */
331
+ function parseSemverCore(v) {
332
+ const m = typeof v === 'string' ? v.match(SEMVER_CORE_RE) : null
333
+ if (!m) return null
334
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
335
+ }
336
+
337
+ /**
338
+ * Порівнює semver-ядра двох версій.
339
+ * @param {unknown} a перша версія
340
+ * @param {unknown} b друга версія
341
+ * @returns {-1 | 0 | 1 | null} a<b → -1, a==b → 0, a>b → 1; null — нерозпізнано
342
+ */
343
+ function compareSemverCore(a, b) {
344
+ const pa = parseSemverCore(a)
345
+ const pb = parseSemverCore(b)
346
+ if (!pa || !pb) return null
347
+ if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
348
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
349
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
350
+ return 0
351
+ }
352
+
353
+ /**
354
+ * Чи `current` випереджає `base` (ручний bump поза CI). Якщо semver нерозпізнаний
355
+ * (null) — fail-closed на будь-яку нерівність (консервативно, як до directional-фікса).
356
+ * @param {unknown} current версія в дереві
357
+ * @param {unknown} base база (опублікована / git-база)
358
+ * @returns {boolean} true — current попереду base (або нерозпізнано й не рівні)
359
+ */
360
+ function versionIsAhead(current, base) {
361
+ const cmp = compareSemverCore(current, base)
362
+ return cmp === null ? current !== base : cmp > 0
363
+ }
364
+
321
365
  /**
322
366
  * @param {string} name пакет
323
367
  * @param {import('../lib/package-manifest.mjs').PackageKind} [kind] тип пакета
@@ -454,16 +498,28 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
454
498
  pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
455
499
  return
456
500
  }
457
- // Drift від опублікованої версії має пріоритет над change-файлом: ручний bump
458
- // заборонено навіть із change-файлом (симетрично з local-only-шляхом).
459
- if (Vpublished !== Vcurrent) {
501
+ // Лише drift УПЕРЕД (version > опублікованої) ручний bump поза CI; має пріоритет
502
+ // над change-файлом (симетрично з local-only-шляхом). Версія ПОЗАДУ реєстру — локаль
503
+ // відстала від уже опублікованого релізу, не порушення (нижче).
504
+ if (versionIsAhead(Vcurrent, Vpublished)) {
460
505
  fail(
461
- `${label}: version у ${mf} (${Vcurrent}) розходиться з опублікованою (${Vpublished}) — ` +
462
- `ручний bump заборонено. Відкоти version і поклади change-файл ` +
506
+ `${label}: version у ${mf} (${Vcurrent}) випереджає опубліковану (${Vpublished}) — ` +
507
+ `ручний bump поза CI заборонено. Відкоти version і поклади change-файл ` +
463
508
  `(npx @nitra/cursor change …); bump зробить CI на main (n-changelog.mdc)`
464
509
  )
465
510
  return
466
511
  }
512
+ if (compareSemverCore(Vcurrent, Vpublished) < 0) {
513
+ // Локаль ПОЗАДУ реєстру: CI вже опублікував новішу версію й закомітив bump назад,
514
+ // а ти ще не зробив `git pull`. Це не ручний bump (git не дасть запушити
515
+ // non-fast-forward), тож коміт не блокуємо — лише вимагаємо change-файл на наявні зміни.
516
+ pass(
517
+ `${label}: version у ${mf} (${Vcurrent}) позаду опублікованої (${Vpublished}) — ` +
518
+ `локаль відстала від реєстру (зроби git pull); це не ручний bump`
519
+ )
520
+ await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
521
+ return
522
+ }
467
523
  pass(`${label}: ${name}@${Vcurrent} збігається з реєстром — перевіряємо git на незрелізні зміни`)
468
524
  await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
469
525
  }
@@ -480,10 +536,11 @@ async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel
480
536
  const label = workspaceLabel(manifest)
481
537
  const mf = manifestFilePath(manifest.ws, manifest)
482
538
  const Vcurrent = manifest.version
483
- // Drift version від бази має пріоритет над change-файлом: ручний bump заборонено
484
- // навіть якщо change-файл присутній (симетрично з published-шляхом).
539
+ // Лише drift УПЕРЕД (version > бази) має пріоритет над change-файлом: ручний bump
540
+ // заборонено навіть із change-файлом (симетрично з published-шляхом). Version позаду
541
+ // бази (гілка відстала від base-ref) — не порушення, не блокуємо.
485
542
  const Vbase = await readBaseVersion(comparisonRef, manifest, cwd)
486
- if (Vbase !== null && Vcurrent !== null && Vbase !== Vcurrent) {
543
+ if (Vbase !== null && Vcurrent !== null && versionIsAhead(Vcurrent, Vbase)) {
487
544
  fail(
488
545
  `${label}: version у ${mf} змінено поза CI (${Vbase} → ${Vcurrent}) — ручний bump заборонено (на ${baseLabel} — ${Vbase}). ` +
489
546
  `Відкоти version і поклади change-файл (npx @nitra/cursor change …); bump зробить CI (n-changelog.mdc)`
package/rules/ci4/ci4.mdc CHANGED
@@ -66,7 +66,7 @@ RAG витягує **фрагменти**, не цілі документи. Т
66
66
  docs/
67
67
  ├── adr/
68
68
  │ ├── <slug>.md # clean ADR-и (MADR v4 minimal, без frontmatter)
69
- │ ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
69
+ │ ├── YYMMDD-HHMM-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
70
70
  │ └── index.md # autogen — список accepted ADR за датою
71
71
  ├── explanation/ # Diátaxis: чому і що
72
72
  │ ├── overview.md # Capability Map (autogen)
@@ -86,7 +86,7 @@ docs/
86
86
 
87
87
  ## ADR як вхід проекцій
88
88
 
89
- Регенератор працює **тільки з clean ADR** (`docs/adr/<slug>.md`, без frontmatter, з рядком `**Status:** Accepted`). Drafts (`YYYYMMDD-HHMMSS-*.md`) ігноруються — їх перетворить у clean ADR `normalize-decisions.sh` із правила `adr`.
89
+ Регенератор працює **тільки з clean ADR** (`docs/adr/<slug>.md`, без frontmatter, з рядком `**Status:** Accepted`). Drafts (`YYMMDD-HHMM-*.md`) ігноруються — їх перетворить у clean ADR `normalize-decisions.sh` із правила `adr`.
90
90
 
91
91
  Поля, які регенератор читає з clean ADR:
92
92
 
@@ -26,6 +26,8 @@
26
26
  * - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
27
27
  * треба замінити на `await setTimeout(ms)` з `node:timers/promises`
28
28
  * (див. `utils/promise-settimeout-scan.mjs`);
29
+ * - «Temporal у Bun runtime»: identifier `Temporal` заборонений, бо поточний Bun runtime
30
+ * не має глобального Temporal API (див. `utils/temporal-scan.mjs`);
29
31
  * - «jsconfig.json»: у backend-пакеті з каталогом `src/` у корені має бути `jsconfig.json`,
30
32
  * вміст якого збігається з каноном js-run.mdc (NodeNext і include на дерево `src`).
31
33
  *
@@ -50,6 +52,7 @@ import {
50
52
  } from '../lib/conn-imports-scan.mjs'
51
53
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
52
54
  import { findPromiseSetTimeoutInText, isPromiseSetTimeoutScanSourceFile } from '../lib/promise-settimeout-scan.mjs'
55
+ import { findTemporalUsageInText, isTemporalScanSourceFile } from '../lib/temporal-scan.mjs'
53
56
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
54
57
  import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
55
58
 
@@ -313,6 +316,30 @@ async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, f
313
316
  return violations
314
317
  }
315
318
 
319
+ /**
320
+ * Сканує джерела пакета на `Temporal`, який у Bun runtime ще недоступний.
321
+ * @param {string} absPackageRoot абсолютний корінь пакета
322
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
323
+ * @param {string} label префікс повідомлення `[<pkg>] `
324
+ * @param {(msg: string) => void} fail callback при помилці
325
+ * @returns {Promise<number>} кількість порушень
326
+ */
327
+ async function checkTemporalUsage(absPackageRoot, sourcePaths, label, fail) {
328
+ let violations = 0
329
+ for (const absPath of sourcePaths) {
330
+ const rel = relPosix(absPackageRoot, absPath)
331
+ if (!isTemporalScanSourceFile(rel)) continue
332
+ const content = await readFile(absPath, 'utf8')
333
+ for (const v of findTemporalUsageInText(content, rel)) {
334
+ violations++
335
+ fail(
336
+ `${label}${rel}:${v.line} — Temporal API заборонений у Bun runtime; використовуй Date або інʼєктований timestamp`
337
+ )
338
+ }
339
+ }
340
+ return violations
341
+ }
342
+
316
343
  /**
317
344
  * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
318
345
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
@@ -371,6 +398,11 @@ async function checkWorkspacePackage(rootDir, ignorePaths, fail, passFn, cwd) {
371
398
  passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
372
399
  }
373
400
 
401
+ const temporalViolations = await checkTemporalUsage(absPackageRoot, sourcePaths, label, fail)
402
+ if (temporalViolations === 0) {
403
+ passFn(`${label}немає Temporal API у Bun runtime-коді`)
404
+ }
405
+
374
406
  checkOtelConfigmap(rootDir, passFn, cwd)
375
407
  }
376
408
 
@@ -30,6 +30,12 @@ version: '1.11'
30
30
 
31
31
  Це **не** стосується поля `engines.node` (мінімальна версія Node для сумісності інструментів) і **не** стосується frontend-пакетів з `vite` у `devDependencies`.
32
32
 
33
+ ## Temporal API
34
+
35
+ У backend/Bun runtime-коді **не використовуй `Temporal`** (`Temporal.Now`, `Temporal.Instant`, імпорти з polyfill тощо). Поточний Bun runtime ще не має глобального `Temporal` (`typeof Temporal === "undefined"`), тому агентам треба лишатися на сумісному `Date` API або передавати timestamp у чисті функції через параметр.
36
+
37
+ Перевірка `npx @nitra/cursor fix js-run` сканує JS/TS AST і падає на identifier `Temporal` у backend workspace-коді.
38
+
33
39
  Канон заборонених патернів у `scripts`: [package.json.deny.json](./policy/package_json/template/package.json.deny.json) (`scriptsForbidden`).
34
40
 
35
41
  ## Структура проекту
@@ -0,0 +1,52 @@
1
+ /**
2
+ * AST-сканер заборони `Temporal` у Bun runtime-коді.
3
+ *
4
+ * Bun 1.3.x ще не має глобального `Temporal`, тому правило js-run забороняє
5
+ * будь-який identifier `Temporal` у backend workspace-коді. Заборона свідомо
6
+ * охоплює polyfill/import-сценарії: у цьому репозиторії канон для часу лишається
7
+ * через `Date` або ін'єкцію timestamp у чисті функції.
8
+ */
9
+ import {
10
+ normalizeSnippet,
11
+ offsetToLine,
12
+ parseProgramOrNull,
13
+ walkAstWithAncestors
14
+ } from '../../../scripts/utils/ast-scan-utils.mjs'
15
+
16
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
17
+
18
+ /**
19
+ * Знаходить використання identifier `Temporal` у тексті.
20
+ * @param {string} content вихідний код
21
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
22
+ * @returns {{ line: number, snippet: string }[]} список порушень
23
+ */
24
+ export function findTemporalUsageInText(content, virtualPath = 'scan.ts') {
25
+ const program = parseProgramOrNull(content, virtualPath)
26
+ if (!program) return []
27
+ /** @type {{ line: number, snippet: string }[]} */
28
+ const out = []
29
+ /** @type {Set<string>} */
30
+ const seen = new Set()
31
+ walkAstWithAncestors(program, [], node => {
32
+ if (node.type !== 'Identifier' || node.name !== 'Temporal') return
33
+ const key = `${node.start}:${node.end}`
34
+ if (seen.has(key)) return
35
+ seen.add(key)
36
+ out.push({
37
+ line: offsetToLine(content, node.start),
38
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
39
+ })
40
+ })
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, виключно з `.d.ts`).
46
+ * @param {string} relativePath відносний шлях до файлу
47
+ * @returns {boolean} `true`, якщо розширення підходить для сканування
48
+ */
49
+ export function isTemporalScanSourceFile(relativePath) {
50
+ if (!SOURCE_FILE_RE.test(relativePath)) return false
51
+ return !relativePath.endsWith('.d.ts')
52
+ }
@@ -4,6 +4,8 @@
4
4
  * Кожен `npm/skills/<id>/` має містити валідний `meta.json`:
5
5
  * - `worktree` присутнє і boolean;
6
6
  * - `auto` (якщо присутнє) — розпізнане (`"завжди"` або непорожній масив рядків);
7
+ * - `requireRoot` (якщо присутнє) — boolean; не може бути `false` при `worktree:true`
8
+ * (worktree вже вимагає кореня — суперечність вводить в оману);
7
9
  * - залишковий `auto.md` заборонено (міграція на meta.json завершена).
8
10
  *
9
11
  * Концерн застосовний лише в репо самого пакета (де є `npm/skills/`); у споживача
@@ -52,6 +54,16 @@ export function check(cwd = process.cwd()) {
52
54
  reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
53
55
  skillOk = false
54
56
  }
57
+ if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
58
+ reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
59
+ skillOk = false
60
+ }
61
+ if (raw.worktree === true && raw.requireRoot === false) {
62
+ reporter.fail(
63
+ `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
64
+ )
65
+ skillOk = false
66
+ }
55
67
  if (skillOk) {
56
68
  reporter.pass(`skills/${id}: meta.json валідний`)
57
69
  }
@@ -1,11 +1,40 @@
1
1
  /**
2
- * `n-cursor change` — пише один change-файл `<ws>/.changes/<timestamp>-<rand>.md`.
2
+ * `n-cursor change` — пише один change-файл `<ws>/.changes/YYMMDD-HHMM.md`.
3
+ * Якщо файл за ту саму хвилину вже існує, додає `-2`, `-3` тощо.
3
4
  * Замінює ручне редагування CHANGELOG у feature-флоу (n-changelog.mdc v3.0).
4
5
  */
5
6
  import { mkdir, writeFile } from 'node:fs/promises'
6
7
  import { join } from 'node:path'
7
8
 
8
- import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
9
+ import { CHANGES_DIR, changeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
10
+
11
+ /**
12
+ * @param {unknown} error помилка `writeFile`
13
+ * @returns {boolean} true, якщо файл уже існує
14
+ */
15
+ function isFileExistsError(error) {
16
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST'
17
+ }
18
+
19
+ /**
20
+ * Записує change-файл create-only, додаючи числовий suffix лише при локальній колізії.
21
+ * @param {string} dir абсолютний шлях до `.changes`
22
+ * @param {string} content вміст change-файлу
23
+ * @param {number} timestamp epoch milliseconds
24
+ * @returns {Promise<string>} створене ім'я файла
25
+ */
26
+ async function writeUniqueChangeFile(dir, content, timestamp) {
27
+ for (let sequence = 1; ; sequence++) {
28
+ const name = changeFileName(timestamp, sequence)
29
+ try {
30
+ await writeFile(join(dir, name), content, { flag: 'wx' })
31
+ return name
32
+ } catch (error) {
33
+ if (isFileExistsError(error)) continue
34
+ throw error
35
+ }
36
+ }
37
+ }
9
38
 
10
39
  /**
11
40
  * @param {object} params параметри
@@ -14,9 +43,10 @@ import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile }
14
43
  * @param {string} params.message опис
15
44
  * @param {string} [params.ws] workspace (за замовчуванням `.`)
16
45
  * @param {string} [params.cwd] корінь
46
+ * @param {number} [params.timestamp] epoch milliseconds для детермінованих тестів
17
47
  * @returns {Promise<string>} відносний шлях створеного файлу (від ws)
18
48
  */
19
- export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd() }) {
49
+ export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd(), timestamp = Date.now() }) {
20
50
  const description = (message ?? '').trim()
21
51
  const content = serializeChangeFile({ bump, section, description })
22
52
  // Валідація полів: parseChangeFile кидає зрозумілу помилку на невалідних bump/section/порожньому описі.
@@ -24,8 +54,7 @@ export async function writeChange({ bump, section, message, ws = '.', cwd = proc
24
54
 
25
55
  const dir = join(cwd, ws, CHANGES_DIR)
26
56
  await mkdir(dir, { recursive: true })
27
- const name = newChangeFileName()
28
- await writeFile(join(dir, name), content)
57
+ const name = await writeUniqueChangeFile(dir, content, timestamp)
29
58
  return join(CHANGES_DIR, name)
30
59
  }
31
60
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Один change-файл `<ws>/.changes/<timestamp>-<rand>.md`: YAML-подібний frontmatter
2
+ * Один change-файл `<ws>/.changes/YYMMDD-HHMM.md`: YAML-подібний frontmatter
3
3
  * із двома ключами (`bump`, `section`) + текст опису. Парсер мінімальний — лише ці два
4
- * ключі, без зовнішніх залежностей.
4
+ * ключі, без зовнішніх залежностей. Якщо файл за ту саму хвилину вже існує, writer додає
5
+ * числовий suffix (`-2`, `-3`) атомарним create-only записом.
5
6
  */
6
7
 
7
- import { randomBytes } from 'node:crypto'
8
8
  import { existsSync } from 'node:fs'
9
9
  import { readdir, readFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
@@ -65,21 +65,36 @@ export function serializeChangeFile(entry) {
65
65
  export const CHANGES_DIR = '.changes'
66
66
 
67
67
  /**
68
- * @param {number} timestamp `Date.now()`
69
- * @param {string} suffix короткий випадковий суфікс (hex)
70
- * @returns {string} `<timestamp>-<suffix>.md`
68
+ * @param {number} timestamp epoch milliseconds
69
+ * @returns {string} local timestamp prefix `YYMMDD-HHMM`
71
70
  */
72
- export function changeFileName(timestamp, suffix) {
73
- return `${timestamp}-${suffix}.md`
71
+ function formatChangeTimestamp(timestamp) {
72
+ const d = new Date(timestamp)
73
+ const yy = String(d.getFullYear()).slice(-2)
74
+ const month = String(d.getMonth() + 1).padStart(2, '0')
75
+ const day = String(d.getDate()).padStart(2, '0')
76
+ const hour = String(d.getHours()).padStart(2, '0')
77
+ const minute = String(d.getMinutes()).padStart(2, '0')
78
+ return `${yy}${month}${day}-${hour}${minute}`
74
79
  }
75
80
 
76
81
  /**
77
- * Унікальне ім'я для нового change-файлу: timestamp (порядок) + rand (анти-колізія
78
- * для паралельних агентів у різних worktree, що пишуть у ту саму мілісекунду).
82
+ * @param {number} timestamp epoch milliseconds
83
+ * @param {number} [sequence] collision sequence; `1`/omitted has no suffix
84
+ * @returns {string} `YYMMDD-HHMM.md` or `YYMMDD-HHMM-<n>.md`
85
+ */
86
+ export function changeFileName(timestamp, sequence = 1) {
87
+ const base = formatChangeTimestamp(timestamp)
88
+ return sequence > 1 ? `${base}-${sequence}.md` : `${base}.md`
89
+ }
90
+
91
+ /**
92
+ * Базове ім'я для нового change-файлу. Унікальність забезпечує writer: він спершу
93
+ * пробує `YYMMDD-HHMM.md`, а suffix додає лише при локальному `EEXIST`.
79
94
  * @returns {string} результат
80
95
  */
81
96
  export function newChangeFileName() {
82
- return changeFileName(Date.now(), randomBytes(3).toString('hex'))
97
+ return changeFileName(Date.now())
83
98
  }
84
99
 
85
100
  /**
@@ -50,15 +50,22 @@ function safeRealpath(dir) {
50
50
 
51
51
  /**
52
52
  * Кидає помилку, якщо `dir` — піддиректорія git-репозиторію (тобто не його
53
- * корінь). Поза git-репо (немає toplevel) — пропускає без помилки.
53
+ * корінь). Поза git-репо (немає toplevel) — пропускає без помилки. У git-worktree
54
+ * (`.worktrees/<branch>/`) toplevel = корінь самого worktree, тож запуск із нього
55
+ * проходить — гард ловить лише старт із піддиректорії робочого дерева.
54
56
  *
55
- * Викликати лише перед дефолтним sync (до перших мутацій файлів), не для
57
+ * Викликати перед мутаційними діями (default sync, `fix`, `lint`, `coverage`,
58
+ * `change`, `release`), які скаффолдять / переписують файли в CWD. Не для
56
59
  * підкоманд із власним `--root` чи read-only-логікою.
57
60
  * @param {string} [dir] каталог, що перевіряємо (типово `cwd()`)
61
+ * @param {string} [action] людинозрозумілий опис дії для тексту помилки
58
62
  * @throws {Error} коли `dir` всередині git-репо, але не його корінь
59
63
  * @returns {void}
60
64
  */
61
- export function assertCwdIsProjectRoot(dir = cwd()) {
65
+ export function assertCwdIsProjectRoot(
66
+ dir = cwd(),
67
+ action = 'Команда @nitra/cursor мутує проєкт у поточному каталозі'
68
+ ) {
62
69
  const top = gitToplevel(dir)
63
70
  if (top === null) return
64
71
  const here = safeRealpath(dir)
@@ -67,8 +74,7 @@ export function assertCwdIsProjectRoot(dir = cwd()) {
67
74
  `❌ @nitra/cursor запущено не в корені проєкту.\n` +
68
75
  ` Поточний каталог: ${here}\n` +
69
76
  ` Корінь git-репо: ${top}\n` +
70
- ` Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json\n` +
71
- ` і робить bun install у поточному каталозі із піддиректорії це розкидало б\n` +
72
- ` конфіг не туди. Перейдіть у корінь репозиторію: cd ${top}`
77
+ ` ${action} із піддиректорії це зачепило б не той каталог.\n` +
78
+ ` Перейдіть у корінь репозиторію: cd ${top}`
73
79
  )
74
80
  }
@@ -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
+ }
@@ -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 абсолютний шлях до каталогу скіла
@@ -18,66 +18,65 @@
18
18
  */
19
19
 
20
20
  /** Маркер початку worktree-блоку (стабільний, не залежить від тексту всередині). */
21
- export const WORKTREE_START = "<!-- n-cursor:worktree:start -->";
21
+ export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
22
22
  /** Маркер кінця worktree-блоку. */
23
- export const WORKTREE_END = "<!-- n-cursor:worktree:end -->";
23
+ export const WORKTREE_END = '<!-- n-cursor:worktree:end -->'
24
24
 
25
- const FALLBACK_SUFFIX = "task";
25
+ const FALLBACK_SUFFIX = 'task'
26
26
 
27
27
  /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
28
- const BLOCK_RE =
29
- /\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
30
29
 
31
30
  /** Закриття YAML-frontmatter на початку файла. */
32
- const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u;
31
+ const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u
33
32
 
34
33
  /** Значення `name` з YAML-frontmatter. */
35
- const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu;
34
+ const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu
36
35
 
37
36
  /** Перший H1 як fallback, якщо frontmatter не містить `name`. */
38
- const H1_RE = /^#\s+(.+)$/mu;
37
+ const H1_RE = /^#\s+(.+)$/mu
39
38
 
40
39
  const CYRILLIC_TRANSLIT = new Map(
41
40
  Object.entries({
42
- а: "a",
43
- б: "b",
44
- в: "v",
45
- г: "h",
46
- ґ: "g",
47
- д: "d",
48
- е: "e",
49
- є: "ye",
50
- ж: "zh",
51
- з: "z",
52
- и: "y",
53
- і: "i",
54
- ї: "yi",
55
- й: "y",
56
- к: "k",
57
- л: "l",
58
- м: "m",
59
- н: "n",
60
- о: "o",
61
- п: "p",
62
- р: "r",
63
- с: "s",
64
- т: "t",
65
- у: "u",
66
- ф: "f",
67
- х: "kh",
68
- ц: "ts",
69
- ч: "ch",
70
- ш: "sh",
71
- щ: "shch",
72
- ь: "",
73
- ю: "yu",
74
- я: "ya",
75
- ы: "y",
76
- э: "e",
77
- ё: "yo",
78
- ъ: "",
79
- }),
80
- );
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
+ )
81
80
 
82
81
  /**
83
82
  * Транслітерує кирилицю в ASCII для короткого suffix.
@@ -85,8 +84,7 @@ const CYRILLIC_TRANSLIT = new Map(
85
84
  * @returns {string} транслітерований текст
86
85
  */
87
86
  function transliterate(value) {
88
- return Array.from(value.toLowerCase(), (char) => CYRILLIC_TRANSLIT.get(char) ?? char)
89
- .join("");
87
+ return Array.from(value.toLowerCase(), char => CYRILLIC_TRANSLIT.get(char) ?? char).join('')
90
88
  }
91
89
 
92
90
  /**
@@ -95,20 +93,16 @@ function transliterate(value) {
95
93
  * @returns {string} suffix до 10 символів
96
94
  */
97
95
  function deriveSuffix(content) {
98
- const raw =
99
- 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
100
97
  const slug = transliterate(raw)
101
98
  .trim()
102
- .replace(/^n-/u, "")
103
- .normalize("NFKD")
104
- .replaceAll(/[\u0300-\u036F]/gu, "")
105
- .replaceAll(/[^a-z0-9]+/gu, "-")
106
- .replaceAll(/^-+|-+$/gu, "");
107
-
108
- return (
109
- (slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, "") ||
110
- FALLBACK_SUFFIX
111
- );
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
112
106
  }
113
107
 
114
108
  /**
@@ -123,11 +117,14 @@ function buildNoticeBody(suffix) {
123
117
  **Крок 0 — preflight (обовʼязковий, перед будь-якими іншими діями).** Якщо перевірка падає — **STOP**: не питай користувача про назву гілки, а сам створи worktree від поточної гілки за конвенцією \`<current-branch>-${suffix}\`. Суфікс \`${suffix}\` — коротка (до 10 символів) транслітерація задачі. Не виконуй **жоден** наступний крок скіла, поки preflight не завершився успіхом.
124
118
 
125
119
  \`\`\`bash
120
+ pwd
126
121
  git rev-parse --show-toplevel
127
122
  git branch --show-current
128
123
  \`\`\`
129
124
 
130
- Якщо перша команда показала, що ти **не** в \`.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\`:
131
128
 
132
129
  \`\`\`bash
133
130
  npx @nitra/cursor worktree add "feature/x-${suffix}" "n-${suffix}: worktree-only skill"
@@ -175,7 +172,7 @@ n_cursor_npx() {
175
172
  }
176
173
  \`\`\`
177
174
 
178
- Усі подальші bootstrap-виклики \`npx @nitra/cursor <cmd>\` у цій сесії роби через \`n_cursor_npx <cmd>\`. Якщо опинився у свіжому shell без цієї функції — спершу повтори блок вище (\`bun install\` + визначення \`n_cursor_npx\`).`;
175
+ Усі подальші bootstrap-виклики \`npx @nitra/cursor <cmd>\` у цій сесії роби через \`n_cursor_npx <cmd>\`. Якщо опинився у свіжому shell без цієї функції — спершу повтори блок вище (\`bun install\` + визначення \`n_cursor_npx\`).`
179
176
  }
180
177
 
181
178
  /**
@@ -184,7 +181,7 @@ n_cursor_npx() {
184
181
  * @returns {string} текст блоку від START до END
185
182
  */
186
183
  function buildBlock(content) {
187
- return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`;
184
+ return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`
188
185
  }
189
186
 
190
187
  /**
@@ -194,19 +191,19 @@ function buildBlock(content) {
194
191
  * @returns {string} оновлений вміст (ідемпотентно)
195
192
  */
196
193
  export function injectWorktreeNotice(content, enabled) {
197
- const hadBlock = content.includes(WORKTREE_START);
198
- const withoutBlock = content.replace(BLOCK_RE, "\n\n");
194
+ const hadBlock = content.includes(WORKTREE_START)
195
+ const withoutBlock = content.replace(BLOCK_RE, '\n\n')
199
196
 
200
197
  if (!enabled) {
201
- return hadBlock ? withoutBlock : content;
198
+ return hadBlock ? withoutBlock : content
202
199
  }
203
200
 
204
- const block = buildBlock(withoutBlock);
205
- const fm = withoutBlock.match(FRONTMATTER_RE);
201
+ const block = buildBlock(withoutBlock)
202
+ const fm = withoutBlock.match(FRONTMATTER_RE)
206
203
  if (fm) {
207
- const head = fm[1];
208
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, "");
209
- 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}`
210
207
  }
211
- return `${block}\n\n${withoutBlock.replace(/^\n+/u, "")}`;
208
+ return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
212
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
  */
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": false }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": false }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": true }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": false, "requireRoot": false }