@7n/rules 1.49.6 → 1.49.7

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.49.7] - 2026-07-26
4
+
5
+ ### Fixed
6
+
7
+ - Усунено порожні PR, помилкові empty cherry-pick, неповний cleanup aliases і зайві LLM-виклики в `git-reconcile`.
8
+
3
9
  ## [1.49.6] - 2026-07-26
4
10
 
5
11
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.49.6",
3
+ "version": "1.49.7",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -65,7 +65,7 @@
65
65
  "rename-yaml-extensions": "bun ./bin/n-rules.js rename-yaml-extensions"
66
66
  },
67
67
  "dependencies": {
68
- "@7n/llm-lib": "2.9.5",
68
+ "@7n/llm-lib": "2.9.6",
69
69
  "@7n/mt": "^0.5.1",
70
70
  "@zed-industries/agent-client-protocol": "^0.4.5",
71
71
  "ajv": "^8.20.0",
@@ -23,7 +23,9 @@ npx @7n/rules skill pi git-reconcile
23
23
 
24
24
  JS виконує `fetch`, inventory, patch-equivalence, дедуплікацію refs, збір
25
25
  worktree/PR/stash, підготовку worktree від `origin/main`, cherry-pick або
26
- застосування stash, gates, commit, push, PR і фінальний звіт.
26
+ застосування stash, gates, commit, push, PR і фінальний звіт. Semantic no-op
27
+ після conflict resolution детерміновано пропускає через `cherry-pick --skip`;
28
+ порожній tree diff не push-иться і не створює PR.
27
29
 
28
30
  LLM отримує лише bounded-завдання:
29
31
 
@@ -72,6 +74,11 @@ transport. Після провалу `max` джерело fail-closed лишає
72
74
  `npm exec --package`.
73
75
  - За невизначеності джерело лишається `kept`; misleading ready PR не
74
76
  створюється.
77
+ - Перед push обов'язково проходять фінальний tree-diff guard, domain lint для
78
+ non-code paths, changelog і `git diff --check`; code changes додатково
79
+ проходять scoped docs/lint та tests.
80
+ - Behavioral LLM не викликається для змін без code paths; test baseline
81
+ актуального `origin/main` кешується між PR-групами.
75
82
  - Cleanup виконує лише JS і тільки після inventory/PR-фази: видаляє точні refs,
76
83
  уже merged/patch-equivalent, явно класифіковані як `drop` або повністю
77
84
  перенесені в успішний PR.
@@ -85,5 +92,5 @@ transport. Після провалу `max` джерело fail-closed лишає
85
92
  Оркестратор повертає для кожного джерела один verdict: `merged`,
86
93
  `patch-equivalent`, `open-pr`, `protected`, `pr-created`, `kept`,
87
94
  `drop-recommended` або `failed`. Для `pr-created` додає URL, перенесені коміти,
88
- конфлікти та виконані перевірки. Для cleanup ref звіт також містить точний OID,
89
- щоб видалення можна було аудіювати після завершення.
95
+ конфлікти та виконані перевірки. Для cleanup ref звіт також містить точний OID
96
+ і видалені aliases, а для failure — branch та збережений worktree.
@@ -3,12 +3,9 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/git-reconcile/js/orchestrate.mjs
5
5
  docgen:
6
- crc: 09f8b69d
7
- model: openai-codex/gpt-5.4-mini
8
- tier: cloud-min
9
- score: 100
10
- issues: judge-refine:kept-original,judge:inaccurate:0.99
11
- judgeModel: openai-codex/gpt-5.4-mini
6
+ crc: f0c9ea76
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 70
12
9
  ---
13
10
 
14
11
  ## Огляд
@@ -19,59 +16,42 @@ JS виконує inventory, validation, materialization, gates, PR і cleanup,
19
16
 
20
17
  ## Поведінка
21
18
 
22
- - Збирає refs, worktree, open PR, patch-equivalent commits і stash. Detached
23
- worktree захищає за checkout HEAD OID.
19
+ - Збирає refs, aliases, worktree, open PR, patch-equivalent commits і stash.
20
+ Detached worktree захищає за checkout HEAD OID.
24
21
  - Ізолює вкладені `npx` від `npm_config_package` зовнішнього `npm exec`.
22
+ - Приглушує дубльовані ACP events, якщо користувач не ввімкнув explicit verbose.
25
23
  - Показує inventory elapsed time та окремі progress bars для triage, PR і
26
- cleanup; у non-TTY виводить append-only progress.
27
- - Перевіряє повноту triage, groups та commit OID. Infrastructure failure
28
- runner завершує крок одразу; `max` запускається лише після валідної
29
- відповіді, що не пройшла validation.
30
- - Створює worktree від `origin/main`, за потреби ставить frozen Bun
31
- dependencies і фіксує test baseline до перенесення source.
32
- - Red baseline допускається лише для розпізнаних Vitest failures, якщо
33
- post-change набір не містить нових failures. Нерозпізнаний output
34
- fail-closed.
35
- - Зводить tracked+untracked code paths до унікальних директорій. Для кожної
36
- запускає scoped `doc-files` fix та unified lint `--no-fix`, не скануючи
37
- repository-wide baseline.
38
- - Empty cherry-pick пропускається лише за активного `CHERRY_PICK_HEAD`,
39
- відсутніх conflicts і порожнього staged diff.
40
- - Після LLM edits JS сам виконує Git checks, repository tests і changelog
41
- gate. LLM prompt дозволяє лише narrow tests.
42
- - Створює PR і лише після успіху точково прибирає перенесені або доведено
43
- неактуальні refs/stash; cleanup refs у звіті мають точний OID.
24
+ cleanup; довгі deterministic gates отримують окремі stage labels.
25
+ - Перевіряє повноту triage, groups та commit OID. `max` запускається лише після
26
+ конкретного провалу validation.
27
+ - Створює worktree від `origin/main`; baseline tests кешує за OID бази між
28
+ PR-групами. Для змін без code paths behavioral LLM не викликається.
29
+ - Red baseline допускається лише для розпізнаних Vitest failures без нових
30
+ failures після перенесення.
31
+ - Для code paths запускає scoped `doc-files`, lint і tests. Перед push запускає
32
+ domain lint для non-code paths, changelog та `git diff --check`.
33
+ - Після conflict resolution порожній cherry-pick пропускається лише за
34
+ активного `CHERRY_PICK_HEAD`, відсутніх conflicts і порожнього staged diff.
35
+ - Tree-diff guard двічі відсікає semantic no-op. Порожня група отримує
36
+ `patch-equivalent`, worktree прибирається, push і PR не виконуються.
37
+ - Cleanup видаляє всі точні local/remote aliases і звітує їх. Failed source
38
+ зберігає branch, worktree і, якщо PR уже створено, його URL.
44
39
 
45
40
  ## Публічний API
46
41
 
47
- - parseWorktrees — Парсить `git worktree list --porcelain` у branch→path.
48
- - dedupeRefs — Дедуплікує local/remote refs одного commit: remote має пріоритет, але
49
- worktree-protection локального ref переноситься у запис.
50
- - conflictFilesВитягає конфліктні файли з `git merge-tree`.
51
- - inventoryRepositoryЗбирає детермінований Git inventory. Нічого не видаляє і не змінює у
52
- checkout, крім оновлення remote refs через fetch --prune.
53
- - buildTriagePromptФормує bounded semantic-triage prompt. Git-факти вже пораховані JS; модель
54
- не виконує shell-команди й повертає лише JSON-рішення.
55
- - parseDecisionEnvelopeВитягає JSON object із чистої або fenced відповіді.
56
- - callRunnerВикликає вибраний LLM runner для одного bounded-завдання.
57
- - callWithValidatedFallbackВиконує bounded LLM-крок через min, валідовує результат JS-функцією і
58
- викликає max лише після конкретного провалу.
59
- - validateTriageOutcomeСтруктурно перевіряє triage-рішення: рівно один verdict на candidate,
60
- валідні actions/groups і лише відомі commit OID.
61
- - branchSlug — Перетворює довільний title/ref на branch slug.
62
- - skipEmptyCherryPick — Пропускає лише підтверджений empty cherry-pick: sequencer активний,
63
- конфліктів немає, staged diff порожній.
64
- - testFailureSignatures — Витягає стабільні Vitest failure identifiers без summary/timing.
65
- - acceptsTestOutcome — Дозволяє red baseline лише якщо після перенесення не з'явилось нових
66
- Vitest failures. Нерозпізнаний red output завжди fail-closed.
67
- - sourceDirectories — Зводить змінені code paths до найвужчих директорій для scoped gates.
68
- - captureBehaviorBaseline — Фіксує test baseline на чистому origin/main до перенесення source.
69
- - validateBehaviorState — Додає до Git-state validation test script із репозиторію і changelog gate.
70
- Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
71
- - cleanupSource — Видаляє точний source після Git-доказу неактуальності або успішного
72
- перенесення. Protected/open-PR refs не потрапляють у цей крок.
73
- - formatReport — Формує deterministic report.
74
- - runGitReconcileOrchestrator — JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
42
+ - `parseWorktrees`парсить `git worktree list --porcelain`.
43
+ - `dedupeRefs`зводить local/remote refs одного commit зі збереженням aliases.
44
+ - `inventoryRepository` збирає детермінований Git inventory.
45
+ - `callWithValidatedFallback`виконує min validation max fallback.
46
+ - `finishCherryPick`пропускає semantic no-op або продовжує непорожній pick.
47
+ - `hasChangesFromBase` перевіряє реальний tree diff, а не commits ahead.
48
+ - `captureCachedBehaviorBaseline`кешує test baseline за OID `origin/main`.
49
+ - `changedNonCodeDirectories` групує non-code paths у domain directories.
50
+ - `validateBehaviorState`перевіряє scoped code gates і test regression.
51
+ - `validateFinalProjectGates`перевіряє non-code domains та changelog.
52
+ - `cleanupSource`видаляє тільки доказово безпечні точні refs або stash.
53
+ - `formatReport` формує deterministic звіт із cleanup та failure details.
54
+ - `runGitReconcileOrchestrator`координує inventory, triage, PR і cleanup.
75
55
 
76
56
  ## Гарантії поведінки
77
57
 
@@ -82,4 +62,5 @@ Vitest failures. Нерозпізнаний red output завжди fail-closed.
82
62
  - Stash видаляється точково за стабільним commit ID; `git stash clear` не
83
63
  використовується.
84
64
  - Worktree із проваленим перенесенням зберігається для діагностики.
65
+ - Порожній tree diff не може створити remote branch або PR.
85
66
  - Progress не змішує фази з різною вартістю в удаваний global percentage.
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process'
3
3
  import { existsSync, readFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { performance } from 'node:perf_hooks'
6
+ import { env } from 'node:process'
6
7
 
7
8
  import { createProgressReporter } from '../../../scripts/lib/lint-surface/progress.mjs'
8
9
 
@@ -19,6 +20,7 @@ const REF_ORIGIN_RE = /^refs\/remotes\/origin\//
19
20
  const RENAME_DELETE_CONFLICT_RE = /^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/
20
21
  const SOURCE_CODE_RE = /\.(?:js|mjs|ts|vue|rs|py)$/
21
22
  const WHITESPACE_RE = /\s+/
23
+ const ACP_PROGRESS_ENV = 'N_LLM_ACP_PROGRESS'
22
24
  const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '%(committer', 'date:iso-strict)'].join(
23
25
  ''
24
26
  )
@@ -84,13 +86,13 @@ function createPhaseProgress(args) {
84
86
  * @returns {{ status: number, stdout: string, stderr: string }} результат
85
87
  */
86
88
  function run(command, args, cwd, spawnFn, options = {}) {
87
- const env = { ...process.env, GIT_EDITOR: 'true' }
88
- if (command === 'npx') delete env.npm_config_package
89
+ const childEnv = { ...env, GIT_EDITOR: 'true' }
90
+ if (command === 'npx') delete childEnv.npm_config_package
89
91
  const result = spawnFn(command, args, {
90
92
  cwd,
91
93
  encoding: 'utf8',
92
94
  input: options.input,
93
- env,
95
+ env: childEnv,
94
96
  maxBuffer: 16 * 1024 * 1024
95
97
  })
96
98
  const normalized = {
@@ -216,7 +218,7 @@ function branchState({ merged, novelCommitIds, pr, worktree }) {
216
218
  * Збирає відкриті PR; недоступний gh не блокує git-inventory.
217
219
  * @param {string} cwd корінь
218
220
  * @param {typeof spawnSync} spawnFn інжект
219
- * @returns {Map<string,{number:number,url:string}>} head branch → PR
221
+ * @returns {{items:Map<string,{number:number,url:string}>,warning:string|null}} inventory
220
222
  */
221
223
  function openPullRequests(cwd, spawnFn) {
222
224
  const result = run(
@@ -226,9 +228,18 @@ function openPullRequests(cwd, spawnFn) {
226
228
  spawnFn,
227
229
  { allowFailure: true }
228
230
  )
229
- if (result.status !== 0) return new Map()
231
+ if (result.status !== 0) {
232
+ const detail = result.stderr || result.stdout || `exit ${result.status}`
233
+ return {
234
+ items: new Map(),
235
+ warning: `GitHub PR inventory недоступний: ${detail}`
236
+ }
237
+ }
230
238
  const rows = /** @type {Array<{headRefName:string,number:number,url:string}>} */ (parseJson(result.stdout, []))
231
- return new Map(rows.map(row => [row.headRefName, { number: row.number, url: row.url }]))
239
+ return {
240
+ items: new Map(rows.map(row => [row.headRefName, { number: row.number, url: row.url }])),
241
+ warning: null
242
+ }
232
243
  }
233
244
 
234
245
  /**
@@ -290,9 +301,9 @@ export function inventoryRepository(cwd, deps = {}) {
290
301
  worktreeState.branches,
291
302
  worktreeState.commits
292
303
  )
293
- const prs = openPullRequests(cwd, spawnFn)
294
- const warnings = []
295
- if (prs.size === 0) warnings.push('GitHub PR inventory порожній або gh недоступний')
304
+ const prInventory = openPullRequests(cwd, spawnFn)
305
+ const prs = prInventory.items
306
+ const warnings = prInventory.warning ? [prInventory.warning] : []
296
307
 
297
308
  const branches = refs.map(item => {
298
309
  const name = branchName(item.ref)
@@ -324,6 +335,7 @@ export function inventoryRepository(cwd, deps = {}) {
324
335
  return {
325
336
  source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
326
337
  ref: item.ref,
338
+ aliases: item.aliases,
327
339
  name,
328
340
  oid: item.oid,
329
341
  date: item.date,
@@ -430,11 +442,17 @@ export async function callRunner(runner, prompt, cwd, deps = {}, tier = 'max') {
430
442
  const module = await import('@7n/llm-lib/acp')
431
443
  runAcpAgent = module.runAcpAgent
432
444
  }
445
+ const previousProgress = env[ACP_PROGRESS_ENV]
446
+ const verbose = ['1', 'true'].includes((env.N_LLM_ACP_VERBOSE ?? '').toLowerCase())
447
+ if (previousProgress === undefined && !verbose) env[ACP_PROGRESS_ENV] = '0'
433
448
  try {
434
449
  const text = await runAcpAgent(runner, prompt, cwd, { tier })
435
450
  return { ok: true, text, error: null }
436
451
  } catch (error) {
437
452
  return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
453
+ } finally {
454
+ if (previousProgress === undefined) delete env[ACP_PROGRESS_ENV]
455
+ else env[ACP_PROGRESS_ENV] = previousProgress
438
456
  }
439
457
  }
440
458
 
@@ -628,6 +646,21 @@ function createReconcileWorktree(title, source, cwd, spawnFn) {
628
646
  return { branch, cwd: worktreeCwd }
629
647
  }
630
648
 
649
+ /**
650
+ * Прибирає reconciliation worktree або fail-closed лишає source неочищеним.
651
+ * @param {{branch:string,cwd:string}} worktree створений worktree
652
+ * @param {string} rootCwd корінь репо
653
+ * @param {typeof spawnSync} spawnFn інжект
654
+ */
655
+ function removeReconcileWorktree(worktree, rootCwd, spawnFn) {
656
+ const removed = run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
657
+ allowFailure: true
658
+ })
659
+ if (removed.status !== 0) {
660
+ throw new Error(`worktree cleanup: ${removed.stderr || removed.stdout}`)
661
+ }
662
+ }
663
+
631
664
  /**
632
665
  * Перевіряє, чи лишились unmerged paths.
633
666
  * @param {string} cwd worktree
@@ -660,6 +693,42 @@ export function skipEmptyCherryPick(cwd, spawnFn = spawnSync) {
660
693
  return true
661
694
  }
662
695
 
696
+ /**
697
+ * Завершує активний cherry-pick: semantic no-op пропускає, непорожній
698
+ * продовжує. Відсутній sequencer не потребує дії.
699
+ * @param {string} cwd worktree
700
+ * @param {typeof spawnSync} spawnFn інжект
701
+ * @returns {'none'|'skipped'|'continued'} виконана дія
702
+ */
703
+ export function finishCherryPick(cwd, spawnFn = spawnSync) {
704
+ const inProgress =
705
+ git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], cwd, spawnFn, {
706
+ allowFailure: true
707
+ }).status === 0
708
+ if (!inProgress) return 'none'
709
+ if (skipEmptyCherryPick(cwd, spawnFn)) return 'skipped'
710
+ git(['cherry-pick', '--continue'], cwd, spawnFn)
711
+ return 'continued'
712
+ }
713
+
714
+ /**
715
+ * Перевіряє реальний tree diff, а не лише кількість commits ahead.
716
+ * @param {string} cwd worktree
717
+ * @param {typeof spawnSync} spawnFn інжект
718
+ * @returns {boolean} чи є що переносити в PR
719
+ */
720
+ export function hasChangesFromBase(cwd, spawnFn = spawnSync) {
721
+ return (
722
+ git(['diff', '--quiet', `${BASE_REF}...HEAD`], cwd, spawnFn, {
723
+ allowFailure: true
724
+ }).status !== 0 ||
725
+ git(['diff', '--quiet', BASE_REF, '--'], cwd, spawnFn, {
726
+ allowFailure: true
727
+ }).status !== 0 ||
728
+ git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.trim().length > 0
729
+ )
730
+ }
731
+
663
732
  /**
664
733
  * Перевіряє, що agentic-крок залишив Git у консистентному стані.
665
734
  * @param {string} cwd worktree
@@ -731,10 +800,38 @@ export function sourceDirectories(paths) {
731
800
  * @param {typeof spawnSync} spawnFn інжект
732
801
  * @returns {string[]} directories
733
802
  */
734
- function changedSourceDirectories(cwd, spawnFn) {
803
+ function changedPaths(cwd, spawnFn) {
735
804
  const tracked = git(['diff', '--name-only', BASE_REF, '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
736
805
  const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
737
- return sourceDirectories([...tracked, ...untracked]).filter(path => existsSync(join(cwd, path === '.' ? '' : path)))
806
+ return [...new Set([...tracked, ...untracked])]
807
+ }
808
+
809
+ /**
810
+ * Повертає директорії зміненого коду.
811
+ * @param {string} cwd worktree
812
+ * @param {typeof spawnSync} spawnFn інжект
813
+ * @returns {string[]} directories
814
+ */
815
+ function changedSourceDirectories(cwd, spawnFn) {
816
+ return sourceDirectories(changedPaths(cwd, spawnFn)).filter(path => existsSync(join(cwd, path === '.' ? '' : path)))
817
+ }
818
+
819
+ /**
820
+ * Повертає директорії non-code змін для фінального domain lint.
821
+ * @param {string} cwd worktree
822
+ * @param {typeof spawnSync} spawnFn інжект
823
+ * @returns {string[]} directories
824
+ */
825
+ export function changedNonCodeDirectories(cwd, spawnFn = spawnSync) {
826
+ return [
827
+ ...new Set(
828
+ changedPaths(cwd, spawnFn)
829
+ .filter(path => !SOURCE_CODE_RE.test(path))
830
+ .map(path => dirname(path))
831
+ )
832
+ ]
833
+ .filter(path => existsSync(join(cwd, path === '.' ? '' : path)))
834
+ .toSorted()
738
835
  }
739
836
 
740
837
  /**
@@ -742,16 +839,19 @@ function changedSourceDirectories(cwd, spawnFn) {
742
839
  * зміненого коду, не торкаючись repository-wide baseline.
743
840
  * @param {string} cwd worktree
744
841
  * @param {typeof spawnSync} spawnFn інжект
842
+ * @param {(stage:string)=>void} [onProgress] stage callback
745
843
  * @returns {{ok:boolean,error?:string}} gate
746
844
  */
747
- function validateScopedProjectGates(cwd, spawnFn) {
845
+ function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
748
846
  for (const path of changedSourceDirectories(cwd, spawnFn)) {
847
+ onProgress(`doc-files (${path})`)
749
848
  const docs = run('npx', ['@7n/rules', 'lint', 'doc-files', '--path', path], cwd, spawnFn, {
750
849
  allowFailure: true
751
850
  })
752
851
  if (docs.status !== 0) {
753
852
  return { ok: false, error: `doc-files (${path}): ${docs.stderr || docs.stdout}` }
754
853
  }
854
+ onProgress(`scoped lint (${path})`)
755
855
  const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
756
856
  allowFailure: true
757
857
  })
@@ -788,18 +888,36 @@ export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
788
888
  return { tests }
789
889
  }
790
890
 
891
+ /**
892
+ * Повторно використовує test baseline одного origin/main між PR-групами.
893
+ * Залежності все одно встановлюються в кожному окремому worktree.
894
+ * @param {string} cwd worktree
895
+ * @param {Map<string,object>} cache кеш за OID бази
896
+ * @param {typeof spawnSync} spawnFn інжект
897
+ * @returns {{baseline:object,cached:boolean}} baseline і ознака cache hit
898
+ */
899
+ export function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync) {
900
+ ensureWorktreeDependencies(cwd, spawnFn)
901
+ const baseOid = git(['rev-parse', BASE_REF], cwd, spawnFn).stdout.trim()
902
+ if (cache.has(baseOid)) return { baseline: cache.get(baseOid), cached: true }
903
+ const baseline = captureBehaviorBaseline(cwd, spawnFn)
904
+ cache.set(baseOid, baseline)
905
+ return { baseline, cached: false }
906
+ }
907
+
791
908
  /**
792
909
  * Додає до Git-state validation test script із репозиторію і changelog gate.
793
910
  * Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
794
911
  * @param {string} cwd worktree
795
912
  * @param {typeof spawnSync} spawnFn інжект
796
913
  * @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан origin/main
914
+ * @param {(stage:string)=>void} [onProgress] stage callback
797
915
  * @returns {{ok:boolean,error?:string}} validation
798
916
  */
799
- export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null) {
917
+ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null, onProgress = noop) {
800
918
  const gitState = validateGitState(cwd, spawnFn)
801
919
  if (!gitState.ok) return gitState
802
- const projectGates = validateScopedProjectGates(cwd, spawnFn)
920
+ const projectGates = validateScopedProjectGates(cwd, spawnFn, onProgress)
803
921
  if (!projectGates.ok) return projectGates
804
922
  const postGateGitState = validateGitState(cwd, spawnFn)
805
923
  if (!postGateGitState.ok) return postGateGitState
@@ -808,6 +926,7 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null)
808
926
  if (existsSync(packageJsonPath)) {
809
927
  const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
810
928
  if (packageJson?.scripts?.test) {
929
+ onProgress('project tests')
811
930
  const tests = run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true })
812
931
  if (!acceptsTestOutcome(baseline?.tests ?? null, tests)) {
813
932
  return { ok: false, error: `bun run test: ${tests.stderr || tests.stdout}` }
@@ -815,6 +934,7 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null)
815
934
  }
816
935
  }
