@7n/rules 1.8.4 → 1.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0] - 2026-07-17
4
+
5
+ ### Added
6
+
7
+ - `skill pi/cursor/codex taze` — Rust-гілку (`Cargo.toml`) тепер теж покриває оркестратор: детермінований `collectCargoDiff` (`npm/skills/taze/js/cargo-diff.mjs`, парсинг через `smol-toml`, той самий caret-семантики принцип, що й для npm) замість ручної класифікації, і по одному ізольованому виклику раннера на кожен major-крейт. Якщо `cargo-edit` не встановлено — Rust-гілка пропускається без блокування npm-гілки (перелік Cargo.toml лишається в звіті)
8
+
9
+ ## [1.9.0] - 2026-07-17
10
+
11
+ ### Added
12
+
13
+ - `skill pi/cursor/codex taze` тепер виконується через оркестратор (`npm/skills/taze/js/orchestrate.mjs`) замість одного величезного непрозорого ходу на весь монорепо: детерміновані кроки (бекап/масовий bump/diff/прибирання) без LLM, і по одному ізольованому, обмеженому виклику раннера на кожен major-пакет — падіння/timeout одного пакета не втрачає прогрес по інших
14
+
3
15
  ## [1.8.4] - 2026-07-17
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.8.4",
3
+ "version": "1.10.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: skills-cli.mjs
4
4
  resource: npm/scripts/skills-cli.mjs
5
5
  docgen:
6
- crc: 1a8f83e4
6
+ crc: 12d3bf6d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -8,6 +8,13 @@
8
8
  * (napi-міст до `llm_cascade::acp`, без власного JSON-RPC у JS); deprecated
9
9
  * `claude` — окремий JS-шим (`./lib/acp-runner.mjs`), бо Rust-крейт його не моделює.
10
10
  *
11
+ * `skill <runner> taze` — виняток із загального шляху "весь SKILL.md одним промптом":
12
+ * делегує в `../skills/taze/js/orchestrate.mjs`, який детерміновано (без LLM) робить
13
+ * бекап/масовий bump/diff/прибирання і лише по одному ОБМЕЖЕНОМУ виклику `<runner>`
14
+ * на кожен major-пакет — замість одного величезного непрозорого ходу на весь монорепо
15
+ * (той, single-shot, раніше зависав без діагностики; per-пакет виклики успадковують
16
+ * власний timeout раннера, і падіння одного пакета не втрачає прогрес по інших).
17
+ *
11
18
  * Підтримувані формати:
12
19
  * `npx \@7n/rules skill list`
13
20
  * `npx \@7n/rules skill taze`
@@ -202,6 +209,32 @@ async function runLlmCli(kind, prompt, projectDir, logError, deps = {}) {
202
209
  }
203
210
  }
204
211
 
212
+ /**
213
+ * Виконує `taze` через оркестратор (`../skills/taze/js/orchestrate.mjs`) замість
214
+ * загального одноходового шляху — детерміновані кроки без LLM (бекап/bump/diff/
215
+ * прибирання) + по одному обмеженому виклику обраного `runner` на кожен major-пакет.
216
+ * @param {'pi' | 'cursor' | 'codex'} runner раннер для per-пакетних викликів
217
+ * @param {string} projectDir корінь проєкту (де лежить package.json)
218
+ * @param {(line: string) => void} log вивід прогресу/звіту
219
+ * @param {(line: string) => void} logError вивід помилок
220
+ * @param {{ runTazeOrchestrator?: (opts: object) => Promise<{ ok: boolean, report: string }> }} [deps] інжект для тестів
221
+ * @returns {Promise<number>} exit code (0 — усі major-пакети ok)
222
+ */
223
+ async function runTazeOrchestratorCli(runner, projectDir, log, logError, deps = {}) {
224
+ let orchestrate = deps.runTazeOrchestrator
225
+ if (!orchestrate) {
226
+ const orchestrateModule = await import('../skills/taze/js/orchestrate.mjs')
227
+ orchestrate = orchestrateModule.runTazeOrchestrator
228
+ }
229
+ try {
230
+ const result = await orchestrate({ cwd: projectDir, runner, log, deps })
231
+ return result.ok ? 0 : 1
232
+ } catch (error) {
233
+ logError(error instanceof Error ? error.message : String(error))
234
+ return 1
235
+ }
236
+ }
237
+
205
238
  /**
206
239
  * Корінь пакета `@7n/rules` (каталог з `skills/`, `rules/`, …).
207
240
  * @param {string} [fromModuleUrl] для тестів — `import.meta.url`, відносно якого шукати корінь
@@ -245,6 +278,15 @@ export async function runSkillsCli(argv, options = {}) {
245
278
  if (!second) {
246
279
  throw new Error(`Skill name is required after "${first}"`)
247
280
  }
281
+ if (first !== 'claude' && normalizeSkillId(second) === 'taze') {
282
+ return await runTazeOrchestratorCli(
283
+ /** @type {'pi' | 'cursor' | 'codex'} */ (first),
284
+ projectDir,
285
+ log,
286
+ logError,
287
+ deps
288
+ )
289
+ }
248
290
  const task = rest.join(' ')
249
291
  const prompt = buildSkillPrompt(skillsRoot, second, task, projectDir)
