@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.
- package/.claude-template/hooks/capture-decisions.sh +1 -1
- package/.claude-template/hooks/normalize-decisions.sh +8 -4
- package/CHANGELOG.md +15 -0
- package/bin/n-cursor.js +46 -5
- package/package.json +1 -1
- package/rules/adr/adr.mdc +5 -5
- package/rules/adr/js/templates/hooks/.gitignore.snippet +1 -0
- package/rules/changelog/changelog.mdc +1 -1
- package/rules/changelog/js/consistency.mjs +69 -12
- package/rules/ci4/ci4.mdc +2 -2
- package/rules/js-run/js/runtime.mjs +32 -0
- package/rules/js-run/js-run.mdc +6 -0
- package/rules/js-run/lib/temporal-scan.mjs +52 -0
- package/rules/npm-module/js/skill_meta.mjs +12 -0
- package/rules/release/change.mjs +34 -5
- package/rules/release/lib/change-file.mjs +26 -11
- package/scripts/lib/assert-project-root.mjs +12 -6
- package/scripts/lib/root-notice.mjs +64 -0
- package/scripts/lib/skill-meta.mjs +16 -2
- package/scripts/lib/worktree-notice.mjs +70 -73
- package/scripts/sync-claude-config.mjs +2 -2
- package/skills/llm-patch/meta.json +1 -1
- package/skills/publish-telegram/meta.json +1 -1
- package/skills/start-check/meta.json +1 -1
- package/skills/worktree/meta.json +1 -1
|
@@ -199,7 +199,7 @@ if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
|
|
|
199
199
|
exit 0
|
|
200
200
|
fi
|
|
201
201
|
|
|
202
|
-
TS=$(date +%
|
|
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
|
|
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.
|
|
413
|
-
#
|
|
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
|
|
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
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1470
|
-
|
|
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
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-іменем `
|
|
22
|
-
- **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `
|
|
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-файлу скрипт додає `
|
|
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
|
-
├──
|
|
90
|
-
└──
|
|
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 з пакета
|
|
@@ -8,7 +8,7 @@ alwaysApply: true
|
|
|
8
8
|
|
|
9
9
|
> **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
|
|
10
10
|
|
|
11
|
-
1. **Поклади change-файл** `<ws>/.changes
|
|
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` не має
|
|
7
|
-
* `version`
|
|
8
|
-
* перевірку, навіть
|
|
9
|
-
* не
|
|
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
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
501
|
+
// Лише drift УПЕРЕД (version > опублікованої) — ручний bump поза CI; має пріоритет
|
|
502
|
+
// над change-файлом (симетрично з local-only-шляхом). Версія ПОЗАДУ реєстру — локаль
|
|
503
|
+
// відстала від уже опублікованого релізу, не порушення (нижче).
|
|
504
|
+
if (versionIsAhead(Vcurrent, Vpublished)) {
|
|
460
505
|
fail(
|
|
461
|
-
`${label}: version у ${mf} (${Vcurrent})
|
|
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
|
-
//
|
|
484
|
-
// навіть
|
|
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
|
|
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
|
-
│ ├──
|
|
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 (`
|
|
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
|
|
package/rules/js-run/js-run.mdc
CHANGED
|
@@ -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
|
}
|
package/rules/release/change.mjs
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n-cursor change` — пише один change-файл `<ws>/.changes
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
69
|
-
* @
|
|
70
|
-
* @returns {string} `<timestamp>-<suffix>.md`
|
|
68
|
+
* @param {number} timestamp epoch milliseconds
|
|
69
|
+
* @returns {string} local timestamp prefix `YYMMDD-HHMM`
|
|
71
70
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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()
|
|
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
|
-
* Викликати
|
|
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(
|
|
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
|
-
`
|
|
71
|
-
`
|
|
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 =
|
|
21
|
+
export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
|
|
22
22
|
/** Маркер кінця worktree-блоку. */
|
|
23
|
-
export const WORKTREE_END =
|
|
23
|
+
export const WORKTREE_END = '<!-- n-cursor:worktree:end -->'
|
|
24
24
|
|
|
25
|
-
const FALLBACK_SUFFIX =
|
|
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
|
-
а:
|
|
43
|
-
б:
|
|
44
|
-
в:
|
|
45
|
-
г:
|
|
46
|
-
ґ:
|
|
47
|
-
д:
|
|
48
|
-
е:
|
|
49
|
-
є:
|
|
50
|
-
ж:
|
|
51
|
-
з:
|
|
52
|
-
и:
|
|
53
|
-
і:
|
|
54
|
-
ї:
|
|
55
|
-
й:
|
|
56
|
-
к:
|
|
57
|
-
л:
|
|
58
|
-
м:
|
|
59
|
-
н:
|
|
60
|
-
о:
|
|
61
|
-
п:
|
|
62
|
-
р:
|
|
63
|
-
с:
|
|
64
|
-
т:
|
|
65
|
-
у:
|
|
66
|
-
ф:
|
|
67
|
-
х:
|
|
68
|
-
ц:
|
|
69
|
-
ч:
|
|
70
|
-
ш:
|
|
71
|
-
щ:
|
|
72
|
-
ь:
|
|
73
|
-
ю:
|
|
74
|
-
я:
|
|
75
|
-
ы:
|
|
76
|
-
э:
|
|
77
|
-
ё:
|
|
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(),
|
|
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(
|
|
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
|
-
Якщо
|
|
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,
|
|
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 }
|