817
936
 
937
+ onProgress('changelog')
818
938
  const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
819
939
  if (changelog.status !== 0) {
820
940
  return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
@@ -822,6 +942,27 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null)
822
942
  return { ok: true }
823
943
  }
824
944
 
945
+ /**
946
+ * Фінальний domain gate охоплює non-code зміни, зокрема workflows, dependency
947
+ * manifests і правила. Code directories уже пройшли scoped lint і tests.
948
+ * @param {string} cwd worktree
949
+ * @param {typeof spawnSync} spawnFn інжект
950
+ * @returns {{ok:boolean,error?:string}} gate
951
+ */
952
+ export function validateFinalProjectGates(cwd, spawnFn = spawnSync) {
953
+ for (const path of changedNonCodeDirectories(cwd, spawnFn)) {
954
+ const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
955
+ allowFailure: true
956
+ })
957
+ if (lint.status !== 0) return { ok: false, error: `domain lint (${path}): ${lint.stderr || lint.stdout}` }
958
+ }
959
+ const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, {
960
+ allowFailure: true
961
+ })
962
+ if (changelog.status !== 0) return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
963
+ return { ok: true }
964
+ }
965
+
825
966
  /**
826
967
  * Просить LLM розв'язати лише вже матеріалізований конфлікт.
827
968
  * @param {object} args контекст
@@ -868,13 +1009,7 @@ async function applySource(args) {
868
1009
  throw new Error(`cherry-pick ${oid}: ${result.stderr || result.stdout}`)
869
1010
  }
870
1011
  await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn, log, onProgress })
871
- if (
872
- git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], worktreeCwd, spawnFn, {
873
- allowFailure: true
874
- }).status === 0
875
- ) {
876
- git(['cherry-pick', '--continue'], worktreeCwd, spawnFn)
877
- }
1012
+ finishCherryPick(worktreeCwd, spawnFn)
878
1013
  }
879
1014
  return
880
1015
  }
@@ -918,7 +1053,8 @@ async function finalizeBehavior(args) {
918
1053
  log,
919
1054
  label: `behavior ${source}`,
920
1055
  onAttempt: ({ tier }) => onProgress('behavior validation', tier),
921
- validate: () => validateBehaviorState(worktreeCwd, spawnFn, baseline)
1056
+ validate: () =>
1057
+ validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`))
922
1058
  })
923
1059
  if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
924
1060
  return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
@@ -931,7 +1067,7 @@ async function finalizeBehavior(args) {
931
1067
  * @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
932
1068
  */
