@7n/rules 1.8.3 → 1.9.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] - 2026-07-17
4
+
5
+ ### Added
6
+
7
+ - `skill pi/cursor/codex taze` тепер виконується через оркестратор (`npm/skills/taze/js/orchestrate.mjs`) замість одного величезного непрозорого ходу на весь монорепо: детерміновані кроки (бекап/масовий bump/diff/прибирання) без LLM, і по одному ізольованому, обмеженому виклику раннера на кожен major-пакет — падіння/timeout одного пакета не втрачає прогрес по інших
8
+
9
+ ## [1.8.4] - 2026-07-17
10
+
11
+ ### Fixed
12
+
13
+ - text/run-v8r: включати причину провалу v8r (stdout+stderr, без noise) у violation-повідомлення замість голого коду виходу
14
+ - k8s/manifests: форматування tests/check-schema.test.mjs (oxfmt)
15
+
3
16
  ## [1.8.3] - 2026-07-17
4
17
 
5
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,8 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/text/run-v8r/main.mjs
5
5
  docgen:
6
- crc: 804dce1c
6
+ crc: 5c8aab7b
7
+ model: manual
7
8
  ---
8
9
 
9
10
  ## Огляд
@@ -20,18 +21,20 @@ docgen:
20
21
  - `V8R_CACHE_TTL_SECONDS` — TTL HTTP-кешу v8r (доба замість дефолтних 600 с): fallback-фетчі schemastore не повторюються на кожен прогін.
21
22
  - `resolveCustomCatalogSchemas()` — читає джерельний каталог, повертає масив схем у форматі v8r `customCatalog.schemas` (ключ `location`, локальні шляхи — абсолютні).
22
23
  - `writeResolvedV8rConfig()` — записує `{ cacheTtl, customCatalog: { schemas } }` у `RESOLVED_V8R_CONFIG_PATH` (побічний ефект: файлова операція запису, поза rollback-механізмом лінт-пайплайна).
23
- - `runV8rWithGlobs(globs?)` — послідовно запускає `v8r` для кожного glob-у з `V8R_CONFIG_FILE`, вказаним на резольвнутий конфіг; при коді 0/98 вивід приховано, інакше друкується й повертається код першого невдалого прогону.
24
- - `runV8rWithFiles(files)` — delta-режим: один запуск `v8r` по конкретних існуючих шляхах (код 98 неможливий), порожній список — одразу 0.
24
+ - `runV8rWithGlobs(globs?)` — послідовно запускає `v8r` для кожного glob-у з `V8R_CONFIG_FILE`, вказаним на резольвнутий конфіг; повертає `{ code, detail }` (не голий код): при `code` 0/98 вивід приховано, `detail` порожній; інакше `detail` друкується й повертається код першого невдалого прогону.
25
+ - `runV8rWithFiles(files)` — delta-режим: один запуск `v8r` по конкретних існуючих шляхах (код 98 неможливий), порожній список — одразу `{ code: 0, detail: '' }`.
26
+ - `extractFailureLines(combinedText)` — фільтрує `ℹ`-шум і bunx install-вивід з об'єднаного `stdout+stderr` одного запуску `v8r`, лишаючи предметну деталь (`✖ …`-заголовки, ajv-причини). Обидва потоки об'єднуються навмисно: `v8r` непослідовно розкидає деталь між ними залежно від типу помилки (schema violation — причина в stdout, заголовок у stderr; "не знайдено схему" — усе в stderr).
25
27
  - `warnAboutRemoteSchemaFallback(stderrText)` — парсить stderr `v8r` і на кожен файл, чию схему знайдено мережевим fallback-ом (schemastore, не customCatalog), друкує в stdout пораду додати схему в каталог.
26
28
  - `stripBunNodeShimDirs(pathValue)` — прибирає з PATH shim-теки `bun-node-*`, які `bun run --bun` додає з підміненим `node`: дочірній `v8r` (node-shebang) інакше виконувався б під bun і падав на непідтримуваному `node:sea`. Дочірній процес `v8r` завжди отримує очищений PATH.
27
- - `lint(ctx)` — detector `text/run-v8r`: `ctx.files` → delta по відфільтрованих розширеннях, без `ctx.files` → full за `DEFAULT_V8R_GLOBS`.
29
+ - `lint(ctx)` — detector `text/run-v8r`: `ctx.files` → delta по відфільтрованих розширеннях, без `ctx.files` → full за `DEFAULT_V8R_GLOBS`. Порушення несе `detail` (якщо є) у тексті `fail()`-повідомлення — без нього LLM fix-worker бачив лише "щось не пройшло" й незмінно провалював усі rung-и драбини (не мав інформації, що саме виправляти).
28
30
 
29
31
  ## Публічний API
30
32
 