250
292
  if (first === 'pi') {
@@ -13,12 +13,35 @@ version: '1.1'
13
13
 
14
14
  Оновити всі модулі проекту (npm/bun-залежності, а за наявності `Cargo.toml` — і Rust-крейти) до останніх версій, виявити major-оновлення, перевірити сумісність змін з кодом проекту і за потреби зрефакторити несумісні місця.
15
15
 
16
+ ## Оркестрація (npm/bun- і Rust-гілка) — не одним промптом
17
+
18
+ `npx @7n/rules skill pi|cursor|codex taze` **не** передає цей файл одним суцільним
19
+ промптом в один агентський хід — так робив старий дизайн, і саме тому реальний
20
+ прогін міг зависати без жодної діагностики (один величезний непрозорий хід на
21
+ весь монорепо, без проміжних зупинок для перевірки прогресу). Замість цього `npm/skills/taze/js/orchestrate.mjs`:
22
+
23
+ 1. Детерміновано, без LLM, виконує кроки 1-3 нижче для обох гілок: npm/bun (бекап → `bunx taze -w -r latest` → `bun install` → `n-rules taze diff`) і, якщо знайдені `Cargo.toml`, Rust (бекап → `cargo upgrade --incompatible allow` → `cargo update` → `collectCargoDiff` з `cargo-diff.mjs`, детермінований cargo-еквівалент `n-rules taze diff` — парсить кожен `Cargo.toml` через `smol-toml`, класифікує major за тим самим правилом caret-семантики, включно з Cargo-скороченими версіями `"1"`/`"0.4"`).
24
+ 2. Для кожного **окремого** major-пакета/крейта з diff-у — один ізольований, обмежений виклик обраного раннера (кроки 4-6, лише для цього запису; промпт генерує `buildDependencyPrompt`/`buildCargoDependencyPrompt`, не цей SKILL.md).
25
+ 3. Детерміновано прибирає бекапи (крок 7) і компонує звіт (крок 8) з результатів усіх ітерацій.
26
+
27
+ Якщо `Cargo.toml` знайдені, але `cargo-edit` не встановлено — Rust-гілка **не**
28
+ блокує npm-гілку: оркестратор пропускає Rust-обробку і в звіті перелічує
29
+ знайдені `Cargo.toml` як такі, що потребують ручного прогону (нижче).
30
+
31
+ Переваги: падіння/timeout на одному пакеті/крейті не втрачає прогрес по інших;
32
+ кожен виклик успадковує власний timeout раннера (короткий, бо скоуп малий —
33
+ один запис, не весь монорепо); видно прогрес по записах, а не чорну скриньку.
34
+
35
+ Кроки 1-8 нижче лишаються джерелом правди щодо ЗМІСТУ роботи (що саме робить
36
+ кожен крок) — оркестратор їх виконує програмно (1-3/7/8) або як промпт на
37
+ один запис (4-6) для обох гілок.
38
+
16
39
  ## Передумови
17
40
 
18
41
  - Чисте робоче дерево (`git status` без незакомічених змін у `package.json` / `bun.lock` / `node_modules`) — інакше різницю не відрізнити від оновлення.
19
42
  - Встановлений `bun` і доступний `bunx`.
20
43
  - Запуск з кореня проекту (де лежить `package.json` / `bun.lock`).
21
- - Якщо в проекті є хоч один `Cargo.toml` (не в `node_modules`/`.worktrees`) — додатково встановлений `cargo-edit` (`cargo install cargo-edit`, дає команду `cargo upgrade`). Без нього major-бампи Rust-залежностей неможливо застосувати детерміновано (голий `cargo update` піднімає лише semver-сумісні версії) — **STOP** і попроси користувача встановити перед продовженням кроку 2 для Rust-гілки.
44
+ - Якщо в проекті є хоч один `Cargo.toml` (не в `node_modules`/`.worktrees`) — бажано встановлений `cargo-edit` (`cargo install cargo-edit`, дає команду `cargo upgrade`). Без нього major-бампи Rust-залежностей неможливо застосувати детерміновано (голий `cargo update` піднімає лише semver-сумісні версії) — оркестратор пропускає Rust-гілку (без блокування npm-гілки) і перелічує знайдені `Cargo.toml` у звіті як такі, що потребують ручного прогону кроків 2-8 нижче.
22
45
 
23
46
  ### 0.2. Детекція Rust-крейтів
24
47
 
@@ -81,7 +104,7 @@ n-rules taze diff
81
104
 
82
105
  Покриває **прямі** залежності з `package.json` (root + воркспейси). Транзитивні major-стрибки (`bun.lock`) — за потреби переглянь окремо; основний ризик breaking-змін — у прямих.
83
106
 
84
- **Rust-гілка**для `n-rules taze diff` немає cargo-еквівалента, класифікація ручна: для кожного `Cargo.toml.taze-bak` порівняти версію кожної залежності зі свіжим `Cargo.toml` за тим самим правилом (зміна найлівішої ненульової semver-компоненти = major). Швидкий спосіб — `diff Cargo.toml.taze-bak Cargo.toml` по рядках `<name> = "<version>"` і вручну класифікувати кожну зміну.
107
+ **Rust-гілка (оркестровано)** `collectCargoDiff` (`npm/skills/taze/js/cargo-diff.mjs`) робить те саме детерміновано: парсить кожен `Cargo.toml.taze-bak`/`Cargo.toml` через `smol-toml`, порівнює `dependencies`/`dev-dependencies`/`build-dependencies` (рядок чи `{ version = "...", features = [...] }`), класифікує за тим самим правилом caret-семантики — Cargo-скорочені версії (`"1"`, `"0.4"`) трактуються як відсутні компоненти = 0. Ручний прогін поза оркестратором той самий принцип: `diff Cargo.toml.taze-bak Cargo.toml` по рядках `<name> = "<version>"`.
85
108
 
86
109
  ### 4. Зібрати breaking changes по кожному major-оновленню
87
110
 
@@ -0,0 +1,117 @@
1
+ /** @see ./docs/cargo-diff.md */
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { parse as parseToml } from 'smol-toml'
6
+
7
+ import { isBreaking } from './diff.mjs'
8
+
9
+ /** Поля Cargo.toml із залежностями, які порівнюємо (аналог DEP_FIELDS у diff.mjs). */
10
+ const CARGO_DEP_FIELDS = ['dependencies', 'dev-dependencies', 'build-dependencies']
11
+
12
+ /** Дефолтний суфікс бекапу — той самий, що й для package.json (крок 1 SKILL.md, Rust-гілка). */
13
+ const DEFAULT_BACKUP_SUFFIX = '.taze-bak'
14
+
15
+ // 1-3 числові компоненти (`"1"`, `"0.4"`, `"1.2.3"`), опційний `=`/`~`/`^`/`>=`-префікс.
16
+ //Cargo трактує відсутні компоненти як 0 і для матчингу, і для caret-сумісності —
17
+ // той самий "найлівіша ненульова компонента" принцип, що й у isBreaking з diff.mjs.
18
+ const CARGO_VERSION_RE = /^[\s=~^><]*(\d+)(?:\.(\d+))?(?:\.(\d+))?/
19
+
20
+ /**
21
+ * Парсить Cargo-версійний specifier (1-3 компоненти, відсутні → 0).
22
+ * @param {string} spec версійний specifier із Cargo.toml (`"1"`, `"0.4.2"`, `"=1.2.3"`)
23
+ * @returns {{major:number, minor:number, patch:number}|null} ядро або null для не-semver
24
+ */
25
+ export function parseCargoVersion(spec) {
26
+ if (typeof spec !== 'string') return null
27
+ const m = CARGO_VERSION_RE.exec(spec)
28
+ if (!m) return null
29
+ return { major: Number(m[1]), minor: Number(m[2] ?? 0), patch: Number(m[3] ?? 0) }
30
+ }
31
+
32
+ /**
33
+ * Дістає версійний specifier одного запису залежності Cargo.toml — рядок
34
+ * напряму (`tokio = "1"`), або поле `version` inline-таблиці
35
+ * (`serde = { version = "1", features = [...] }`).
36
+ * @param {unknown} value значення запису залежності
37
+ * @returns {string|null} specifier, або null для path/git-залежності (без номера версії)
38
+ */
39
+ export function extractCargoVersionSpec(value) {
40
+ if (typeof value === 'string') return value
41
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.version === 'string') {
42
+ return value.version
43
+ }
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * Порівнює два розпарсені Cargo.toml і повертає зміни залежностей —
49
+ * той самий контракт, що й `diffPackageJson` у diff.mjs, лише для Cargo.
50
+ * @param {object} oldManifest розпарсений старий Cargo.toml (бекап)
51
+ * @param {object} newManifest розпарсений новий Cargo.toml
52
+ * @param {string} manifest відносний шлях Cargo.toml (мітка джерела запису)
53
+ * @returns {{major: Array<{manifest:string, pkg:string, from:string, to:string}>, minorPatch:number}} зміни
54
+ */
55
+ export function diffCargoToml(oldManifest, newManifest, manifest) {
56
+ const major = []
57
+ let minorPatch = 0
58
+ for (const field of CARGO_DEP_FIELDS) {
59
+ const oldDeps = oldManifest?.[field]
60
+ const newDeps = newManifest?.[field]
61
+ if (!oldDeps || !newDeps) continue
62
+ for (const [crate, oldValue] of Object.entries(oldDeps)) {
63
+ const newValue = newDeps[crate]
64
+ if (newValue === undefined) continue
65
+ const from = extractCargoVersionSpec(oldValue)
66
+ const to = extractCargoVersionSpec(newValue)
67
+ if (from === null || to === null || from === to) continue
68
+ const fromV = parseCargoVersion(from)
69
+ const toV = parseCargoVersion(to)
70
+ if (fromV && toV && isBreaking(fromV, toV)) {
71
+ major.push({ manifest, pkg: crate, from, to })
72
+ } else {
73
+ minorPatch += 1
74
+ }
75
+ }
76
+ }
77
+ return { major, minorPatch }
78
+ }
79
+
80
+ /**
81
+ * Читає й парсить TOML-файл, або повертає null, якщо файл відсутній/невалідний.
82
+ * @param {string} path абсолютний шлях
83
+ * @returns {Promise<object|null>} розпарсений обʼєкт або null
84
+ */
85
+ async function readTomlOrNull(path) {
86
+ if (!existsSync(path)) return null
87
+ try {
88
+ return parseToml(await readFile(path, 'utf8'))
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Збирає diff по всіх Cargo.toml монорепо: для кожного шляху зі списку
96
+ * порівнює `<manifest>` з `<manifest><backupSuffix>` — той самий контракт,
97
+ * що й `collectTazeDiff` для package.json.
98
+ * @param {string} cwd корінь репозиторію
99
+ * @param {string[]} manifestPaths відносні шляхи Cargo.toml (з `findCargoManifests`)
100
+ * @param {string} [backupSuffix] суфікс бекап-файлу
101
+ * @returns {Promise<{major: Array<{manifest:string, pkg:string, from:string, to:string}>, minorPatch:number, totalChanged:number, comparedManifests:number}>} агрегований diff
102
+ */
103
+ export async function collectCargoDiff(cwd, manifestPaths, backupSuffix = DEFAULT_BACKUP_SUFFIX) {
104
+ const major = []
105
+ let minorPatch = 0
106
+ let comparedManifests = 0
107
+ for (const manifest of manifestPaths) {
108
+ const oldManifest = await readTomlOrNull(join(cwd, `${manifest}${backupSuffix}`))
109
+ const newManifest = await readTomlOrNull(join(cwd, manifest))
110
+ if (!oldManifest || !newManifest) continue
111
+ comparedManifests += 1
112
+ const res = diffCargoToml(oldManifest, newManifest, manifest)
113
+ major.push(...res.major)
114
+ minorPatch += res.minorPatch
115
+ }
116
+ return { major, minorPatch, totalChanged: major.length + minorPatch, comparedManifests }
117
+ }
@@ -0,0 +1,41 @@
1
+ ---
2
+ type: JS Module
3
+ title: cargo-diff.mjs
4
+ resource: npm/skills/taze/js/cargo-diff.mjs
5
+ docgen:
6
+ crc: afe20497
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ `package.json`
17
+
18
+ Файл читає версії з `Cargo.toml`, виділяє специфікації залежностей, порівнює дві версії маніфесту та збирає diff по змінах версій. Для монорепо проходить по списку `Cargo.toml` і агрегує результати для всіх знайдених маніфестів. Усі помилки перехоплює fail-safe: не кидає винятків назовні й за окремих збоїв повертає порожнє значення замість помилки.
19
+
20
+ ## Поведінка
21
+
22
+ - parseCargoVersion — виділяє версійну частину Cargo-специфікатора й повертає її як нормалізоване числове ядро; для не-версійних значень повертає null.
23
+ - extractCargoVersionSpec — дістає версійний specifier із запису залежності Cargo.toml; для path/git-залежностей без версії повертає null.
24
+ - diffCargoToml — порівнює два розпарсені Cargo.toml і визначає зміни залежностей, розділяючи їх на major та minor/patch.
25
+ - collectCargoDiff — проходить по списку Cargo.toml у монорепо, зіставляє кожен файл із його backup-файлом і збирає агрегований diff; якщо один із файлів відсутній або невалідний, пропускає його без падіння.
26
+
27
+ ## Публічний API
28
+
29
+ - Конфіги, на які спирається код: `package.json` — джерело списку залежностей і меж для порівняння змін.
30
+ - `parseCargoVersion` — розбирає Cargo-версійний specifier і заповнює відсутні частини нулями.
31
+ - `extractCargoVersionSpec` — дістає версію залежності з запису `Cargo.toml`, як з короткого рядка, так і з inline-таблиці.
32
+ - `diffCargoToml` — порівнює два розібрані `Cargo.toml` і повертає зміни по залежностях.
33
+ - `collectCargoDiff` — збирає diff для всіх `Cargo.toml` у монорепо, зіставляючи кожен маніфест із його backup-версією.
34
+
35
+ Changelog: не перевіряв — змін у файлах не вносив.
36
+
37
+ ## Гарантії поведінки
38
+
39
+ - Read-only: не виконує операцій запису (ФС/БД).
40
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
41
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -4,8 +4,8 @@ title: npm/skills/taze/js
4
4
  resource: npm/skills/taze/js/
5
5
  ---
6
6
 
7
- # npm/skills/taze/js
8
-
9
- | Файл | Тип |
10
- | ------------------- | --------- |
11
- | [diff.mjs](diff.md) | JS Module |
7
+ | Файл | Тип |
8
+ | --------------------------------- | --------- |
9
+ | [cargo-diff.mjs](cargo-diff.md) | JS Module |
10
+ | [diff.mjs](diff.md) | JS Module |
11
+ | [orchestrate.mjs](orchestrate.md) | JS Module |
@@ -0,0 +1,43 @@
1
+ ---
2
+ type: JS Module
3
+ title: orchestrate.mjs
4
+ resource: npm/skills/taze/js/orchestrate.mjs
5
+ docgen:
6
+ crc: 1ff52a80
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Файл об’єднує публічні дії `buildDependencyPrompt`, `callRunner`, `backupWorkspacePackageFiles`, `cleanupBackups`, `findCargoManifests`, `formatReport`, `runTazeOrchestrator`, щоб узгодити оновлення залежностей за даними з `main.json` і `package.json`. Він працює read-only: не пише у ФС/БД, має кешування в межах прогону, свідомо пропускає шляхи `node_modules`, тимчасово зберігає бекапи `package.json` у воркспейсах і прибирає їх після завершення. Результат проходу оформлюється через `formatReport` як підсумок змін і стану оновлення.
16
+
17
+ ## Поведінка
18
+
19
+ - **buildDependencyPrompt** — формує текст завдання для перевірки major-оновлення одного пакета й подальшого сумісного рефакторингу.
20
+ - **callRunner** — запускає один ітеративний LLM-виклик у вибраному раннері та повертає результат разом із зібраним текстом відповіді.
21
+ - **backupWorkspacePackageFiles** — створює тимчасові бекапи `package.json` у воркспейсах для подальшого порівняння змін.
22
+ - **cleanupBackups** — прибирає тимчасові бекапи `package.json` після завершення прогону.
23
+ - **findCargoManifests** — знаходить `Cargo.toml` поза `node_modules`, `.worktrees` і `target` для інформаційного підсумку.
24
+ - **formatReport** — збирає лаконічний Markdown-звіт про minor/patch, major-оновлення, Rust-крейти та загальний обсяг змін.
25
+ - **runTazeOrchestrator** — виконує повний прогін taze: перевіряє worktree з `main.json`, робить бекап, оновлює залежності з `package.json`, обробляє major-оновлення по одному пакету, прибирає бекапи й повертає підсумок.
26
+
27
+ ## Публічний API
28
+
29
+ - buildDependencyPrompt — Готує промпт для одного LLM-кроку taze: тільки breaking changes, сумісність і рефакторинг для одного major-пакета. Перші кроки аналізу та фінальну збірку звіту робить оркестратор без LLM.
30
+ - callRunner — Запускає один ітеративний виклик через вибраний раннер. Для `pi` бере текст із stdout вбудованого pi-агента, для `cursor` і `codex` отримує його напряму через ACP-міст.
31
+ - backupWorkspacePackageFiles — Зберігає копії `package.json` усіх workspace-пакетів перед змінами, щоб потім відрізнити major і minor оновлення.
32
+ - cleanupBackups — Видаляє тимчасові копії `package.json` після завершення роботи.
33
+ - findCargoManifests — Находить `Cargo.toml` у репозиторії поза службовими директоріями; використовується лише для огляду Rust-крейтів.
34
+ - formatReport — Складає фінальний звіт із результатів усіх ітерацій без окремого LLM-запиту.
35
+ - runTazeOrchestrator — Керує taze від початку до кінця: робить бекап, масово оновлює версії, збирає diff, прибирає тимчасові файли й формує звіт. Для кожного major-пакета окремо запускає обмежений LLM-виклик, щоб збій одного пакета не зупиняв інші.
36
+
37
+ Конфіги: `package.json`, `main.json`
38
+
39
+ ## Гарантії поведінки
40
+
41
+ - Read-only: не виконує операцій запису (ФС/БД).
42
+ - Кешує результати в межах одного прогону.
43
+ - Свідомо пропускає шляхи: `node_modules`.
@@ -0,0 +1,410 @@
1
+ /** @see ./docs/orchestrate.md */
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
4
+ import { copyFile, rm } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+
7
+ import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
8
+ import { collectCargoDiff } from './cargo-diff.mjs'
9
+ import { collectTazeDiff } from './diff.mjs'
10
+
11
+ /** Суфікс бекапу package.json — той самий, що й у `diff.mjs`/кроці 1 SKILL.md. */
12
+ const BACKUP_SUFFIX = '.taze-bak'
13
+
14
+ /**
15
+ * Промпт ОДНОГО ітеративного виклику — лише кроки 4-6 SKILL.md (breaking
16
+ * changes → сумісність коду → рефакторинг) для ОДНОГО major-пакета. Кроки
17
+ * 1-3/7/8 виконує оркестратор детерміновано, без LLM.
18
+ * @param {{workspace: string, pkg: string, from: string, to: string}} entry запис major-diff (з `collectTazeDiff`)
19
+ * @returns {string} готовий промпт
20
+ */
21
+ export function buildDependencyPrompt({ workspace, pkg, from, to }) {
22
+ return [
23
+ '# Major-оновлення одного пакета: перевірка сумісності й рефакторинг',
24
+ '',
25
+ `Пакет \`${pkg}\` у воркспейсі \`${workspace}\`: **${from} → ${to}** — вже застосовано в package.json/bun.lock (кроки 1-3 виконано детерміновано, без тебе). Твоя задача — лише breaking-changes-перевірка й, за потреби, рефакторинг.`,
26
+ '',
27
+ '## Кроки',
28
+ `1. Зібрати breaking changes цього оновлення: CHANGELOG/Releases репозиторію модуля (поле \`repository\` у \`node_modules/${pkg}/package.json\`), або git/diff між закешованою старою версією (\`~/.bun/install/cache/${pkg}@<стара-версія>/\`) і новою (\`node_modules/${pkg}/\`).`,
29
+ `2. Знайти використання зачепленого API в коді проєкту (\`rg -n\` по імпортах/викликах \`${pkg}\`).`,
30
+ '3. Сумісно — нічого не робити. Несумісно — застосувати міграцію (перейменувати імпорт, оновити сигнатуру виклику, замінити видалену опцію еквівалентом).',
31
+ '4. Якщо були правки — запусти `npx @7n/rules lint`, typecheck/test якщо є в проєкті.',
32
+ '5. Нетривіальна/неоднозначна міграція — не вгадуй, залиш TODO-коментар із посиланням на CHANGELOG.',
33
+ '',
34
+ 'У відповіді одним абзацом підсумуй: сумісно / зрефакторено (які файли) / TODO (чому).'
35
+ ].join('\n')
36
+ }
37
+
38
+ /**
39
+ * Промпт ОДНОГО ітеративного виклику для Rust-крейта — Rust-варіант
40
+ * [`buildDependencyPrompt`] (кроки 4-6 SKILL.md, Rust-гілка), для ОДНОГО
41
+ * major-крейта. Кроки 1-3/7/8 виконує оркестратор детерміновано, без LLM.
42
+ * @param {{manifest: string, pkg: string, from: string, to: string}} entry запис major-diff (з `collectCargoDiff`)
43
+ * @returns {string} готовий промпт
44
+ */
45
+ export function buildCargoDependencyPrompt({ manifest, pkg, from, to }) {
46
+ return [
47
+ '# Major-оновлення одного Rust-крейта: перевірка сумісності й рефакторинг',
48
+ '',
49
+ `Крейт \`${pkg}\` у \`${manifest}\`: **${from} → ${to}** — вже застосовано в Cargo.toml/Cargo.lock (кроки 1-3 виконано детерміновано, без тебе). Твоя задача — лише breaking-changes-перевірка й, за потреби, рефакторинг.`,
50
+ '',
51
+ '## Кроки',
52
+ `1. Зібрати breaking changes цього оновлення: адреса репозиторію з поля \`repository\`/\`documentation\` крейта на crates.io (https://crates.io/crates/${pkg}) — CHANGELOG.md репозиторію чи GitHub Releases. Якщо немає — різниця по публічному API (\`pub fn\`/\`pub struct\`/\`pub trait\`) між закешованою старою версією (\`~/.cargo/registry/src/*/${pkg}-<стара-версія>/\`) і новою.`,
53
+ `2. Знайти використання зачепленого API в коді проєкту (\`rg -n --type rust\` по use-шляхах/викликах \`${pkg}\`).`,
54
+ '3. Сумісно — нічого не робити. Несумісно — застосувати міграцію (перейменувати use-шлях, оновити сигнатуру виклику, замінити видалений макрос еквівалентом).',
55
+ '4. Якщо були правки — запусти `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test`.',
56
+ '5. Нетривіальна/неоднозначна міграція — не вгадуй, залиш TODO-коментар із посиланням на CHANGELOG.',
57
+ '',
58
+ 'У відповіді одним абзацом підсумуй: сумісно / зрефакторено (які файли) / TODO (чому).'
59
+ ].join('\n')
60
+ }
61
+
62
+ /**
63
+ * Диспетчер одного ітеративного виклику на обраний раннер. `pi` — вбудований
64
+ * pi-агент (`@7n/llm-lib/agent-skill`; текст перехоплюється через `deps.out`,
65
+ * бо `runAgentSkill` не повертає його напряму — лише стрімить у stdout).
66
+ * `cursor`/`codex` — napi-міст ACP (`@7n/llm-lib/acp`; текст — прямий return,
67
+ * idle-timeout і видимість прогресу вже вбудовані в сам міст).
68
+ * @param {'pi' | 'cursor' | 'codex'} runner раннер
69
+ * @param {string} prompt промпт для одного пакета
70
+ * @param {string} cwd робочий каталог
71
+ * @param {{ runAgentSkill?: (prompt: string, opts?: object) => Promise<{ok: boolean, error: string|null}>, runAcpAgent?: (kind: string, prompt: string, cwd: string) => Promise<string> }} [deps] інжекти для тестів
72
+ * @returns {Promise<{ ok: boolean, text: string, error: string|null }>} результат виклику
73
+ */
74
+ export async function callRunner(runner, prompt, cwd, deps = {}) {
75
+ if (runner === 'pi') {
76
+ let runAgentSkill = deps.runAgentSkill
77
+ if (!runAgentSkill) {
78
+ const agentSkillModule = await import('@7n/llm-lib/agent-skill')
79
+ runAgentSkill = agentSkillModule.runAgentSkill
80
+ }
81
+ let text = ''
82
+ const result = await runAgentSkill(prompt, {
83
+ skillId: 'taze',
84
+ tier: 'avg',
85
+ cwd,
86
+ deps: { out: chunk => (text += chunk) }
87
+ })
88
+ return { ok: result.ok, text, error: result.error }
89
+ }
90
+
91
+ let runAcpAgent = deps.runAcpAgent
92
+ if (!runAcpAgent) {
93
+ const acpModule = await import('@7n/llm-lib/acp')
94
+ runAcpAgent = acpModule.runAcpAgent
95
+ }
96
+ try {
97
+ const text = await runAcpAgent(runner, prompt, cwd)
98
+ return { ok: true, text, error: null }
99
+ } catch (error) {
100
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Перевіряє, що `cwd` — ізольований worktree (`main.json.worktree: true`,
106
+ * той самий контракт, що й для інших worktree-only скілів). Раніше цю
107
+ * гарантію тримав агент, читаючи SKILL.md-preflight як частину промпту;
108
+ * оркестратор більше НЕ годує SKILL.md жодному викликові, тож без цієї
109
+ * перевірки `bunx taze -w -r latest`/`bun install` мовчки виконались би
110
+ * прямо в основному дереві виклику. Кидає, якщо `git rev-parse --show-toplevel`
111
+ * не містить `.worktrees` як сегмент шляху (покриває і `npx \@7n/mt worktree
112
+ * create`-конвенцію `.worktrees/`, і сесійну `.claude/worktrees/`).
113
+ * @param {string} cwd каталог для перевірки
114
+ * @param {typeof spawnSync} spawnFn інжект для тестів
115
+ * @returns {void}
116
+ */
117
+ function assertRunningInWorktree(cwd, spawnFn) {
118
+ const result = spawnFn('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
119
+ const toplevel = result.status === 0 ? result.stdout.trim() : ''
120
+ const segments = new Set(toplevel.replaceAll('\\', '/').split('/'))
121
+ if (!segments.has('.worktrees')) {
122
+ throw new Error(
123
+ `taze: "${cwd}" не в ізольованому worktree (git toplevel: "${toplevel || '?'}"). ` +
124
+ 'main.json.worktree=true вимагає окремого дерева — створи його спершу (див. SKILL.md preflight), не запускай taze в основному дереві.'
125
+ )
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Синхронно виконує детерміновану команду (bunx/bun/find), кидає з
131
+ * exit-кодом+stderr при провалі.
132
+ * @param {string} cmd бінарник
133
+ * @param {string[]} args аргументи
134
+ * @param {string} cwd робочий каталог
135
+ * @param {typeof spawnSync} spawnFn інжект для тестів
136
+ * @returns {string} stdout
137
+ */
138
+ function runCommand(cmd, args, cwd, spawnFn) {
139
+ const result = spawnFn(cmd, args, { cwd, encoding: 'utf8' })
140
+ if (result.status !== 0) {
141
+ throw new Error(`${cmd} ${args.join(' ')} → exit ${result.status}: ${result.stderr || result.stdout}`)
142
+ }
143
+ return result.stdout
144
+ }
145
+
146
+ /**
147
+ * Бекапить package.json кожного воркспейсу (крок 1 SKILL.md) — потрібно для
148
+ * класифікації major/minor через `collectTazeDiff` після bump-у.
149
+ * @param {string} cwd корінь репо
150
+ * @param {{ getMonorepoPackageRootDirs?: (cwd: string) => Promise<string[]>, copyFile?: (src: string, dest: string) => Promise<void> }} [deps] інжекти
151
+ * @returns {Promise<string[]>} відносні шляхи воркспейсів, що мали package.json
152
+ */
153
+ export async function backupWorkspacePackageFiles(cwd, deps = {}) {
154
+ const getRoots = deps.getMonorepoPackageRootDirs ?? getMonorepoPackageRootDirs
155
+ const copy = deps.copyFile ?? copyFile
156
+ const roots = await getRoots(cwd)
157
+ const backedUp = []
158
+ for (const ws of roots) {
159
+ const pkgPath = join(cwd, ws, 'package.json')
160
+ if (!existsSync(pkgPath)) continue
161
+ await copy(pkgPath, `${pkgPath}${BACKUP_SUFFIX}`)
162
+ backedUp.push(ws)
163
+ }
164
+ return backedUp
165
+ }
166
+
167
+ /**
168
+ * Прибирає бекапи package.json після завершення (крок 7 SKILL.md).
169
+ * @param {string} cwd корінь репо
170
+ * @param {string[]} workspaces воркспейси з бекапом (з `backupWorkspacePackageFiles`)
171
+ * @param {{ rm?: (path: string, opts?: object) => Promise<void> }} [deps] інжект
172
+ * @returns {Promise<void>}
173
+ */
174
+ export async function cleanupBackups(cwd, workspaces, deps = {}) {
175
+ const remove = deps.rm ?? rm
176
+ for (const ws of workspaces) {
177
+ await remove(join(cwd, ws, `package.json${BACKUP_SUFFIX}`), { force: true })
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Чи встановлений cargo-edit (дає `cargo upgrade`) — без нього неможливо
183
+ * детерміновано перетнути major-межу Rust-залежностей (голий `cargo update`
184
+ * піднімає лише semver-сумісні версії; SKILL.md §Передумови).
185
+ * @param {typeof spawnSync} spawnFn інжект для тестів
186
+ * @returns {boolean} true — `cargo upgrade` доступна
187
+ */
188
+ function hasCargoEdit(spawnFn) {
189
+ return spawnFn('cargo', ['upgrade', '--version'], { encoding: 'utf8' }).status === 0
190
+ }
191
+
192
+ /**
193
+ * Бекапить кожен Cargo.toml + спільний кореневий Cargo.lock (крок 1 SKILL.md,
194
+ * Rust-гілка). v1: один спільний workspace/Cargo.lock у корені `cwd` —
195
+ * поточна реальна топологія репо (кореневий `Cargo.toml` з `[workspace]` +
196
+ * члени); кілька незалежних Cargo-workspace під час не підтримується.
197
+ * @param {string} cwd корінь репо
198
+ * @param {string[]} manifestPaths відносні шляхи Cargo.toml (з `findCargoManifests`)
199
+ * @param {{ copyFile?: (src: string, dest: string) => Promise<void> }} [deps] інжект
200
+ * @returns {Promise<void>}
201
+ */
202
+ export async function backupCargoManifests(cwd, manifestPaths, deps = {}) {
203
+ const copy = deps.copyFile ?? copyFile
204
+ for (const manifest of manifestPaths) {
205
+ const manifestPath = join(cwd, manifest)
206
+ if (existsSync(manifestPath)) await copy(manifestPath, `${manifestPath}${BACKUP_SUFFIX}`)
207
+ }
208
+ const lockPath = join(cwd, 'Cargo.lock')
209
+ if (existsSync(lockPath)) await copy(lockPath, `${lockPath}${BACKUP_SUFFIX}`)
210
+ }
211
+
212
+ /**
213
+ * Прибирає бекапи Cargo.toml/Cargo.lock після завершення (крок 7 SKILL.md,
214
+ * Rust-гілка).
215
+ * @param {string} cwd корінь репо
216
+ * @param {string[]} manifestPaths відносні шляхи Cargo.toml (з `findCargoManifests`)
217
+ * @param {{ rm?: (path: string, opts?: object) => Promise<void> }} [deps] інжект
218
+ * @returns {Promise<void>}
219
+ */
220
+ export async function cleanupCargoBackups(cwd, manifestPaths, deps = {}) {
221
+ const remove = deps.rm ?? rm
222
+ for (const manifest of manifestPaths) {
223
+ await remove(join(cwd, `${manifest}${BACKUP_SUFFIX}`), { force: true })
224
+ }
225
+ await remove(join(cwd, `Cargo.lock${BACKUP_SUFFIX}`), { force: true })
226
+ }
227
+
228
+ /**
229
+ * Знаходить Cargo.toml поза node_modules/.worktrees/target (крок 0.2 SKILL.md).
230
+ * @param {string} cwd корінь репо
231
+ * @param {{ spawnFn?: typeof spawnSync }} [deps] інжект
232
+ * @returns {string[]} відносні шляхи знайдених Cargo.toml
233
+ */
234
+ export function findCargoManifests(cwd, deps = {}) {
235
+ const spawnFn = deps.spawnFn ?? spawnSync
236
+ const result = spawnFn(
237
+ 'find',
238
+ [
239
+ '.',
240
+ '-name',
241
+ 'Cargo.toml',
242
+ '-not',
243
+ '-path',
244
+ '*/node_modules/*',
245
+ '-not',
246
+ '-path',
247
+ '*/.worktrees/*',
248
+ '-not',
249
+ '-path',
250
+ '*/target/*'
251
+ ],
252
+ { cwd, encoding: 'utf8' }
253
+ )
254
+ return (result.stdout ?? '')
255
+ .split('\n')
256
+ .map(line => line.trim())
257
+ .filter(Boolean)
258
+ }
259
+
260
+ /**
261
+ * Форматує один рядок результату ітерації (спільний для npm- і Rust-гілки).
262
+ * @param {{pkg: string, ok: boolean, error: string|null, from: string, to: string}} r результат ітерації
263
+ * @param {string} scopeLabel мітка джерела (`workspace` для npm, `manifest` для Rust)
264
+ * @returns {string} один рядок звіту
265
+ */
266
+ function formatResultLine(r, scopeLabel) {
267
+ const status = r.ok ? '✅' : '❌'
268
+ const errorSuffix = r.error ? ` — ${r.error}` : ''
269
+ return ` ${status} \`${r.pkg}\` (${scopeLabel}): ${r.from} → ${r.to}${errorSuffix}`
270
+ }
271
+
272
+ /**
273
+ * Компонує підсумковий звіт (крок 8 SKILL.md) детерміновано з результатів
274
+ * ітерацій — без окремого LLM-виклику для самого звіту.
275
+ * @param {{
276
+ * minorPatch: number,
277
+ * totalChanged: number,
278
+ * results: Array<{pkg:string, workspace:string, from:string, to:string, ok:boolean, error:string|null}>,
279
+ * rust: {
280
+ * manifests: string[],
281
+ * processed: boolean,
282
+ * skippedReason: string|null,
283
+ * minorPatch: number,
284
+ * results: Array<{pkg:string, manifest:string, from:string, to:string, ok:boolean, error:string|null}>
285
+ * }
286
+ * }} args дані звіту
287
+ * @returns {string} markdown-звіт
288
+ */
289
+ export function formatReport({ minorPatch, totalChanged, results, rust }) {
290
+ const lines = [
291
+ '## taze: підсумок',
292
+ '',
293
+ `- **Оновлено (minor/patch):** ${minorPatch}`,
294
+ `- **Major-оновлення:** ${results.length}`
295
+ ]
296
+ for (const r of results) {
297
+ lines.push(formatResultLine(r, r.workspace))
298
+ }
299
+
300
+ let rustTotalChanged = 0
301
+ if (rust.manifests.length > 0) {
302
+ lines.push('', '### Rust-крейти')
303
+ if (rust.processed) {
304
+ lines.push(`- **Оновлено (minor/patch):** ${rust.minorPatch}`, `- **Major-оновлення:** ${rust.results.length}`)
305
+ for (const r of rust.results) {
306
+ lines.push(formatResultLine(r, r.manifest))
307
+ }
308
+ rustTotalChanged = rust.minorPatch + rust.results.length
309
+ } else {
310
+ lines.push(
311
+ `- ⏭ Пропущено (${rust.skippedReason}) — ${rust.manifests.length} Cargo.toml, онови вручну за Rust-гілкою SKILL.md: ${rust.manifests.join(', ')}`
312
+ )
313
+ }
314
+ }
315
+
316
+ lines.push('', `- **Всього змінено:** ${totalChanged + rustTotalChanged}`)
317
+ return lines.join('\n')
318
+ }
319
+
320
+ /**
321
+ * Оркеструє taze: детерміновані кроки (бекап → масовий bump → diff →
322
+ * прибирання → звіт) без LLM, і по одному ізольованому, обмеженому по
323
+ * обсягу виклику `callRunner` на кожен major-пакет (кроки 4-6 SKILL.md) —
324
+ * замість одного величезного непрозорого ходу на весь монорепо. Кожен
325
+ * виклик успадковує власний timeout/idle-timeout раннера, тож падіння
326
+ * одного пакета не втрачає прогрес по інших.
327
+ * @param {{
328
+ * cwd?: string,
329
+ * runner?: 'pi' | 'cursor' | 'codex',
330
+ * log?: (line: string) => void,
331
+ * deps?: { spawnFn?: typeof spawnSync, collectTazeDiff?: (cwd: string) => Promise<object>, collectCargoDiff?: (cwd: string, manifests: string[]) => Promise<object>, callRunner?: (runner: string, prompt: string, cwd: string, deps: object) => Promise<{ok: boolean, text: string, error: string|null}> } & Record<string, unknown>
332
+ * }} [options] опції + інжекти для тестів
333
+ * @returns {Promise<{ ok: boolean, report: string, results: Array<object>, rustResults: Array<object> }>} результат
334
+ */
335
+ export async function runTazeOrchestrator(options = {}) {
336
+ const cwd = options.cwd ?? process.cwd()
337
+ const runner = options.runner ?? 'pi'
338
+ const log = options.log ?? (line => console.log(line))
339
+ const deps = options.deps ?? {}
340
+ const spawnFn = deps.spawnFn ?? spawnSync
341
+ const call = deps.callRunner ?? callRunner
342
+
343
+ assertRunningInWorktree(cwd, spawnFn)
344
+
345
+ log('📦 Бекап package.json...')
346
+ const backedUpWorkspaces = await backupWorkspacePackageFiles(cwd, deps)
347
+
348
+ log('⬆️ bunx taze -w -r latest...')
349
+ runCommand('bunx', ['taze', '-w', '-r', 'latest'], cwd, spawnFn)
350
+ log('📥 bun install...')
351
+ runCommand('bun', ['install'], cwd, spawnFn)
352
+
353
+ const collectDiff = deps.collectTazeDiff ?? collectTazeDiff
354
+ const diff = await collectDiff(cwd)
355
+ log(`🔍 diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
356
+
357
+ const results = []
358
+ for (const entry of diff.major) {
359
+ log(`🔧 ${entry.pkg} (${entry.workspace}): ${entry.from} → ${entry.to}...`)
360
+ const outcome = await call(runner, buildDependencyPrompt(entry), cwd, deps)
361
+ results.push({ ...entry, ...outcome })
362
+ log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
363
+ }
364
+
365
+ await cleanupBackups(cwd, backedUpWorkspaces, deps)
366
+
367
+ const rustManifests = findCargoManifests(cwd, { spawnFn })
368
+ let rust = { manifests: rustManifests, processed: false, skippedReason: null, minorPatch: 0, results: [] }
369
+
370
+ if (rustManifests.length > 0 && !hasCargoEdit(spawnFn)) {
371
+ rust.skippedReason =
372
+ 'cargo-edit не встановлено (`cargo install cargo-edit`) — cargo update без нього недетермінований для major'
373
+ log(`⏭ Rust: ${rust.skippedReason}`)
374
+ } else if (rustManifests.length > 0) {
375
+ log('📦 Бекап Cargo.toml/Cargo.lock...')
376
+ await backupCargoManifests(cwd, rustManifests, deps)
377
+
378
+ log('⬆️ cargo upgrade --incompatible allow...')
379
+ runCommand('cargo', ['upgrade', '--incompatible', 'allow'], cwd, spawnFn)
380
+ log('🔄 cargo update...')
381
+ runCommand('cargo', ['update'], cwd, spawnFn)
382
+
383
+ const collectCargo = deps.collectCargoDiff ?? collectCargoDiff
384
+ const cargoDiff = await collectCargo(cwd, rustManifests)
385
+ log(`🔍 Rust diff: ${cargoDiff.major.length} major, ${cargoDiff.minorPatch} minor/patch`)
386
+
387
+ const rustResults = []
388
+ for (const entry of cargoDiff.major) {
389
+ log(`🔧 [rust] ${entry.pkg} (${entry.manifest}): ${entry.from} → ${entry.to}...`)
390
+ const outcome = await call(runner, buildCargoDependencyPrompt(entry), cwd, deps)
391
+ rustResults.push({ ...entry, ...outcome })
392
+ log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
393
+ }
394
+
395
+ await cleanupCargoBackups(cwd, rustManifests, deps)
396
+
397
+ rust = {
398
+ manifests: rustManifests,
399
+ processed: true,
400
+ skippedReason: null,
401
+ minorPatch: cargoDiff.minorPatch,
402
+ results: rustResults
403
+ }
404
+ }
405
+
406
+ const report = formatReport({ minorPatch: diff.minorPatch, totalChanged: diff.totalChanged, results, rust })
407
+ log(report)
408
+
409
+ return { ok: results.every(r => r.ok) && rust.results.every(r => r.ok), report, results, rustResults: rust.results }
410
+ }