933
1069
  async function createPullRequest(args) {
934
- const { candidate, group, runner, rootCwd, deps, spawnFn, log, onProgress = noop } = args
1070
+ const { candidate, group, runner, rootCwd, baselineCache, deps, spawnFn, log, onProgress = noop } = args
935
1071
  const source = candidate.source
936
1072
  const validCommitIds = new Set(candidate.commits?.map(commit => commit.oid))
937
1073
  const commits = source.startsWith(SOURCE_BRANCH_PREFIX)
@@ -943,10 +1079,17 @@ async function createPullRequest(args) {
943
1079
 
944
1080
  onProgress('worktree')
945
1081
  const worktree = createReconcileWorktree(group.title, source, rootCwd, spawnFn)
1082
+ let createdPr
946
1083
  log(`🌿 ${source} → ${worktree.branch}`)
947
1084
  try {
948
- onProgress('baseline tests')
949
- const baseline = captureBehaviorBaseline(worktree.cwd, spawnFn)
1085
+ const sourceMayChangeCode = (candidate.changedFiles ?? []).some(path => SOURCE_CODE_RE.test(path))
1086
+ let baseline = null
1087
+ if (sourceMayChangeCode) {
1088
+ onProgress('baseline tests')
1089
+ const captured = captureCachedBehaviorBaseline(worktree.cwd, baselineCache, spawnFn)
1090
+ baseline = captured.baseline
1091
+ if (captured.cached) onProgress('baseline tests (cached)')
1092
+ }
950
1093
  onProgress('apply')
951
1094
  await applySource({
952
1095
  source,
@@ -960,17 +1103,25 @@ async function createPullRequest(args) {
960
1103
  log,
961
1104
  onProgress
962
1105
  })
963
- const verification = await finalizeBehavior({
964
- runner,
965
- source,
966
- rationale: group.rationale ?? candidate.rationale ?? '',
967
- worktreeCwd: worktree.cwd,
968
- baseline,
969
- deps,
970
- spawnFn,
971
- log,
972
- onProgress
973
- })
1106
+ if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1107
+ onProgress('remove no-op worktree')
1108
+ removeReconcileWorktree(worktree, rootCwd, spawnFn)
1109
+ return { status: 'patch-equivalent', branch: worktree.branch }
1110
+ }
1111
+ const verification =
1112
+ changedSourceDirectories(worktree.cwd, spawnFn).length > 0
1113
+ ? await finalizeBehavior({
1114
+ runner,
1115
+ source,
1116
+ rationale: group.rationale ?? candidate.rationale ?? '',
1117
+ worktreeCwd: worktree.cwd,
1118
+ baseline,
1119
+ deps,
1120
+ spawnFn,
1121
+ log,
1122
+ onProgress
1123
+ })
1124
+ : 'Додатковий behavioral LLM не потрібен: code paths не змінено.'
974
1125
  onProgress('Git validation')
975
1126
  const unresolved = unresolvedFiles(worktree.cwd, spawnFn)
976
1127
  if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
@@ -981,15 +1132,14 @@ async function createPullRequest(args) {
981
1132
  allowFailure: true
982
1133
  }).status !== 0
983
1134
  if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
984
- const ahead = Number(git(['rev-list', '--count', `${BASE_REF}..HEAD`], worktree.cwd, spawnFn).stdout.trim())
985
- if (!ahead) throw new Error('Після reconciliation немає змін відносно origin/main')
986
-
987
- const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], worktree.cwd, spawnFn, {
988
- allowFailure: true
989
- })
990
- if (changelog.status !== 0) {
991
- throw new Error(`changelog gate: ${changelog.stderr || changelog.stdout}`)
1135
+ if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1136
+ onProgress('remove no-op worktree')
1137
+ removeReconcileWorktree(worktree, rootCwd, spawnFn)
1138
+ return { status: 'patch-equivalent', branch: worktree.branch }
992
1139
  }