31
33
  - `DEFAULT_V8R_GLOBS`, `V8R_CATALOG_PATH`, `RESOLVED_V8R_CONFIG_PATH`, `V8R_CACHE_TTL_SECONDS` — константи.
32
34
  - `resolveCustomCatalogSchemas()` — обчислені схеми customCatalog.
33
35
  - `writeResolvedV8rConfig()` — матеріалізація тимчасового конфігу, повертає його шлях.
34
- - `runV8rWithGlobs(globs?)`, `runV8rWithFiles(files)` — точки входу перевірки (full/delta).
36
+ - `runV8rWithGlobs(globs?)`, `runV8rWithFiles(files)` — точки входу перевірки (full/delta), повертають `{ code, detail }`.
37
+ - `extractFailureLines(combinedText)` — фільтр noise-рядків для деталі провалу.
35
38
  - `warnAboutRemoteSchemaFallback(stderrText)` — попередження про мережевий fallback схем.
36
39
  - `stripBunNodeShimDirs(pathValue)` — PATH без shim-тек `bun-node-*` (для дочірнього v8r).
37
40
  - `lint(ctx)` — інтеграція в lint-пайплайн.
@@ -114,17 +114,28 @@ export function writeResolvedV8rConfig() {
114
114
 
115
115
  const PROCESSING_LINE_RE = /^ℹ Processing (.+)$/u
116
116
  const FOUND_REMOTE_SCHEMA_RE = /^ℹ Found schema in (https?:\/\/\S+)/u
117
- const FAILURE_LINE_RE = /^✖ .+$/mu
117
+ const NOISE_LINE_RE = /^(?:ℹ .*|Resolving dependencies|Resolved, downloaded and extracted.*|Saved lockfile)$/u
118
118
 
119
119
  /**
120
- * Витягує лише рядки `✖ …` (конкретні помилки валідації) з raw stdout v8r — без `ℹ`-шуму
121
- * (Pre-warming the cache, Processing <file>, Found schema in …) і без `✔ … is valid` по
122
- * пройдених файлах. Використовується для non-verbose підсумку при невдалому прогоні.
123
- * @param {string} stdoutText захоплений stdout одного запуску v8r
124
- * @returns {string} рядки `✖ …`, з'єднані `\n` (порожній рядок, якщо збігів нема)
120
+ * Прибирає v8r/bunx noise-рядки (весь `ℹ`-статус: Loaded config file, Patterns and relative
121
+ * paths, Pre-warming the cache, Processing <file>, Found schema in …, Validating …; і службовий
122
+ * вивід bunx-встановлення Resolving/Resolved/Saved lockfile) з об'єднаного stdout+stderr одного
123
+ * запуску
124
+ * лишає предметну деталь (`✖ …`-заголовки й ajv-причини на кшталт "must NOT have additional
125
+ * properties…"). Обидва потоки об'єднуються НАВМИСНО: v8r непослідовно розкидає ці рядки між
126
+ * stdout/stderr залежно від типу помилки — при порушенні схеми ajv-причина йде у stdout, а
127
+ * `✖ … is invalid`-заголовок у stderr; при "не знайдено схему" все йде у stderr, stdout
128
+ * порожній. Фільтр лише за stdout (як раніше) на другому випадку повертав би зовсім порожню
129
+ * деталь — і LLM fix-worker (як і non-verbose CLI-підсумок) не бачив би жодної причини провалу.
130
+ * @param {string} combinedText stdout + '\n' + stderr одного запуску v8r
131
+ * @returns {string} відфільтровані непорожні рядки, join('\n') (порожній рядок, якщо деталі нема)
125
132
  */
126
- export function extractFailureLines(stdoutText) {
127
- return (stdoutText.match(FAILURE_LINE_RE) ?? []).join('\n')
133
+ export function extractFailureLines(combinedText) {
134
+ return combinedText
135
+ .split('\n')
136
+ .map(line => line.trim())
137
+ .filter(line => line.length > 0 && !NOISE_LINE_RE.test(line))
138
+ .join('\n')
128
139
  }
129
140
 
130
141
  /**
@@ -168,11 +179,16 @@ export function stripBunNodeShimDirs(pathValue) {
168
179
 
169
180
  /**
170
181
  * Один виклик `bun x v8r <targets...>` з підготовленим `customCatalog`-конфігом.
182
+ * `detail` (рядки `✖ …`) обчислюється завжди, незалежно від `verbose` — потрібен викликачу
183
+ * (`lint()`) для вбудовування у violation-повідомлення, яке бачить LLM fix-worker: без нього
184
+ * fix-ladder отримує лише "щось не пройшло" й не має шансів вгадати, що саме (спостережено —
185
+ * усі 4 rung-и незмінно падають у timeout на v8r-порушеннях).
171
186
  * @param {string[]} targets glob-и або конкретні шляхи файлів
172
187
  * @param {string} configPath шлях до `V8R_CONFIG_FILE`
173
188
  * @param {boolean} [verbose] друкувати повний raw stdout/stderr v8r при помилці; інакше — лише
174
189
  * рядки `✖ …` без `ℹ`-шуму (Pre-warming the cache, Processing <file>, Found schema in …)
175
- * @returns {{ exitError: true } | { exitError: false, code: number }} помилка spawn або код v8r (0/98 — трактує викликач)
190
+ * @returns {{ exitError: true } | { exitError: false, code: number, detail: string }} помилка
191
+ * spawn або код v8r (0/98 — трактує викликач) + деталь `✖ …`-рядків
176
192
  */
177
193
  function runOneV8rInvocation(targets, configPath, verbose = false) {
178
194
  const bunPath = resolveCmd('bun') ?? process.execPath
@@ -192,16 +208,17 @@ function runOneV8rInvocation(targets, configPath, verbose = false) {
192
208
  warnAboutRemoteSchemaFallback(result.stderr ?? '')
193
209
 
194
210
  const exitCode = result.status ?? 1
211
+ let detail = ''
195
212
  if (exitCode !== 0 && exitCode !== 98) {
213
+ detail = extractFailureLines(`${result.stdout ?? ''}\n${result.stderr ?? ''}`)
196
214
  if (verbose) {
197
215
  if (result.stdout?.length) process.stdout.write(result.stdout)
198
216
  if (result.stderr?.length) process.stderr.write(result.stderr)
199
- } else {
200
- const failureLines = extractFailureLines(result.stdout ?? '')
201
- if (failureLines.length) process.stdout.write(`${failureLines}\n`)
217
+ } else if (detail.length) {
218
+ process.stdout.write(`${detail}\n`)
202
219
  }
203
220
  }
204
- return { exitError: false, code: exitCode }
221
+ return { exitError: false, code: exitCode, detail }
205
222
  }
206
223
 
207
224
  /**
@@ -210,24 +227,25 @@ function runOneV8rInvocation(targets, configPath, verbose = false) {
210
227
  * glob не знаходить файлів, і тоді решта розширень не перевіряються в тому ж виклику.
211
228
  * @param {string[]} [globs] патерни; за замовчуванням DEFAULT_V8R_GLOBS
212
229
  * @param {boolean} [verbose] друкувати повний raw вивід v8r при помилці (див. runOneV8rInvocation)
213
- * @returns {number} 0 — OK, 1 — помилка spawn, 2 — немає каталогу схем, інше — код v8r
230
+ * @returns {{ code: number, detail: string }} `code`: 0 — OK, 1 — помилка spawn, 2 — немає
231
+ * каталогу схем, інше — код v8r; `detail` — рядки `✖ …` (порожньо, якщо `code` не про валідацію)
214
232
  */
215
233
  export function runV8rWithGlobs(globs = DEFAULT_V8R_GLOBS, verbose = false) {
216
234
  if (!existsSync(V8R_CATALOG_PATH)) {
217
235
  process.stderr.write(
218
236
  `run-v8r: не знайдено каталог схем за шляхом ${V8R_CATALOG_PATH} (очікується npm/schemas/v8r-catalog.json у пакеті)\n`
219
237
  )
220
- return 2
238
+ return { code: 2, detail: '' }
221
239
  }
222
240
 
223
241
  const configPath = writeResolvedV8rConfig()
224
242
 
225
243
  for (const pattern of globs) {
226
244
  const r = runOneV8rInvocation([pattern], configPath, verbose)
227
- if (r.exitError) return 1
228
- if (r.code !== 0 && r.code !== 98) return r.code
245
+ if (r.exitError) return { code: 1, detail: '' }
246
+ if (r.code !== 0 && r.code !== 98) return { code: r.code, detail: r.detail }
229
247
  }
230
- return 0
248
+ return { code: 0, detail: '' }
231
249
  }
232
250
 
233
251
  /**
@@ -235,21 +253,33 @@ export function runV8rWithGlobs(globs = DEFAULT_V8R_GLOBS, verbose = false) {
235
253
  * бо кожен переданий шлях уже існує (не glob), тож код 98 "порожній glob" тут не виникає.
236
254
  * @param {string[]} files абсолютні або відносні до cwd v8r-процесу шляхи файлів
237
255
  * @param {boolean} [verbose] друкувати повний raw вивід v8r при помилці (див. runOneV8rInvocation)
238
- * @returns {number} 0 — OK, 1 — помилка spawn, 2 — немає каталогу схем, інше — код v8r
256
+ * @returns {{ code: number, detail: string }} `code`: 0 — OK, 1 — помилка spawn, 2 — немає
257
+ * каталогу схем, інше — код v8r; `detail` — рядки `✖ …` (порожньо, якщо `code` не про валідацію)
239
258
  */
240
259
  export function runV8rWithFiles(files, verbose = false) {
241
- if (files.length === 0) return 0
260
+ if (files.length === 0) return { code: 0, detail: '' }
242
261
  if (!existsSync(V8R_CATALOG_PATH)) {
243
262
  process.stderr.write(
244
263
  `run-v8r: не знайдено каталог схем за шляхом ${V8R_CATALOG_PATH} (очікується npm/schemas/v8r-catalog.json у пакеті)\n`
245
264
  )
246
- return 2
265
+ return { code: 2, detail: '' }
247
266
  }
248
267
 
249
268
  const configPath = writeResolvedV8rConfig()
250
269
  const r = runOneV8rInvocation(files, configPath, verbose)
251
- if (r.exitError) return 1
252
- return r.code === 98 ? 0 : r.code
270
+ if (r.exitError) return { code: 1, detail: '' }
271
+ return { code: r.code === 98 ? 0 : r.code, detail: r.detail }
272
+ }
273
+
274
+ /**
275
+ * Будує violation-повідомлення з опційною деталлю `✖ …`-рядків v8r — без неї LLM fix-worker
276
+ * бачить лише "щось не пройшло" й не має інформації, який файл/поле саме порушує схему.
277
+ * @param {string} detail рядки `✖ …` з `runV8rWithGlobs`/`runV8rWithFiles` (може бути порожнім)
278
+ * @returns {string} повне повідомлення для `fail()`
279
+ */
280
+ function v8rFailMessage(detail) {
281
+ const base = 'v8r schema-валідація json/yaml/toml не пройшла (text.mdc)'
282
+ return detail ? `${base}:\n${detail}` : base
253
283
  }
254
284
 
255
285
  /**
@@ -264,19 +294,19 @@ export function lint(ctx) {
264
294
  const verbose = ctx.verbose === true
265
295
 
266
296
  if (ctx.files === undefined) {
267
- const code = runV8rWithGlobs(DEFAULT_V8R_GLOBS, verbose)
268
- if (code !== 0) fail('v8r schema-валідація json/yaml/toml не пройшла (text.mdc)', 'v8r')
297
+ const { code, detail } = runV8rWithGlobs(DEFAULT_V8R_GLOBS, verbose)
298
+ if (code !== 0) fail(v8rFailMessage(detail), 'v8r')
269
299
  return reporter.result()
270
300
  }
271
301
 
272
302
  const files = ctx.files.filter(f => V8R_EXT_RE.test(f))
273
303
  if (files.length === 0) return reporter.result()
274
- const code = runV8rWithFiles(files, verbose)
275
- if (code !== 0) fail('v8r schema-валідація json/yaml/toml не пройшла (text.mdc)', 'v8r')
304
+ const { code, detail } = runV8rWithFiles(files, verbose)
305
+ if (code !== 0) fail(v8rFailMessage(detail), 'v8r')
276
306
  return reporter.result()
277
307
  }
278
308
 
279
309
  if (isRunAsCli(import.meta.url)) {
280
310
  const globs = process.argv.length > 2 ? process.argv.slice(2) : DEFAULT_V8R_GLOBS
281
- process.exitCode = runV8rWithGlobs(globs)
311
+ process.exitCode = runV8rWithGlobs(globs).code
282
312
  }
@@ -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,6 +13,27 @@ version: '1.1'
13
13
 
14
14
  Оновити всі модулі проекту (npm/bun-залежності, а за наявності `Cargo.toml` — і Rust-крейти) до останніх версій, виявити major-оновлення, перевірити сумісність змін з кодом проекту і за потреби зрефакторити несумісні місця.
15
15
 
16
+ ## Оркестрація (npm/bun-гілка) — не одним промптом
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 нижче (бекап → `bunx taze -w -r latest` → `bun install` → `n-rules taze diff`).
24
+ 2. Для кожного **окремого** major-пакета з diff-у — один ізольований, обмежений виклик обраного раннера (кроки 4-6, лише для цього пакета; промпт генерує `buildDependencyPrompt`, не цей SKILL.md).
25
+ 3. Детерміновано прибирає бекапи (крок 7) і компонує звіт (крок 8) з результатів усіх ітерацій.
26
+
27
+ Переваги: падіння/timeout на одному пакеті не втрачає прогрес по інших; кожен
28
+ виклик успадковує власний timeout раннера (короткий, бо скоуп малий — один
29
+ пакет, не весь монорепо); видно прогрес по пакетах, а не чорну скриньку.
30
+
31
+ Кроки 1-8 нижче лишаються джерелом правди щодо ЗМІСТУ роботи (що саме робить
32
+ кожен крок) — оркестратор їх виконує програмно (1-3/7/8) або як промпт на
33
+ один пакет (4-6). Пряме читання цього файлу одним агентом (як SKILL.md для
34
+ інших скілів) досі стосується лише **Rust-гілки** (нижче) — вона оркестратором
35
+ не покрита.
36
+
16
37
  ## Передумови
17
38
 
18
39
  - Чисте робоче дерево (`git status` без незакомічених змін у `package.json` / `bun.lock` / `node_modules`) — інакше різницю не відрізнити від оновлення.
@@ -4,8 +4,7 @@ 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
+ | [diff.mjs](diff.md) | JS Module |
10
+ | [orchestrate.mjs](orchestrate.md) | JS Module |
@@ -0,0 +1,44 @@
1
+ ---
2
+ type: JS Module
3
+ title: orchestrate.mjs
4
+ resource: npm/skills/taze/js/orchestrate.mjs
5
+ docgen:
6
+ crc: 37e2a3e9
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл об’єднує публічні дії `buildDependencyPrompt`, `callRunner`, `backupWorkspacePackageFiles`, `cleanupBackups`, `findCargoManifests`, `formatReport`, `runTazeOrchestrator`, щоб узгодити оновлення залежностей за даними з `main.json` і `package.json`. Він працює read-only: не пише у ФС/БД, має кешування в межах прогону, свідомо пропускає шляхи `node_modules`, тимчасово зберігає бекапи `package.json` у воркспейсах і прибирає їх після завершення. Результат проходу оформлюється через `formatReport` як підсумок змін і стану оновлення.
17
+
18
+ ## Поведінка
19
+
20
+ - **buildDependencyPrompt** — формує текст завдання для перевірки major-оновлення одного пакета й подальшого сумісного рефакторингу.
21
+ - **callRunner** — запускає один ітеративний LLM-виклик у вибраному раннері та повертає результат разом із зібраним текстом відповіді.
22
+ - **backupWorkspacePackageFiles** — створює тимчасові бекапи `package.json` у воркспейсах для подальшого порівняння змін.
23
+ - **cleanupBackups** — прибирає тимчасові бекапи `package.json` після завершення прогону.
24
+ - **findCargoManifests** — знаходить `Cargo.toml` поза `node_modules`, `.worktrees` і `target` для інформаційного підсумку.
25
+ - **formatReport** — збирає лаконічний Markdown-звіт про minor/patch, major-оновлення, Rust-крейти та загальний обсяг змін.
26
+ - **runTazeOrchestrator** — виконує повний прогін taze: перевіряє worktree з `main.json`, робить бекап, оновлює залежності з `package.json`, обробляє major-оновлення по одному пакету, прибирає бекапи й повертає підсумок.
27
+
28
+ ## Публічний API
29
+
30
+ - buildDependencyPrompt — Готує промпт для одного LLM-кроку taze: тільки breaking changes, сумісність і рефакторинг для одного major-пакета. Перші кроки аналізу та фінальну збірку звіту робить оркестратор без LLM.
31
+ - callRunner — Запускає один ітеративний виклик через вибраний раннер. Для `pi` бере текст із stdout вбудованого pi-агента, для `cursor` і `codex` отримує його напряму через ACP-міст.
32
+ - backupWorkspacePackageFiles — Зберігає копії `package.json` усіх workspace-пакетів перед змінами, щоб потім відрізнити major і minor оновлення.
33
+ - cleanupBackups — Видаляє тимчасові копії `package.json` після завершення роботи.
34
+ - findCargoManifests — Находить `Cargo.toml` у репозиторії поза службовими директоріями; використовується лише для огляду Rust-крейтів.
35
+ - formatReport — Складає фінальний звіт із результатів усіх ітерацій без окремого LLM-запиту.
36
+ - runTazeOrchestrator — Керує taze від початку до кінця: робить бекап, масово оновлює версії, збирає diff, прибирає тимчасові файли й формує звіт. Для кожного major-пакета окремо запускає обмежений LLM-виклик, щоб збій одного пакета не зупиняв інші.
37
+
38
+ Конфіги: `package.json`, `main.json`
39
+
40
+ ## Гарантії поведінки
41
+
42
+ - Read-only: не виконує операцій запису (ФС/БД).
43
+ - Кешує результати в межах одного прогону.
44
+ - Свідомо пропускає шляхи: `node_modules`.
@@ -0,0 +1,272 @@
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 { collectTazeDiff } from './diff.mjs'
9
+
10
+ /** Суфікс бекапу package.json — той самий, що й у `diff.mjs`/кроці 1 SKILL.md. */
11
+ const BACKUP_SUFFIX = '.taze-bak'
12
+
13
+ /**
14
+ * Промпт ОДНОГО ітеративного виклику — лише кроки 4-6 SKILL.md (breaking
15
+ * changes → сумісність коду → рефакторинг) для ОДНОГО major-пакета. Кроки
16
+ * 1-3/7/8 виконує оркестратор детерміновано, без LLM.
17
+ * @param {{workspace: string, pkg: string, from: string, to: string}} entry запис major-diff (з `collectTazeDiff`)
18
+ * @returns {string} готовий промпт
19
+ */
20
+ export function buildDependencyPrompt({ workspace, pkg, from, to }) {
21
+ return [
22
+ '# Major-оновлення одного пакета: перевірка сумісності й рефакторинг',
23
+ '',
24
+ `Пакет \`${pkg}\` у воркспейсі \`${workspace}\`: **${from} → ${to}** — вже застосовано в package.json/bun.lock (кроки 1-3 виконано детерміновано, без тебе). Твоя задача — лише breaking-changes-перевірка й, за потреби, рефакторинг.`,
25
+ '',
26
+ '## Кроки',
27
+ `1. Зібрати breaking changes цього оновлення: CHANGELOG/Releases репозиторію модуля (поле \`repository\` у \`node_modules/${pkg}/package.json\`), або git/diff між закешованою старою версією (\`~/.bun/install/cache/${pkg}@<стара-версія>/\`) і новою (\`node_modules/${pkg}/\`).`,
28
+ `2. Знайти використання зачепленого API в коді проєкту (\`rg -n\` по імпортах/викликах \`${pkg}\`).`,
29
+ '3. Сумісно — нічого не робити. Несумісно — застосувати міграцію (перейменувати імпорт, оновити сигнатуру виклику, замінити видалену опцію еквівалентом).',
30
+ '4. Якщо були правки — запусти `npx @7n/rules lint`, typecheck/test якщо є в проєкті.',
31
+ '5. Нетривіальна/неоднозначна міграція — не вгадуй, залиш TODO-коментар із посиланням на CHANGELOG.',
32
+ '',
33
+ 'У відповіді одним абзацом підсумуй: сумісно / зрефакторено (які файли) / TODO (чому).'
34
+ ].join('\n')
35
+ }
36
+
37
+ /**
38
+ * Диспетчер одного ітеративного виклику на обраний раннер. `pi` — вбудований
39
+ * pi-агент (`@7n/llm-lib/agent-skill`; текст перехоплюється через `deps.out`,
40
+ * бо `runAgentSkill` не повертає його напряму — лише стрімить у stdout).
41
+ * `cursor`/`codex` — napi-міст ACP (`@7n/llm-lib/acp`; текст — прямий return,
42
+ * idle-timeout і видимість прогресу вже вбудовані в сам міст).
43
+ * @param {'pi' | 'cursor' | 'codex'} runner раннер
44
+ * @param {string} prompt промпт для одного пакета
45
+ * @param {string} cwd робочий каталог
46
+ * @param {{ runAgentSkill?: (prompt: string, opts?: object) => Promise<{ok: boolean, error: string|null}>, runAcpAgent?: (kind: string, prompt: string, cwd: string) => Promise<string> }} [deps] інжекти для тестів
47
+ * @returns {Promise<{ ok: boolean, text: string, error: string|null }>} результат виклику
48
+ */
49
+ export async function callRunner(runner, prompt, cwd, deps = {}) {
50
+ if (runner === 'pi') {
51
+ let runAgentSkill = deps.runAgentSkill
52
+ if (!runAgentSkill) {
53
+ const agentSkillModule = await import('@7n/llm-lib/agent-skill')
54
+ runAgentSkill = agentSkillModule.runAgentSkill
55
+ }
56
+ let text = ''
57
+ const result = await runAgentSkill(prompt, {
58
+ skillId: 'taze',
59
+ tier: 'avg',
60
+ cwd,
61
+ deps: { out: chunk => (text += chunk) }
62
+ })
63
+ return { ok: result.ok, text, error: result.error }
64
+ }
65
+
66
+ let runAcpAgent = deps.runAcpAgent
67
+ if (!runAcpAgent) {
68
+ const acpModule = await import('@7n/llm-lib/acp')
69
+ runAcpAgent = acpModule.runAcpAgent
70
+ }
71
+ try {
72
+ const text = await runAcpAgent(runner, prompt, cwd)
73
+ return { ok: true, text, error: null }
74
+ } catch (error) {
75
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Перевіряє, що `cwd` — ізольований worktree (`main.json.worktree: true`,
81
+ * той самий контракт, що й для інших worktree-only скілів). Раніше цю
82
+ * гарантію тримав агент, читаючи SKILL.md-preflight як частину промпту;
83
+ * оркестратор більше НЕ годує SKILL.md жодному викликові, тож без цієї
84
+ * перевірки `bunx taze -w -r latest`/`bun install` мовчки виконались би
85
+ * прямо в основному дереві виклику. Кидає, якщо `git rev-parse --show-toplevel`
86
+ * не містить `.worktrees` як сегмент шляху (покриває і `npx \@7n/mt worktree
87
+ * create`-конвенцію `.worktrees/`, і сесійну `.claude/worktrees/`).
88
+ * @param {string} cwd каталог для перевірки
89
+ * @param {typeof spawnSync} spawnFn інжект для тестів
90
+ * @returns {void}
91
+ */
92
+ function assertRunningInWorktree(cwd, spawnFn) {
93
+ const result = spawnFn('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
94
+ const toplevel = result.status === 0 ? result.stdout.trim() : ''
95
+ const segments = new Set(toplevel.replaceAll('\\', '/').split('/'))
96
+ if (!segments.has('.worktrees')) {
97
+ throw new Error(
98
+ `taze: "${cwd}" не в ізольованому worktree (git toplevel: "${toplevel || '?'}"). ` +
99
+ 'main.json.worktree=true вимагає окремого дерева — створи його спершу (див. SKILL.md preflight), не запускай taze в основному дереві.'
100
+ )
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Синхронно виконує детерміновану команду (bunx/bun/find), кидає з
106
+ * exit-кодом+stderr при провалі.
107
+ * @param {string} cmd бінарник
108
+ * @param {string[]} args аргументи
109
+ * @param {string} cwd робочий каталог
110
+ * @param {typeof spawnSync} spawnFn інжект для тестів
111
+ * @returns {string} stdout
112
+ */
113
+ function runCommand(cmd, args, cwd, spawnFn) {
114
+ const result = spawnFn(cmd, args, { cwd, encoding: 'utf8' })
115
+ if (result.status !== 0) {
116
+ throw new Error(`${cmd} ${args.join(' ')} → exit ${result.status}: ${result.stderr || result.stdout}`)
117
+ }
118
+ return result.stdout
119
+ }
120
+
121
+ /**
122
+ * Бекапить package.json кожного воркспейсу (крок 1 SKILL.md) — потрібно для
123
+ * класифікації major/minor через `collectTazeDiff` після bump-у.
124
+ * @param {string} cwd корінь репо
125
+ * @param {{ getMonorepoPackageRootDirs?: (cwd: string) => Promise<string[]>, copyFile?: (src: string, dest: string) => Promise<void> }} [deps] інжекти
126
+ * @returns {Promise<string[]>} відносні шляхи воркспейсів, що мали package.json
127
+ */
128
+ export async function backupWorkspacePackageFiles(cwd, deps = {}) {
129
+ const getRoots = deps.getMonorepoPackageRootDirs ?? getMonorepoPackageRootDirs
130
+ const copy = deps.copyFile ?? copyFile
131
+ const roots = await getRoots(cwd)
132
+ const backedUp = []
133
+ for (const ws of roots) {
134
+ const pkgPath = join(cwd, ws, 'package.json')
135
+ if (!existsSync(pkgPath)) continue
136
+ await copy(pkgPath, `${pkgPath}${BACKUP_SUFFIX}`)
137
+ backedUp.push(ws)
138
+ }
139
+ return backedUp
140
+ }
141
+
142
+ /**
143
+ * Прибирає бекапи package.json після завершення (крок 7 SKILL.md).
144
+ * @param {string} cwd корінь репо
145
+ * @param {string[]} workspaces воркспейси з бекапом (з `backupWorkspacePackageFiles`)
146
+ * @param {{ rm?: (path: string, opts?: object) => Promise<void> }} [deps] інжект
147
+ * @returns {Promise<void>}
148
+ */
149
+ export async function cleanupBackups(cwd, workspaces, deps = {}) {
150
+ const remove = deps.rm ?? rm
151
+ for (const ws of workspaces) {
152
+ await remove(join(cwd, ws, `package.json${BACKUP_SUFFIX}`), { force: true })
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Знаходить Cargo.toml поза node_modules/.worktrees/target (крок 0.2
158
+ * SKILL.md). Лише інформаційно — v1 оркестратора Rust-крейти не оновлює
159
+ * (немає детермінованого cargo-diff-еквівалента `collectTazeDiff`,
160
+ * класифікація major там ручна за SKILL.md).
161
+ * @param {string} cwd корінь репо
162
+ * @param {{ spawnFn?: typeof spawnSync }} [deps] інжект
163
+ * @returns {string[]} відносні шляхи знайдених Cargo.toml
164
+ */
165
+ export function findCargoManifests(cwd, deps = {}) {
166
+ const spawnFn = deps.spawnFn ?? spawnSync
167
+ const result = spawnFn(
168
+ 'find',
169
+ [
170
+ '.',
171
+ '-name',
172
+ 'Cargo.toml',
173
+ '-not',
174
+ '-path',
175
+ '*/node_modules/*',
176
+ '-not',
177
+ '-path',
178
+ '*/.worktrees/*',
179
+ '-not',
180
+ '-path',
181
+ '*/target/*'
182
+ ],
183
+ { cwd, encoding: 'utf8' }
184
+ )
185
+ return (result.stdout ?? '')
186
+ .split('\n')
187
+ .map(line => line.trim())
188
+ .filter(Boolean)
189
+ }
190
+
191
+ /**
192
+ * Компонує підсумковий звіт (крок 8 SKILL.md) детерміновано з результатів
193
+ * ітерацій — без окремого LLM-виклику для самого звіту.
194
+ * @param {{ minorPatch: number, totalChanged: number, results: Array<{pkg:string, workspace:string, from:string, to:string, ok:boolean, error:string|null}>, rustCrates: string[] }} args дані звіту
195
+ * @returns {string} markdown-звіт
196
+ */
197
+ export function formatReport({ minorPatch, totalChanged, results, rustCrates }) {
198
+ const lines = [
199
+ '## taze: підсумок',
200
+ '',
201
+ `- **Оновлено (minor/patch):** ${minorPatch}`,
202
+ `- **Major-оновлення:** ${results.length}`
203
+ ]
204
+ for (const r of results) {
205
+ const status = r.ok ? '✅' : '❌'
206
+ const errorSuffix = r.error ? ` — ${r.error}` : ''
207
+ lines.push(` ${status} \`${r.pkg}\` (${r.workspace}): ${r.from} → ${r.to}${errorSuffix}`)
208
+ }
209
+ if (rustCrates.length > 0) {
210
+ lines.push(
211
+ '',
212
+ `- **Rust-крейти (${rustCrates.length}), потребують ручного прогону Rust-гілки SKILL.md:** ${rustCrates.join(', ')}`
213
+ )
214
+ }
215
+ lines.push('', `- **Всього змінено:** ${totalChanged}`)
216
+ return lines.join('\n')
217
+ }
218
+
219
+ /**
220
+ * Оркеструє taze: детерміновані кроки (бекап → масовий bump → diff →
221
+ * прибирання → звіт) без LLM, і по одному ізольованому, обмеженому по
222
+ * обсягу виклику `callRunner` на кожен major-пакет (кроки 4-6 SKILL.md) —
223
+ * замість одного величезного непрозорого ходу на весь монорепо. Кожен
224
+ * виклик успадковує власний timeout/idle-timeout раннера, тож падіння
225
+ * одного пакета не втрачає прогрес по інших.
226
+ * @param {{
227
+ * cwd?: string,
228
+ * runner?: 'pi' | 'cursor' | 'codex',
229
+ * log?: (line: string) => void,
230
+ * deps?: { spawnFn?: typeof spawnSync, collectTazeDiff?: (cwd: string) => Promise<object>, callRunner?: (runner: string, prompt: string, cwd: string, deps: object) => Promise<{ok: boolean, text: string, error: string|null}> } & Record<string, unknown>
231
+ * }} [options] опції + інжекти для тестів
232
+ * @returns {Promise<{ ok: boolean, report: string, results: Array<object> }>} результат
233
+ */
234
+ export async function runTazeOrchestrator(options = {}) {
235
+ const cwd = options.cwd ?? process.cwd()
236
+ const runner = options.runner ?? 'pi'
237
+ const log = options.log ?? (line => console.log(line))
238
+ const deps = options.deps ?? {}
239
+ const spawnFn = deps.spawnFn ?? spawnSync
240
+
241
+ assertRunningInWorktree(cwd, spawnFn)
242
+
243
+ const rustCrates = findCargoManifests(cwd, { spawnFn })
244
+
245
+ log('📦 Бекап package.json...')
246
+ const backedUpWorkspaces = await backupWorkspacePackageFiles(cwd, deps)
247
+
248
+ log('⬆️ bunx taze -w -r latest...')
249
+ runCommand('bunx', ['taze', '-w', '-r', 'latest'], cwd, spawnFn)
250
+ log('📥 bun install...')
251
+ runCommand('bun', ['install'], cwd, spawnFn)
252
+
253
+ const collectDiff = deps.collectTazeDiff ?? collectTazeDiff
254
+ const diff = await collectDiff(cwd)
255
+ log(`🔍 diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
256
+
257
+ const results = []
258
+ const call = deps.callRunner ?? callRunner
259
+ for (const entry of diff.major) {
260
+ log(`🔧 ${entry.pkg} (${entry.workspace}): ${entry.from} → ${entry.to}...`)
261
+ const outcome = await call(runner, buildDependencyPrompt(entry), cwd, deps)
262
+ results.push({ ...entry, ...outcome })
263
+ log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
264
+ }
265
+
266
+ await cleanupBackups(cwd, backedUpWorkspaces, deps)
267
+
268
+ const report = formatReport({ minorPatch: diff.minorPatch, totalChanged: diff.totalChanged, results, rustCrates })
269
+ log(report)
270
+
271
+ return { ok: results.every(r => r.ok), report, results }
272
+ }