@7n/rules 1.21.0 → 1.22.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.22.0] - 2026-07-18
4
+
5
+ ### Added
6
+
7
+ - taze: по завершенню переносить зміни з автоствореного worktree назад у вихідне дерево (untracked) і прибирає worktree
8
+
9
+ ### Changed
10
+
11
+ - taze: worktree-only гейт сам створює .worktrees/branch-taze і продовжує там замість throw-and-stop
12
+
3
13
  ## [1.21.0] - 2026-07-18
4
14
 
5
15
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/taze/js/orchestrate.mjs
5
5
  docgen:
6
- crc: b675f6e7
6
+ crc: bce1f492
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -1,8 +1,8 @@
1
1
  /** @see ./docs/orchestrate.md */
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { existsSync } from 'node:fs'
4
- import { copyFile, rm } from 'node:fs/promises'
5
- import { join } from 'node:path'
4
+ import { copyFile, mkdir, rm } from 'node:fs/promises'
5
+ import { dirname, join } from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
7
 
8
8
  import { assertEcosystemProvider } from '../../../scripts/lib/plugin-api.mjs'
@@ -81,28 +81,122 @@ export async function callRunner(runner, prompt, cwd, deps = {}) {
81
81
  }
82
82
 
83
83
  /**
84
- * Перевіряє, що `cwd` ізольований worktree (`main.json.worktree: true`,
85
- * той самий контракт, що й для інших worktree-only скілів). Раніше цю
86
- * гарантію тримав агент, читаючи SKILL.md-preflight як частину промпту;
87
- * оркестратор більше НЕ годує SKILL.md жодному викликові, тож без цієї
88
- * перевірки `bunx taze -w -r latest`/`bun install` мовчки виконались би
89
- * прямо в основному дереві виклику. Кидає, якщо `git rev-parse --show-toplevel`
90
- * не містить `.worktrees` як сегмент шляху (покриває і `npx \@7n/mt worktree
91
- * create`-конвенцію `.worktrees/`, і сесійну `.claude/worktrees/`).
84
+ * Гарантує, що подальші кроки оркестратора виконуються в ізольованому
85
+ * worktree (`main.json.worktree: true`, той самий контракт, що й для інших
86
+ * worktree-only скілів). Оркестратор не годує SKILL.md жодному агенту, тож
87
+ * замість покладатись на агента, що читає SKILL.md-preflight, сам детерміновано
88
+ * створює worktree конвенцією `<current-branch>-taze` (`npx \@7n/mt worktree
89
+ * create`) і ставить залежності щоб `bunx taze -w -r latest`/`bun install`
90
+ * ніколи не виконались прямо в основному дереві виклику. `autoCreated: true`
91
+ * сигналить викликачеві, що після завершення прогону варто перенести зміни
92
+ * назад (`bringChangesBackToOriginal`) і прибрати worktree
93
+ * (`removeAutoCreatedWorktree`) — а не лишати сирітське дерево.
92
94
  * @param {string} cwd каталог для перевірки
93
95
  * @param {typeof spawnSync} spawnFn інжект для тестів
94
- * @returns {void}
96
+ * @param {(line: string) => void} log колбек прогресу
97
+ * @returns {{ cwd: string, autoCreated: boolean, branchArg: string|null }} `autoCreated: false` — `cwd` без змін
98
+ * (вже worktree); `autoCreated: true` — `cwd` щойно створеного worktree і `branchArg`, з яким його створено
95
99
  */