1140
+ onProgress('delta lint')
1141
+ const finalGates = validateFinalProjectGates(worktree.cwd, spawnFn)
1142
+ if (!finalGates.ok) throw new Error(finalGates.error)
993
1143
  git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
994
1144
  onProgress('push')
995
1145
  git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
@@ -1002,28 +1152,28 @@ async function createPullRequest(args) {
1002
1152
  '',
1003
1153
  'Перевірки:',
1004
1154
  '- `git diff --check origin/main...HEAD`',
1155
+ '- scoped code lint/tests та domain lint для non-code paths',
1005
1156
  '- `npx @7n/rules lint changelog --no-fix`',
1006
1157
  verification ? `- LLM behavioral verification: ${verification.slice(0, 1000)}` : ''
1007
1158
  ]
1008
1159
  .filter(Boolean)
1009
1160
  .join('\n')
1010
- const pr = run(
1161
+ createdPr = run(
1011
1162
  'gh',
1012
1163
  ['pr', 'create', '--base', 'main', '--head', worktree.branch, '--title', group.title, '--body', body],
1013
1164
  worktree.cwd,
1014
1165
  spawnFn
1015
1166
  ).stdout.trim()
1016
1167
  onProgress('remove worktree')
1017
- run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
1018
- allowFailure: true
1019
- })
1020
- return { status: 'pr-created', url: pr, branch: worktree.branch }
1168
+ removeReconcileWorktree(worktree, rootCwd, spawnFn)
1169
+ return { status: 'pr-created', url: createdPr, branch: worktree.branch }
1021
1170
  } catch (error) {
1022
1171
  return {
1023
1172
  status: 'failed',
1024
1173
  error: error instanceof Error ? error.message : String(error),
1025
1174
  branch: worktree.branch,
1026
- worktree: worktree.cwd
1175
+ worktree: worktree.cwd,
1176
+ ...(createdPr && { url: createdPr })
1027
1177
  }
1028
1178
  }
1029
1179
  }
@@ -1044,19 +1194,21 @@ export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
1044
1194
  .filter(Boolean)
1045
1195
  .map(line => line.split('\0'))
1046
1196
  const row = rows.find(([, oid]) => oid === candidate.oid)
1047
- if (!row) return { status: 'already-removed' }
1197
+ if (!row) return { status: 'already-removed', removedRefs: [] }
1048
1198
  git(['stash', 'drop', row[0]], rootCwd, spawnFn)
1049
- return { status: 'removed' }
1199
+ return { status: 'removed', removedRefs: [row[0]] }
1050
1200
  }
1051
1201
 
1202
+ const removedRefs = []
1052
1203
  for (const ref of candidate.aliases ?? [candidate.ref]) {
1053
1204
  if (ref.startsWith('refs/remotes/origin/')) {
1054
1205
  git(['push', 'origin', '--delete', branchName(ref)], rootCwd, spawnFn)
1055
1206
  } else if (ref.startsWith('refs/heads/')) {
1056
1207
  git(['branch', '-D', branchName(ref)], rootCwd, spawnFn)
1057
1208
  }
1209
+ removedRefs.push(ref)
1058
1210
  }
1059
- return { status: 'removed' }
1211
+ return { status: 'removed', removedRefs }
1060
1212
  } catch (error) {
1061
1213
  return {
1062
1214
  status: 'cleanup-failed',
@@ -1065,6 +1217,17 @@ export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
1065
1217
  }
1066
1218
  }