96
- function assertRunningInWorktree(cwd, spawnFn) {
97
- const result = spawnFn('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
98
- const toplevel = result.status === 0 ? result.stdout.trim() : ''
100
+ function ensureRunningInWorktree(cwd, spawnFn, log) {
101
+ const toplevelResult = spawnFn('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
102
+ const toplevel = toplevelResult.status === 0 ? toplevelResult.stdout.trim() : ''
99
103
  const segments = new Set(toplevel.replaceAll('\\', '/').split('/'))
100
- if (!segments.has('.worktrees')) {
104
+ if (segments.has('.worktrees')) return { cwd, autoCreated: false, branchArg: null }
105
+
106
+ const branchResult = spawnFn('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' })
107
+ const currentBranch = branchResult.status === 0 ? branchResult.stdout.trim() : ''
108
+ if (!currentBranch) {
101
109
  throw new Error(
102
- `taze: "${cwd}" не в ізольованому worktree (git toplevel: "${toplevel || '?'}"). ` +
103
- 'main.json.worktree=true вимагає окремого дерева створи його спершу (див. SKILL.md preflight), не запускай taze в основному дереві.'
110
+ `taze: "${cwd}" не в ізольованому worktree (git toplevel: "${toplevel || '?'}"), і поточну гілку визначити не вдалось ` +
111
+ '(detached HEAD?)автоматичне створення worktree за конвенцією `<current-branch>-taze` неможливе. Перейди на гілку вручну.'
104
112
  )
105
113
  }
114
+
115
+ const branchArg = `${currentBranch}-taze`
116
+ const pathSegment = branchArg.replaceAll('/', '-')
117
+ log(`⚠️ "${cwd}" не в ізольованому worktree — створюю ".worktrees/${pathSegment}"...`)
118
+ runCommand('npx', ['@7n/mt', 'worktree', 'create', branchArg, 'n-taze: worktree-only skill'], cwd, spawnFn)
119
+
120
+ const newCwd = join(cwd, '.worktrees', pathSegment)
121
+ log('📥 bun install (bootstrap нового дерева)...')
122
+ runCommand('bun', ['install'], newCwd, spawnFn)
123
+ return { cwd: newCwd, autoCreated: true, branchArg }
124
+ }
125
+
126
+ /**
127
+ * Переносить зміни з автоствореного worktree назад у вихідне дерево як
128
+ * **untracked/незакомічені** правки (просте копіювання файлів, без git
129
+ * merge/cherry-pick) — так само, як їх бачив би користувач, якби сам
130
+ * працював у вихідному дереві. Джерело істини — `git status --porcelain`
131
+ * у worktree: для кожного шляху копіює файл, якщо він існує (модифікація/
132
+ * додавання), або видаляє його у вихідному дереві, якщо existsSync каже,
133
+ * що в worktree його вже нема (видалення). Перейменування (`old -> new` у
134
+ * porcelain) переносять лише нову назву — стара лишається як була, це
135
+ * прийнятний компроміс: taze не перейменовує файли сам, ефект можливий
136
+ * лише як побічний результат LLM-рефакторингу.
137
+ * @param {string} worktreeCwd автостворений worktree, з якого переносимо
138
+ * @param {string} originalCwd вихідне дерево, куди переносимо
139
+ * @param {typeof spawnSync} spawnFn інжект для тестів
140
+ * @param {(line: string) => void} log колбек прогресу
141
+ * @param {{ copyFile?: (src: string, dest: string) => Promise<void>, rm?: (path: string, opts?: object) => Promise<void>, mkdir?: (path: string, opts?: object) => Promise<void> }} [deps] інжекти для тестів
142
+ * @returns {Promise<string[]>} відносні шляхи перенесених файлів
143
+ */
144
+ export async function bringChangesBackToOriginal(worktreeCwd, originalCwd, spawnFn, log, deps = {}) {
145
+ const copy = deps.copyFile ?? copyFile
146
+ const removeFile = deps.rm ?? rm
147
+ const makeDir = deps.mkdir ?? mkdir
148
+
149
+ const statusResult = spawnFn('git', ['status', '--porcelain'], { cwd: worktreeCwd, encoding: 'utf8' })
150
+ if (statusResult.status !== 0) {
151
+ log(
152
+ `⚠️ Не вдалось прочитати git status у "${worktreeCwd}" — зміни НЕ перенесені назад, worktree лишиться для ручного розбору.`
153
+ )
154
+ return []
155
+ }
156
+
157
+ const lines = statusResult.stdout.split('\n').filter(Boolean)
158
+ if (lines.length === 0) {
159
+ log('ℹ️ Worktree без змін — нічого переносити назад.')
160
+ return []
161
+ }
162
+
163
+ const brought = []
164
+ for (const line of lines) {
165
+ const rest = line.slice(3)
166
+ const relPath = rest.includes(' -> ') ? rest.split(' -> ', 2)[1] : rest
167
+ const srcPath = join(worktreeCwd, relPath)
168
+ const destPath = join(originalCwd, relPath)
169
+ if (existsSync(srcPath)) {
170
+ await makeDir(dirname(destPath), { recursive: true })
171
+ await copy(srcPath, destPath)
172
+ } else {
173
+ await removeFile(destPath, { force: true })
174
+ }
175
+ brought.push(relPath)
176
+ }
177
+ log(`📤 Перенесено назад у "${originalCwd}" як untracked: ${brought.join(', ')}`)
178
+ return brought
179
+ }
180
+
181
+ /**
182
+ * Прибирає автостворений worktree разом з його ефемерною git-гілкою
183
+ * (`npx \@7n/mt worktree remove <branch>`) — викликати лише ПІСЛЯ
184
+ * `bringChangesBackToOriginal`, інакше зміни згорять разом з деревом.
185
+ * Не кидає при провалі — це прибирання, а не крок, від якого залежить
186
+ * результат прогону; провал лише логується, worktree лишається для
187
+ * ручного розбору.
188
+ * @param {string} branchArg гілка, з якою worktree був створений (з `ensureRunningInWorktree`)
189
+ * @param {string} originalCwd вихідне дерево, звідки виконати `npx \@7n/mt worktree remove`
190
+ * @param {typeof spawnSync} spawnFn інжект для тестів
191
+ * @param {(line: string) => void} log колбек прогресу
192
+ * @returns {void}
193
+ */
194
+ export function removeAutoCreatedWorktree(branchArg, originalCwd, spawnFn, log) {
195
+ log(`🧹 Прибираю автостворений worktree "${branchArg}"...`)
196
+ const result = spawnFn('npx', ['@7n/mt', 'worktree', 'remove', branchArg], { cwd: originalCwd, encoding: 'utf8' })
197
+ if (result.status !== 0) {
198
+ log(`⚠️ Не вдалось прибрати worktree "${branchArg}" — приберіть вручну (${result.stderr || result.stdout})`)
199
+ }
106
200
  }
107
201
 
108
202
  /**
@@ -329,62 +423,79 @@ export function formatReport({ minorPatch, totalChanged, results, ecosystems = [
329
423
  * @returns {Promise<{ ok: boolean, report: string, results: Array<object>, ecosystems: Array<object> }>} результат
330
424
  */
331
425
  export async function runTazeOrchestrator(options = {}) {
332
- const cwd = options.cwd ?? process.cwd()
333
426
  const runner = options.runner ?? 'pi'
334
427
  const log = options.log ?? (line => console.log(line))
335
428
  const deps = options.deps ?? {}
336
429
  const spawnFn = deps.spawnFn ?? spawnSync
337
430
  const call = deps.callRunner ?? callRunner
338
431
 
339
- assertRunningInWorktree(cwd, spawnFn)
340
-
341
- // npm/bun-гілка активна лише за кореневим package.json — на чисто-Python/Rust
342
- // репо `bun install` падає з exit 1, і без цього гейта весь прогін гинув би
343
- // до екосистемних провайдерів. Той самий принцип «тиші», що й для мовних
344
- // екосистем: немає сигналу — немає ані кроків, ані згадки у звіті.
345
- const npmPresent = existsSync(join(cwd, 'package.json'))
346
- let diff = { major: [], minorPatch: 0, totalChanged: 0 }
347
- const results = []
348
- if (npmPresent) {
349
- log('📦 Бекап package.json...')
350
- const backedUpWorkspaces = await backupWorkspacePackageFiles(cwd, deps)
351
-
352
- log('⬆️ bunx taze -w -r latest...')
353
- runCommand('bunx', ['taze', '-w', '-r', 'latest'], cwd, spawnFn)
354
- log('📥 bun install...')
355
- runCommand('bun', ['install'], cwd, spawnFn)
356
-
357
- const collectDiff = deps.collectTazeDiff ?? collectTazeDiff
358
- diff = await collectDiff(cwd)
359
- log(`🔍 diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
432
+ const originalCwd = options.cwd ?? process.cwd()
433
+ const worktree = ensureRunningInWorktree(originalCwd, spawnFn, log)
434
+ const cwd = worktree.cwd
360
435
 
361
- for (const entry of diff.major) {
362
- log(`🔧 ${entry.pkg} (${entry.workspace}): ${entry.from} ${entry.to}...`)
363
- const outcome = await call(runner, buildDependencyPrompt(entry), cwd, deps)
364
- results.push({ ...entry, ...outcome })
365
- log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
436
+ try {
437
+ // npm/bun-гілка активна лише за кореневим package.json на чисто-Python/Rust
438
+ // репо `bun install` падає з exit 1, і без цього гейта весь прогін гинув би
439
+ // до екосистемних провайдерів. Той самий принцип «тиші», що й для мовних
440
+ // екосистем: немає сигналу немає ані кроків, ані згадки у звіті.
441
+ const npmPresent = existsSync(join(cwd, 'package.json'))
442
+ let diff = { major: [], minorPatch: 0, totalChanged: 0 }
443
+ const results = []
444
+ if (npmPresent) {
445
+ log('📦 Бекап package.json...')
446
+ const backedUpWorkspaces = await backupWorkspacePackageFiles(cwd, deps)
447
+
448
+ log('⬆️ bunx taze -w -r latest...')
449
+ runCommand('bunx', ['taze', '-w', '-r', 'latest'], cwd, spawnFn)
450
+ log('📥 bun install...')
451
+ runCommand('bun', ['install'], cwd, spawnFn)
452
+
453
+ const collectDiff = deps.collectTazeDiff ?? collectTazeDiff
454
+ diff = await collectDiff(cwd)
455
+ log(`🔍 diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
456
+
457
+ for (const entry of diff.major) {
458
+ log(`🔧 ${entry.pkg} (${entry.workspace}): ${entry.from} → ${entry.to}...`)
459
+ const outcome = await call(runner, buildDependencyPrompt(entry), cwd, deps)
460
+ results.push({ ...entry, ...outcome })
461
+ log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
462
+ }
463
+
464
+ await cleanupBackups(cwd, backedUpWorkspaces, deps)
465
+ } else {
466
+ log('⏭ npm/bun: кореневого package.json немає — гілка пропущена')
366
467
  }
367
468
 
368
- await cleanupBackups(cwd, backedUpWorkspaces, deps)
369
- } else {
370
- log('⏭ npm/bun: кореневого package.json немає — гілка пропущена')
371
- }
469
+ const providers = deps.ecosystemProviders ?? (await loadPluginTazeProviders(cwd, log, deps))
470
+ const ecosystems = []
471
+ for (const provider of providers) {
472
+ ecosystems.push(await runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call }))
473
+ }
372
474
 
373
- const providers = deps.ecosystemProviders ?? (await loadPluginTazeProviders(cwd, log, deps))
374
- const ecosystems = []
375
- for (const provider of providers) {
376
- ecosystems.push(await runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call }))
475
+ const report = formatReport({
476
+ minorPatch: diff.minorPatch,
477
+ totalChanged: diff.totalChanged,
478
+ results,
479
+ ecosystems,
480
+ npmPresent
481
+ })
482
+ log(report)
483
+
484
+ const ecosystemsOk = ecosystems.every(eco => eco.error === null && eco.results.every(r => r.ok))
485
+ return { ok: results.every(r => r.ok) && ecosystemsOk, report, results, ecosystems }
486
+ } finally {
487
+ // Лише для АВТОстворених worktree — якщо викликач уже сидів у своєму
488
+ // worktree (worktree.autoCreated === false), це не наш worktree і не
489
+ // нам його чіпати/прибирати. `finally` — щоб зміни поверталися і
490
+ // сирітський worktree прибирався навіть при кинутому винятку всередині
491
+ // try (падіння bunx/diff/провайдера), а не лише при успішному прогоні.
492
+ if (worktree.autoCreated) {
493
+ try {
494
+ await bringChangesBackToOriginal(cwd, originalCwd, spawnFn, log, deps)
495
+ } catch (error) {
496
+ log(`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`)
497
+ }
498
+ removeAutoCreatedWorktree(worktree.branchArg, originalCwd, spawnFn, log)
499
+ }
377
500
  }
378
-
379
- const report = formatReport({
380
- minorPatch: diff.minorPatch,
381
- totalChanged: diff.totalChanged,
382
- results,
383
- ecosystems,
384
- npmPresent
385
- })
386
- log(report)
387
-
388
- const ecosystemsOk = ecosystems.every(eco => eco.error === null && eco.results.every(r => r.ok))
389
- return { ok: results.every(r => r.ok) && ecosystemsOk, report, results, ecosystems }
390
501
  }