1067
1219
 
1220
+ /**
1221
+ * Форматує aliases, які cleanup фактично видалив.
1222
+ * @param {object|undefined} cleanup cleanup result
1223
+ * @returns {string} report suffix
1224
+ */
1225
+ function formatRemovedRefs(cleanup) {
1226
+ if (!cleanup?.removedRefs?.length) return ''
1227
+ const refs = cleanup.removedRefs.map(ref => `\`${ref}\``).join(',')
1228
+ return `; refs=${refs}`
1229
+ }
1230
+
1068
1231
  /**
1069
1232
  * Формує deterministic report.
1070
1233
  * @param {{inventory:object,results:Array<object>}} args дані
@@ -1078,16 +1241,21 @@ export function formatReport({ inventory, results }) {
1078
1241
  if (branch.pr?.url) suffix = ` — ${branch.pr.url}`
1079
1242
  else if (branch.worktree) suffix = ` — ${branch.worktree}`
1080
1243
  const cleanupOid = branch.oid ? `; oid=${branch.oid}` : ''
1081
- const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}${cleanupOid}` : ''
1082
- lines.push(`- \`${branch.name}\`: ${branch.state}${cleanup}${suffix}`)
1244
+ const removedRefs = formatRemovedRefs(branch.cleanup)
1245
+ const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}${cleanupOid}${removedRefs}` : ''
1246
+ lines.push(`- \`${branch.source ?? branch.name}\`: ${branch.state}${cleanup}${suffix}`)
1083
1247
  }
1084
1248
  for (const result of results) {
1085
- let suffix = ''
1086
- if (result.url) suffix = ` — ${result.url}`
1087
- else if (result.error) suffix = ` — ${result.error}`
1088
- else if (result.rationale) suffix = ` — ${result.rationale}`
1249
+ const details = [
1250
+ result.url,
1251
+ result.error,
1252
+ result.rationale,
1253
+ result.worktree && `worktree=${result.worktree}`
1254
+ ].filter(Boolean)
1255
+ const suffix = details.length > 0 ? ` — ${details.join('; ')}` : ''
1089
1256
  const cleanupOid = result.oid ? `; oid=${result.oid}` : ''
1090
- const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}${cleanupOid}` : ''
1257
+ const removedRefs = formatRemovedRefs(result.cleanup)
1258
+ const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}${cleanupOid}${removedRefs}` : ''
1091
1259
  lines.push(`- \`${result.source}\`: ${result.status}${cleanup}${suffix}`)
1092
1260
  }
1093
1261
  for (const warning of inventory.warnings) lines.push(`- ⚠️ ${warning}`)
@@ -1150,7 +1318,7 @@ async function triageCandidates(args) {
1150
1318
  * @returns {Promise<Array<object>>} results одного source
1151
1319
  */
1152
1320
  async function materializeDecision(args) {
1153
- const { decision, candidate, runner, rootCwd, deps, spawnFn, log, createPr, progress, prState } = args
1321
+ const { decision, candidate, runner, rootCwd, baselineCache, deps, spawnFn, log, createPr, progress, prState } = args
1154
1322
  if (decision.action !== 'pr') {
1155
1323
  return [
1156
1324
  {
@@ -1175,6 +1343,7 @@ async function materializeDecision(args) {
1175
1343
  group,
1176
1344
  runner,
1177
1345
  rootCwd,
1346
+ baselineCache,
1178
1347
  deps,
1179
1348
  spawnFn,
1180
1349
  log,
@@ -1239,8 +1408,10 @@ function cleanupMaterializedSources(args) {
1239
1408
  for (const [source, candidate] of bySource) {
1240
1409
  const sourceResults = results.filter(result => result.source === source)
1241
1410
  const dropped = sourceResults.some(result => result.status === 'drop-recommended')
1242
- const allPrCreated = sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created')
1243
- if (!dropped && !allPrCreated) continue
1411
+ const allTransferred =
1412
+ sourceResults.length > 0 &&
1413
+ sourceResults.every(result => ['pr-created', 'patch-equivalent'].includes(result.status))
1414
+ if (!dropped && !allTransferred) continue
1244
1415
  cleanupState.index += 1
1245
1416
  const key = `cleanup-${cleanupState.index}`
1246
1417
  progress.step(key, source)
@@ -1269,8 +1440,10 @@ function countCleanupSources(inventory, bySource, results) {
1269
1440
  .filter(source => {
1270
1441
  const sourceResults = results.filter(result => result.source === source)
1271
1442
  const dropped = sourceResults.some(result => result.status === 'drop-recommended')
1272
- const allPrCreated = sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created')
1273
- return dropped || allPrCreated
1443
+ const allTransferred =
1444
+ sourceResults.length > 0 &&
1445
+ sourceResults.every(result => ['pr-created', 'patch-equivalent'].includes(result.status))
1446
+ return dropped || allTransferred
1274
1447
  })
1275
1448
  .toArray().length
1276
1449
  return inactive + reviewed
@@ -1294,6 +1467,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
1294
1467
  const reporterFactory = deps.createProgressReporter ?? createProgressReporter
1295
1468
  const isTTY = options.isTTY ?? process.stdout.isTTY === true
1296
1469
  const now = deps.now ?? (() => performance.now())
1470
+ const baselineCache = new Map()
1297
1471
 
1298
1472
  const inventoryStartedAt = now()
1299
1473
  log('⏳ 1/4 inventory')
@@ -1349,6 +1523,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
1349
1523
  candidates,
1350
1524
  runner,
1351
1525
  rootCwd,
1526
+ baselineCache,
1352
1527
  deps,
1353
1528
  spawnFn,
1354
1529
  log,