@7n/rules 1.49.10 → 1.49.12

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.49.12] - 2026-07-26
4
+
5
+ ### Fixed
6
+
7
+ - Git-reconcile отримав ANSI-free progress, heartbeat, canonical fixer pass і bounded parallel PR jobs.
8
+
9
+ ## [1.49.11] - 2026-07-26
10
+
11
+ ### Fixed
12
+
13
+ - Додано окремий 180-секундний cloud budget для survived-mutant coverage batch і deferred решти batch-ів
14
+
3
15
  ## [1.49.10] - 2026-07-26
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.49.10",
3
+ "version": "1.49.12",
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.6",
68
+ "@7n/llm-lib": "2.9.7",
69
69
  "@7n/mt": "^0.5.1",
70
70
  "@zed-industries/agent-client-protocol": "^0.4.5",
71
71
  "ajv": "^8.20.0",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/changelog/consistency/main.mjs
5
5
  docgen:
6
- crc: 40857795
6
+ crc: 3d8d2a45
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  tier: cloud-min
9
9
  score: 95
@@ -22,7 +22,7 @@ docgen:
22
22
  3. Для кожного workspace оцінює, чи є релевантні зміни відносно цієї бази, і відсікає шум від ігнорованих changelog-шляхів та ignore-правил. Це потрібно, щоб перевірка реагувала лише на зміни, які справді потребують bump або change-файлу.
23
23
  4. Для published workspace перевіряє, що changelog присутній і має базовий формат, а також що опублікована версія не відстає від локальної. Якщо версія в дереві випереджає опубліковану — це вважається ручним bump поза CI, а не штатним станом.
24
24
  5. Якщо published workspace має релевантні Git-зміни без change-файлу, або фіксує це як помилку, або в autofix-режимі створює change-файл із автоматичним описом і ставить його в індекс, щоб коміт не падав. Для published-пакетів додатково вимагається, щоб `files` у маніфесті містив `CHANGELOG.md`, бо релізний changelog має публікуватися разом із пакетом.
25
- 6. Для workspace, які змінюються лише локально, звіряє версію з базою порівняння, перевіряє наявність незрелізних change-файлів і, якщо треба, також пропонує або створює change-файл. Повідомлення формуються в стилі, сумісному з правилами ``.
25
+ 6. Для workspace, які змінюються лише локально, звіряє версію з базою порівняння, перевіряє наявність невипущених change-файлів і, якщо треба, також пропонує або створює change-файл. Повідомлення формуються в стилі, сумісному з правилами ``.
26
26
  7. Резолвить опубліковану версію залежно від типу пакета: для Python орієнтується на PyPI, для інших — на npm. Пошук публікації йде через `https://pypi.org/pypi/`, а також через конфігурацію `res.json`, якщо вона визначає очікувану поведінку workspace.
27
27
  8. Працює fail-safe: мережеві чи git-помилки не пробиваються назовні як винятки, а перетворюються на м’який результат або перевірочний фейл, щоб один збій не ламав увесь lint-прогін.
28
28
 
@@ -602,7 +602,10 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, sub
602
602
  return
603
603
  }
604
604
 
605
- if (policy.releaseBranches.includes(branch ?? '') && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))) {
605
+ if (
606
+ policy.releaseBranches.includes(branch ?? '') &&
607
+ (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))
608
+ ) {
606
609
  await fixOrFailPublishedWorkspace(manifest, label, mf, autofix, pass, fail, cwd)
607
610
  }
608
611
  }
@@ -2,6 +2,7 @@
2
2
  "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
3
  "fixability": "code",
4
4
  "skipLocalTier": true,
5
+ "cloudTimeoutMs": 180000,
5
6
  "lint": {
6
7
  "scope": "per-file",
7
8
  "glob": ["**/*.{js,mjs,cjs,jsx,ts,tsx,vue}"]
@@ -18,8 +18,9 @@
18
18
  * (assert порту вимагає лише detect/collect/collectPerFile). Хуки отримують
19
19
  * FixContext-поля (model/tier/timeoutMs/recordWrite/chain/signal/feedback);
20
20
  * recordWrite прокидається до кожного місця запису (rollback-контракт ladder-а).
21
- * Дедлайн: DEADLINE_FRACTION від ctx.timeoutMs гейтить СТАРТ наступного хука
22
- * (як js/eslint fix-worker); залишок бюджету передається хуку як timeoutMs.
21
+ * Coverage-specific cloud rung дає survived-mutant agent batch повний budget:
22
+ * outer runner backstop лишається ×1.25. Один survived batch стартує за rung;
23
+ * решта є deferred telemetry, а не timeout/no-op failure.
23
24
  * Власних retry-циклів немає — success визначає canonical re-detect runner-а.
24
25
  * @typedef {import('../../../scripts/lib/lint-surface/types.mjs').FixWorkerFn} FixWorkerFn
25
26
  * @typedef {import('../../../scripts/lib/lint-surface/types.mjs').FixContext} FixContext
@@ -27,8 +28,8 @@
27
28
  */
28
29
  import { resolveProviders } from './main.mjs'
29
30
 
30
- /** Частка ctx.timeoutMs, після якої не стартує наступний хук (запас до backstop ×1.25). */
31
- const DEADLINE_FRACTION = 0.8
31
+ /** Один survived-mutant agent batch за coverage rung. */
32
+ const SURVIVED_BATCHES_PER_RUNG = 1
32
33
 
33
34
  /** `.vue`-файли → generateStories, решта → generateTests. */
34
35
  const VUE_FILE_RE = /\.vue$/
@@ -59,7 +60,7 @@ export function groupViolations(violations) {
59
60
  */
60
61
  function hookCtx(ctx, deadlineAt) {
61
62
  const remaining = deadlineAt ? Math.max(1000, deadlineAt - Date.now()) : ctx.timeoutMs
62
- const workerDeadlineMs = ctx.timeoutMs ? Math.round(ctx.timeoutMs * DEADLINE_FRACTION) : null
63
+ const workerDeadlineMs = ctx.timeoutMs ?? null
63
64
  return {
64
65
  ...ctx,
65
66
  timeoutMs: remaining,
@@ -68,7 +69,8 @@ function hookCtx(ctx, deadlineAt) {
68
69
  coverageTimeout: {
69
70
  requestedMs: ctx.timeoutMs ?? null,
70
71
  workerDeadlineMs,
71
- effectiveHookTimeoutMs: remaining ?? null
72
+ effectiveHookTimeoutMs: remaining ?? null,
73
+ survivedBatchesPerRung: SURVIVED_BATCHES_PER_RUNG
72
74
  }
73
75
  }
74
76
  }
@@ -76,7 +78,7 @@ function hookCtx(ctx, deadlineAt) {
76
78
  /** @type {FixWorkerFn} */
77
79
  export async function fixWorker(violations, ctx, deps = {}) {
78
80
  // Дедлайн фіксується ДО резолву провайдерів — їх завантаження теж у бюджеті рунга.
79
- const deadlineAt = ctx.timeoutMs ? Date.now() + Math.round(ctx.timeoutMs * DEADLINE_FRACTION) : null
81
+ const deadlineAt = ctx.timeoutMs ? Date.now() + ctx.timeoutMs : null
80
82
  const expired = () => deadlineAt !== null && Date.now() >= deadlineAt
81
83
 
82
84
  const providers = await (deps.resolveProviders ?? resolveProviders)(ctx.cwd)
@@ -88,6 +90,7 @@ export async function fixWorker(violations, ctx, deps = {}) {
88
90
  const touchedFiles = []
89
91
  /** @type {Array<{provider: string, hook: string, files: string[], error: string}>} */
90
92
  const failed = []
93
+ const deferred = []
91
94
  /**
92
95
  * Викликає опційний fix-hook провайдера, збирає touchedFiles; виняток хука не
93
96
  * валить решту хуків/провайдерів — success визначає canonical re-detect.
@@ -101,6 +104,7 @@ export async function fixWorker(violations, ctx, deps = {}) {
101
104
  try {
102
105
  const res = await provider[hook]({ ...args, cwd: ctx.cwd, ctx: hookCtx(ctx, deadlineAt) })
103
106
  touchedFiles.push(...(res?.touchedFiles ?? []))
107
+ deferred.push(...(res?.deferred ?? []))
104
108
  for (const failure of res?.failed ?? []) {
105
109
  const error = failure?.error ?? 'невідома причина'
106
110
  const files = failure?.files ?? []
@@ -115,9 +119,14 @@ export async function fixWorker(violations, ctx, deps = {}) {
115
119
  }
116
120
 
117
121
  for (const provider of providers) {
122
+ // Survived mutants мають власний cloud budget: не витрачаємо його на інші
123
+ // LLM hooks перед єдиним агентним batch-ем цього rung-а.
124
+ if (survived.length > 0) {
125
+ await runHook(provider, 'fixSurvived', { survived })
126
+ continue
127
+ }
118
128
  if (jsFiles.length > 0) await runHook(provider, 'generateTests', { files: jsFiles })
119
129
  if (vueFiles.length > 0) await runHook(provider, 'generateStories', { files: vueFiles })
120
- if (survived.length > 0) await runHook(provider, 'fixSurvived', { survived })
121
130
  // Після генерації: тести, що впали (зокрема щойно згенеровані), чиняться
122
131
  // окремим хуком — свіжий vitest-прогін усередині провайдера. Без жодної
123
132
  // роботи вище (порожній профіль violations) хук не стартує.
@@ -126,5 +135,5 @@ export async function fixWorker(violations, ctx, deps = {}) {
126
135
  }
127
136
  }
128
137
 
129
- return { touchedFiles, failed }
138
+ return { touchedFiles, failed, deferred }
130
139
  }
@@ -23,6 +23,11 @@
23
23
  "type": "boolean",
24
24
  "description": "Дефолт `false` (поле можна опускати). `true` пропускає local-min/local-min-retry rung-и LLM-ladder-а для цього concern-а — перша спроба одразу йде на cloud-min. Для concern-ів, де local-tier (слабка локальна модель, короткий бюджет) емпірично майже завжди лише витрачає час rung-а без результату, перш ніж ladder однаково ескалює далі."
25
25
  },
26
+ "cloudTimeoutMs": {
27
+ "type": "integer",
28
+ "minimum": 1,
29
+ "description": "Опційний budget одного cloud rung-а лише для цього concern-а. Замінює timeout cloud-min/cloud-avg у ladder, не змінюючи глобальний `N_CLOUD_FIX_TIMEOUT_MS` або інші concern-и."
30
+ },
26
31
  "check": {
27
32
  "type": "boolean",
28
33
  "const": true,
@@ -67,7 +67,10 @@ export function resolveChangedBase(cwd = process.cwd(), baseRef = null) {
67
67
  }
68
68
  if (baseRef) return mergeBaseWith(baseRef) || null
69
69
  const { integrationBranches } = readGitPolicy(cwd)
70
- const bases = integrationBranches.flatMap(name => [`origin/${name}`, name]).map(mergeBaseWith).filter(Boolean)
70
+ const bases = integrationBranches
71
+ .flatMap(name => [`origin/${name}`, name])
72
+ .map(ref => mergeBaseWith(ref))
73
+ .filter(Boolean)
71
74
  if (bases.length === 0) return null
72
75
  let newest = bases[0]
73
76
  for (const candidate of bases.slice(1)) {
@@ -38,6 +38,8 @@ import { join } from 'node:path'
38
38
  * ladder-а — ladder одразу стартує з cloud-min. Для concern-ів, де local-tier емпірично не встигає
39
39
  * дати результат у межах свого бюджету (напр. js/eslint — 0/12 успіхів у реальному прогоні, лише
40
40
  * витрачений час).
41
+ * @property {number|undefined} cloudTimeoutMs опційний budget cloud rung-а concern-а;
42
+ * не змінює глобальні timeout-и ladder-а чи інші concern-и.
41
43
  */
42
44
 
43
45
  /**
@@ -131,7 +133,8 @@ export async function readConcernMeta(concernDir, name) {
131
133
  lint,
132
134
  requiresCapability,
133
135
  fixability: parseFixability(raw.fixability),
134
- skipLocalTier: raw.skipLocalTier === true
136
+ skipLocalTier: raw.skipLocalTier === true,
137
+ cloudTimeoutMs: Number.isInteger(raw.cloudTimeoutMs) && raw.cloudTimeoutMs > 0 ? raw.cloudTimeoutMs : undefined
135
138
  }
136
139
  }
137
140
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: changed-files.mjs
4
4
  resource: npm/scripts/lib/changed-files.mjs
5
5
  docgen:
6
- crc: 0482c036
6
+ crc: 2bf61f5b
7
7
  model: openai-codex/gpt-5.5
8
8
  tier: cloud-avg
9
9
  score: 100
@@ -25,7 +25,7 @@ docgen:
25
25
 
26
26
  `collectChangedFilesSince` споживає базу з `resolveChangedBase` або її відсутність. За наявної бази вона збирає всі файли, змінені від цієї бази до поточного робочого дерева, включно із закоміченими після бази, staged і незакоміченими змінами, та додає untracked файли. За відсутньої бази делегує збір до `collectChangedFiles`.
27
27
 
28
- Усі результати є списками унікальних шляхів для наступних lint-етапів. Для обох режимів діє спільне правило: видалені файли не потрапляють у scope, а файли всередині службових worktree-чекаутів відкидаються, щоб сесійні копії репозиторію не лінтилися як робочий код. Поза git-репозиторієм або при помилці git звичайний quick-збір повертає порожній список, але scoped-збір із недосяжною базою завершується явною помилкою, щоб перевірка не пройшла з порожнім scope помилково.
28
+ Усі результати є списками унікальних шляхів для наступних lint-етапів. Для обох режимів діє спільне правило: видалені файли не потрапляють у scope, а файли всередині службових worktree-чекаутів відкидаються, щоб сесійні копії репозиторію не перевірялися як робочий код. Поза git-репозиторієм або при помилці git звичайний quick-збір повертає порожній список, але scoped-збір із недосяжною базою завершується явною помилкою, щоб перевірка не пройшла з порожнім scope помилково.
29
29
 
30
30
  ## Публічний API
31
31
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: git-policy.mjs
4
4
  resource: npm/scripts/lib/git-policy.mjs
5
5
  docgen:
6
- crc: 5809ac19
6
+ crc: 057bf20c
7
7
  model: openai-codex/gpt-5.5
8
8
  tier: cloud-avg
9
9
  score: 100
@@ -22,7 +22,7 @@ function branchName(value) {
22
22
  * Повертає effective policy. `releaseBranches` одночасно є integration-кандидатами
23
23
  * delta та захищеними довгоживучими гілками; `baseBranch` додається до обох наборів.
24
24
  * @param {string} [cwd] корінь репозиторію
25
- * @returns {{baseBranch:string,releaseBranches:string[],integrationBranches:string[],protectedBranches:string[]}}
25
+ * @returns {{baseBranch:string,releaseBranches:string[],integrationBranches:string[],protectedBranches:string[]}} effective Git policy
26
26
  */
27
27
  export function readGitPolicy(cwd = process.cwd()) {
28
28
  let raw = null
@@ -38,7 +38,9 @@ export function readGitPolicy(cwd = process.cwd()) {
38
38
  }
39
39
  const git = raw && typeof raw.git === 'object' && !Array.isArray(raw.git) ? raw.git : {}
40
40
  const baseBranch = branchName(git.baseBranch) ?? DEFAULT_BASE_BRANCH
41
- const configuredRelease = Array.isArray(git.releaseBranches) ? git.releaseBranches.map(branchName).filter(Boolean) : []
41
+ const configuredRelease = Array.isArray(git.releaseBranches)
42
+ ? git.releaseBranches.map(value => branchName(value)).filter(Boolean)
43
+ : []
42
44
  const releaseBranches = configuredRelease.length > 0 ? [...new Set(configuredRelease)] : [DEFAULT_BASE_BRANCH]
43
45
  const integrationBranches = [...new Set([baseBranch, ...releaseBranches])]
44
46
  return { baseBranch, releaseBranches, integrationBranches, protectedBranches: integrationBranches }
@@ -610,7 +610,9 @@ function noteT0Phase(t0, chainExtra, touchedAbs) {
610
610
  }
611
611
 
612
612
  /**
613
- * Ladder concern-а: повний, або без local-tier rung-ів (`concern.skipLocalTier`)
613
+ * Ladder concern-а: повний, або без local-tier rung-ів (`concern.skipLocalTier`).
614
+ * Concern може замінити cloud budget своїм `cloudTimeoutMs`; глобальний ladder та
615
+ * всі інші concern-и лишаються без змін.
614
616
  * concern-и, де local-min/local-min-retry емпірично не встигають дати результат
615
617
  * у межах свого бюджету (concern-meta.mjs).
616
618
  * @param {Rung[]} ladder Повний ladder pipeline-у.
@@ -618,7 +620,10 @@ function noteT0Phase(t0, chainExtra, touchedAbs) {
618
620
  * @returns {Rung[]} Ladder, застосовний до цього concern-а.
619
621
  */
620
622
  function selectLadder(ladder, concern) {
621
- return concern.skipLocalTier ? ladder.filter(rung => !rung.local) : ladder
623
+ const selected = concern.skipLocalTier ? ladder.filter(rung => !rung.local) : ladder
624
+ return concern.cloudTimeoutMs
625
+ ? selected.map(rung => (rung.local ? rung : { ...rung, timeoutMs: concern.cloudTimeoutMs }))
626
+ : selected
622
627
  }
623
628
 
624
629
  /**
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: n-git-reconcile
3
3
  description: >-
4
- JS-оркестрований аналіз git-гілок, worktree та stash відносно актуального
5
- origin/main: детерміновано відсіює merged і patch-equivalent refs, передає
4
+ JS-оркестрований аналіз git-гілок, worktree та stash відносно актуальної
5
+ policy base branch: детерміновано відсіює merged і patch-equivalent refs, передає
6
6
  LLM лише semantic triage та conflict resolution, а корисні зміни переносить
7
7
  у перевірені PR. Використовуй, коли просять розібрати, консолідувати,
8
8
  підготувати PR або безпечно почистити старі Git refs і stash.
@@ -22,7 +22,7 @@ npx @7n/rules skill pi git-reconcile
22
22
  ## Розподіл відповідальності
23
23
 
24
24
  JS виконує `fetch`, inventory, patch-equivalence, дедуплікацію refs, збір
25
- worktree/PR/stash, підготовку worktree від `origin/main`, cherry-pick або
25
+ worktree/PR/stash, підготовку worktree від `origin/<baseBranch>`, cherry-pick або
26
26
  застосування stash, gates, commit, push, PR і фінальний звіт. Semantic no-op
27
27
  після conflict resolution детерміновано пропускає через `cherry-pick --skip`;
28
28
  порожній tree diff не push-иться і не створює PR.
@@ -39,30 +39,38 @@ doc generation, lint і changelog gates запускає JS після cognitive
39
39
  Doc-files і unified lint отримують лише унікальні директорії зміненого коду,
40
40
  щоб repository-wide baseline та stale docs поза scope не забруднювали PR.
41
41
 
42
- Оркестратор показує чесний фазовий progress: inventory має elapsed time без
43
- вигаданого total, а `triage`, `PR` і `cleanup` — окремі точні bars за вже
44
- відомими batches/groups/sources. Поточний LLM-етап показує tier `min` або
45
- `max`. У non-TTY/CI замість перемальовування виводяться append-only рядки.
42
+ Оркестратор показує чесний ANSI-free фазовий progress: inventory має elapsed
43
+ time без вигаданого total, а `triage`, `PR` і `cleanup` — окремі точні
44
+ append-only bar snapshots за вже відомими batches/groups/sources. Поточний
45
+ LLM-етап показує tier `min` або `max`; довгі етапи кожні 30 секунд отримують
46
+ heartbeat з elapsed time. Формат однаковий у TTY/CI, тому captured output не
47
+ містить cursor-control spam.
48
+
49
+ Незалежні PR-групи виконуються з bounded concurrency `3`; override
50
+ `N_GIT_RECONCILE_CONCURRENCY=1..4`. Порядок фінального звіту лишається
51
+ детермінованим, а cleanup починається лише після завершення всіх PR jobs.
46
52
 
47
53
  Кожен LLM-крок починається на tier `min`. JS детерміновано перевіряє:
48
54
 
49
55
  - triage — повноту verdicts, schema, groups і commit OID;
50
56
  - worktree — відсутність conflict markers та `git diff --check`;
51
- - поведінку — repository test script відносно test baseline чистого
52
- `origin/main` і changelog gate. Red baseline приймається лише для
57
+ - поведінку — repository test script відносно test baseline чистої
58
+ `origin/<baseBranch>` і changelog gate. Red baseline приймається лише для
53
59
  розпізнаних Vitest failures, якщо після перенесення не додалось нових.
54
60
  - змінений код — scoped `doc-files` у fix-режимі та unified lint у
55
61
  `--no-fix`, окремо для кожної code directory.
56
62
 
57
- Лише валідна відповідь runner, яка провалила validation, запускає повтор того
58
- самого bounded-завдання на `max` із точною причиною провалу. Infrastructure
63
+ Після min validation failure JS спершу запускає canonical scoped/changelog
64
+ fixers. Якщо вони детерміновано усунули format, CSpell, docs або changeset
65
+ дефект, min приймається без `max`. Лише residual behavioral failure запускає
66
+ повтор того самого bounded-завдання на `max` із точною причиною. Infrastructure
59
67
  failure runner завершує крок одразу: повтор іншою моделлю не маскує проблему
60
68
  transport. Після провалу `max` джерело fail-closed лишається `kept` або
61
69
  `failed`, не потрапляє в cleanup, а неповний triage завершує команду non-zero.
62
70
 
63
71
  ## Інваріанти
64
72
 
65
- - База — тільки свіжий `origin/main`.
73
+ - База — тільки свіжий `origin/<baseBranch>` із repository Git policy.
66
74
  - Живі worktree, включно з detached HEAD за commit OID, та гілки відкритих
67
75
  PR — protected.
68
76
  - Стара дата або великий divergence не означають, що зміна непотрібна.
@@ -72,13 +80,18 @@ transport. Після провалу `max` джерело fail-closed лишає
72
80
  відсутніх conflicts і порожнього staged diff.
73
81
  - Вкладені `npx` не успадковують package selector зовнішнього
74
82
  `npm exec --package`.
83
+ - Перед створенням worktree JS додає `.worktrees/` до локального
84
+ `.git/info/exclude`, не змінюючи tracked `.gitignore` consumer-а.
85
+ - ACP semantic idle watchdog не подовжується від `usage`, thought,
86
+ config або повторних tool-update events; його скидають лише новий tool-call
87
+ чи agent output.
75
88
  - За невизначеності джерело лишається `kept`; misleading ready PR не
76
89
  створюється.
77
90
  - Перед push обов'язково проходять фінальний tree-diff guard, domain lint для
78
91
  non-code paths, changelog і `git diff --check`; code changes додатково
79
92
  проходять scoped docs/lint та tests.
80
93
  - Behavioral LLM не викликається для змін без code paths; test baseline
81
- актуального `origin/main` кешується між PR-групами.
94
+ актуальної policy base branch кешується між PR-групами.
82
95
  - Cleanup виконує лише JS і тільки після inventory/PR-фази: видаляє точні refs,
83
96
  уже merged/patch-equivalent, явно класифіковані як `drop` або повністю
84
97
  перенесені в успішний PR.
@@ -3,64 +3,86 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/git-reconcile/js/orchestrate.mjs
5
5
  docgen:
6
- crc: f0c9ea76
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 70
6
+ crc: c3233b69
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
9
+ score: 100
9
10
  ---
10
11
 
11
12
  ## Огляд
12
13
 
13
- Модуль узгоджує Git branches, worktree і stash зі свіжим `origin/main`.
14
- JS виконує inventory, validation, materialization, gates, PR і cleanup, а LLM
15
- отримує лише bounded semantic triage, conflict resolution та behavioral edits.
14
+ Файл координує Git reconcile-орchestrator: інвентаризує repository refs і worktrees, прибирає дублікати refs, визначає conflict files, формує triage prompt, перевіряє decision envelope та застосовує рішення через runner. Він існує, щоб переносити cleanup у перевірюваний процес із behavior baseline, cached baseline у межах прогону, validation gates, контрольованим concurrency і детермінованим Markdown-звітом.
16
15
 
17
16
  ## Поведінка
18
17
 
19
- - Збирає refs, aliases, worktree, open PR, patch-equivalent commits і stash.
20
- Detached worktree захищає за checkout HEAD OID.
21
- - Ізолює вкладені `npx` від `npm_config_package` зовнішнього `npm exec`.
22
- - Приглушує дубльовані ACP events, якщо користувач не ввімкнув explicit verbose.
23
- - Показує inventory elapsed time та окремі progress bars для triage, PR і
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.
18
+ `runGitReconcileOrchestrator` керує всім потоком: збирає Git-стан через `inventoryRepository`, веде фазовий progress через `createPhaseProgress`, запускає bounded triage, матеріалізує рішення у PR-пайплайн, виконує cleanup і повертає звіт через `formatReport`. Паралельність PR-фази обмежується `normalizePrConcurrency`, а незалежні задачі виконуються через `runWithConcurrency` зі стабільним порядком результатів.
19
+
20
+ `inventoryRepository` отримує факти з локального checkout і remote refs, оновлює remote refs через fetch --prune, але не змінює робочі файли. `parseWorktrees` і `dedupeRefs` зводять worktree-захист, branch refs, aliases і commit identity в один детермінований inventory, щоб protected або відкриті джерела не потрапили в небезпечний cleanup. `conflictFiles` додає до inventory ранню оцінку потенційних merge-conflicts.
21
+
22
+ Для review-кандидатів `buildTriagePrompt` формує обмежене завдання для LLM: модель отримує вже пораховані Git-факти й має повернути лише JSON-рішення. `callRunner` викликає обраний runner, `parseDecisionEnvelope` дістає структуровану відповідь, а `validateTriageOutcome` приймає тільки повний і узгоджений набір рішень для поточного batch. `callWithValidatedFallback` спершу пробує дешевший рівень, а дорожчий використовує лише після конкретного validation failure.
23
+
24
+ Під час матеріалізації рішення source переноситься у керований worktree. `branchSlug` забезпечує передбачувані rescue-гілки, а `ensureLocalWorktreeExclude` не дає службовим worktree забруднювати root `git status`. Якщо cherry-pick стає порожнім, `skipEmptyCherryPick` дозволяє skip лише для доведеного semantic no-op, а `finishCherryPick` завершує активний перенос без прийняття неперевіреного Git-стану. `hasChangesFromBase` відсікає PR без реального tree diff.
25
+
26
+ Behavior-gates будуються навколо baseline: `captureBehaviorBaseline` фіксує стан чистої `origin/<baseBranch>` із repository Git policy, а `captureCachedBehaviorBaseline` перевикористовує цей результат у межах одного прогону для однакової бази. `validateBehaviorState` перевіряє Git-консистентність, scoped lint/docs і test outcome; правила запуску тестів і скриптів беруться з `package.json`. `testFailureSignatures` нормалізує failures, а `acceptsTestOutcome` дозволяє red baseline тільки без нових failures. Якщо валідація падає через типовий formatting, CSpell, docs або changelog-дефект, `remediateBehaviorState` запускає canonical fixers перед ескалацією LLM.
27
+
28
+ Для scoped gates `sourceDirectories` звужує code-зміни до мінімальних директорій, а `changedNonCodeDirectories` окремо готує non-code області для фінальної перевірки. `validateFinalProjectGates` добирає domain lint для workflows, dependency manifests, rules та інших non-code змін після того, як code-директорії вже пройшли свої перевірки.
29
+
30
+ Після успішного перенесення або доведеної неактуальності `cleanupSource` видаляє тільки точний source, який не є protected і не має відкритого PR. Усі accepted, kept, dropped, failed і cleaned результати зводяться в детермінований Markdown через `formatReport`, щоб наступний крок бачив і створені PR, і причини fail-closed рішень.
39
31
 
40
32
  ## Публічний API
41
33
 
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.
34
+ - createPhaseProgressСтворює ANSI-free snapshot progress для однієї фази. Однаковий append-only
35
+ формат у TTY/CI не засмічує captured output cursor-control кодами, а
36
+ heartbeat показує elapsed time довгих LLM-етапів.
37
+ - parseWorktreesПарсить `git worktree list --porcelain` у branch→path.
38
+ - dedupeRefsДедуплікує local/remote refs одного commit: remote має пріоритет, але
39
+ worktree-protection локального ref переноситься у запис.
40
+ - conflictFilesВитягає конфліктні файли з `git merge-tree`.
41
+ - inventoryRepositoryЗбирає детермінований Git inventory. Нічого не видаляє і не змінює у
42
+ checkout, крім оновлення remote refs через fetch --prune.
43
+ - buildTriagePromptФормує bounded semantic-triage prompt. Git-факти вже пораховані JS; модель
44
+ не виконує shell-команди й повертає лише JSON-рішення.
45
+ - parseDecisionEnvelopeВитягає JSON object із чистої або fenced відповіді.
46
+ - callRunnerВикликає вибраний LLM runner для одного bounded-завдання.
47
+ - callWithValidatedFallback — Виконує bounded LLM-крок через min, валідовує результат JS-функцією і
48
+ викликає max лише після конкретного провалу.
49
+ - validateTriageOutcome — Структурно перевіряє triage-рішення: рівно один verdict на candidate,
50
+ валідні actions/groups і лише відомі commit OID.
51
+ - branchSlug — Перетворює довільний title/ref на branch slug.
52
+ - ensureLocalWorktreeExclude — Додає `.worktrees/` до локального Git exclude без tracked-змін у consumer.
53
+ Це не замінює repository Vitest excludes, але не лишає root checkout dirty
54
+ через керовані або forensic worktree.
55
+ - skipEmptyCherryPick — Пропускає лише підтверджений empty cherry-pick: sequencer активний,
56
+ конфліктів немає, staged diff порожній.
57
+ - finishCherryPick — Завершує активний cherry-pick: semantic no-op пропускає, непорожній
58
+ продовжує. Відсутній sequencer не потребує дії.
59
+ - hasChangesFromBase — Перевіряє реальний tree diff, а не лише кількість commits ahead.
60
+ - testFailureSignatures — Витягає стабільні Vitest failure identifiers без summary/timing.
61
+ - acceptsTestOutcome — Дозволяє red baseline лише якщо після перенесення не з'явилось нових
62
+ Vitest failures. Нерозпізнаний red output завжди fail-closed.
63
+ - sourceDirectories — Зводить змінені code paths до найвужчих директорій для scoped gates.
64
+ - changedNonCodeDirectories — Повертає директорії non-code змін для фінального domain lint.
65
+ - remediateBehaviorState — Запускає canonical fixers у worktree до ескалації min→max. Це прибирає
66
+ formatting/CSpell/doc/changelog дефекти без повторного behavioral LLM.
67
+ - captureBehaviorBaseline — Фіксує test baseline на чистій policy base branch до перенесення source.
68
+ - captureCachedBehaviorBaseline — Повторно використовує test baseline однієї policy base branch між PR-групами.
69
+ Залежності все одно встановлюються в кожному окремому worktree.
70
+ - validateBehaviorState — Додає до Git-state validation test script із репозиторію і changelog gate.
71
+ Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
72
+ - validateFinalProjectGates — Фінальний domain gate охоплює non-code зміни, зокрема workflows, dependency
73
+ manifests і правила. Code directories уже пройшли scoped lint і tests.
74
+ - cleanupSource — Видаляє точний source після Git-доказу неактуальності або успішного
75
+ перенесення. Protected/open-PR refs не потрапляють у цей крок.
76
+ - formatReport — Формує deterministic report.
77
+ - runWithConcurrency — Виконує async jobs із bounded concurrency та стабільним порядком output.
78
+ - normalizePrConcurrency — Нормалізує bounded concurrency PR-фази.
79
+ - runGitReconcileOrchestrator — JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
55
80
 
56
81
  ## Гарантії поведінки
57
82
 
58
- - Shell interpolation не використовується: executable та arguments
59
- передаються окремо.
60
- - Live worktree, open PR, `kept`, incomplete triage та failed source не
61
- видаляються.
62
- - Stash видаляється точково за стабільним commit ID; `git stash clear` не
63
- використовується.
64
- - Worktree із проваленим перенесенням зберігається для діагностики.
65
- - Порожній tree diff не може створити remote branch або PR.
66
- - Progress не змішує фази з різною вартістю в удаваний global percentage.
83
+ - Progress є append-only та не містить ANSI cursor-control sequences.
84
+ - Не більше чотирьох PR-груп виконуються одночасно; типовий ліміт — три.
85
+ - Canonical fixers мають пріоритет перед behavioral `max` fallback.
86
+ - Cleanup починається лише після завершення всіх PR jobs і не видаляє
87
+ protected/open-PR/failed sources.
88
+ - Test baseline кешується за OID policy base branch у межах одного прогону.
@@ -1,16 +1,19 @@
1
1
  /** @see ./docs/orchestrate.md */
2
2
  import { spawnSync } from 'node:child_process'
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import { dirname, join } from 'node:path'
3
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
4
+ import { dirname, isAbsolute, join } from 'node:path'
5
5
  import { performance } from 'node:perf_hooks'
6
6
  import { env } from 'node:process'
7
7
 
8
- import { createProgressReporter } from '../../../scripts/lib/lint-surface/progress.mjs'
8
+ import { renderProgressLine } from '../../../scripts/lib/lint-surface/progress.mjs'
9
9
  import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
10
10
 
11
11
  const LLM_TIERS = ['min', 'max']
12
12
  const REVIEW_BATCH_SIZE = 10
13
13
  const PROMPT_TEXT_LIMIT = 12_000
14
+ const PROGRESS_HEARTBEAT_MS = 30_000
15
+ const DEFAULT_PR_CONCURRENCY = 3
16
+ const MAX_PR_CONCURRENCY = 4
14
17
  const SOURCE_BRANCH_PREFIX = 'branch:'
15
18
  const SOURCE_STASH_PREFIX = 'stash:'
16
19
  const CONTENT_CONFLICT_RE = /^CONFLICT \(.+?\): Merge conflict in (.+)$/
@@ -30,7 +33,10 @@ function noop() {
30
33
  // Навмисно порожньо: caller не запросив progress output.
31
34
  }
32
35
 
33
- /** @param {string} cwd корінь репозиторію @returns {string} remote ref базової гілки */
36
+ /**
37
+ * @param {string} cwd корінь репозиторію
38
+ * @returns {string} remote ref базової гілки
39
+ */
34
40
  function policyBaseRef(cwd) {
35
41
  return `origin/${readGitPolicy(cwd).baseBranch}`
36
42
  }
@@ -47,37 +53,81 @@ function elapsedLabel(startedAt, now) {
47
53
  }
48
54
 
49
55
  /**
50
- * Створює точний progress reporter для однієї фази. У TTY тримає живий bar,
51
- * у non-TTY додає незмінювані рядки початку етапів і завершених одиниць.
56
+ * Створює ANSI-free snapshot progress для однієї фази. Однаковий append-only
57
+ * формат у TTY/CI не засмічує captured output cursor-control кодами, а
58
+ * heartbeat показує elapsed time довгих LLM-етапів.
52
59
  * @param {object} args параметри фази
53
60
  * @returns {{step:(key:string,detail:string,tier?:string)=>void,done:(key:string)=>void,stop:()=>void}} reporter
54
61
  */
55
- function createPhaseProgress(args) {
56
- const { total, unitLabel, phase, log, isTTY, reporterFactory } = args
57
- if (total === 0) return { step: noop, done: noop, stop: noop }
58
-
59
- const baseLog = text => log(text.endsWith('\n') ? text.slice(0, -1) : text)
60
- const reporter = reporterFactory({
62
+ export function createPhaseProgress(args) {
63
+ const {
61
64
  total,
62
- log: baseLog,
63
- isTTY,
64
65
  unitLabel,
65
- withFixed: false
66
- })
67
- let lastNonTtyLabel = ''
66
+ phase,
67
+ log,
68
+ now = () => performance.now(),
69
+ heartbeatMs = PROGRESS_HEARTBEAT_MS,
70
+ setIntervalFn = setInterval,
71
+ clearIntervalFn = clearInterval
72
+ } = args
73
+ if (total === 0) return { step: noop, done: noop, stop: noop }
74
+
75
+ const active = new Map()
76
+ const completed = new Set()
77
+
78
+ /**
79
+ * Рендерить один append-only snapshot без керувальних ANSI-послідовностей.
80
+ * @param {string} prefix статус
81
+ * @param {string} current поточний етап
82
+ * @param {number|null} startedAt початок активної одиниці
83
+ */
84
+ function render(prefix, current, startedAt = null) {
85
+ const elapsed = startedAt === null ? '' : ` · elapsed ${elapsedLabel(startedAt, now)}`
86
+ log(
87
+ `${prefix} ${renderProgressLine({
88
+ done: completed.size,
89
+ total,
90
+ found: 0,
91
+ fixed: 0,
92
+ current,
93
+ unitLabel,
94
+ withFixed: false
95
+ })}${elapsed}`
96
+ )
97
+ }
98
+
99
+ const heartbeat = setIntervalFn(() => {
100
+ if (active.size === 0) return
101
+ const labels = active
102
+ .values()
103
+ .map(item => item.label)
104
+ .toArray()
105
+ .join(' | ')
106
+ const oldest = Math.min(...active.values().map(item => item.startedAt))
107
+ render('💓', labels, oldest)
108
+ }, heartbeatMs)
109
+ heartbeat?.unref?.()
68
110
 
69
111
  return {
70
- step: (_key, detail, tier) => {
112
+ step: (key, detail, tier) => {
71
113
  const label = `${phase} · ${detail}`
72
- reporter.concernStart(label, tier)
73
114
  const rendered = tier ? `${label} · ${tier}` : label
74
- if (!isTTY && rendered !== lastNonTtyLabel) {
75
- log(`⏳ ${rendered}`)
76
- lastNonTtyLabel = rendered
77
- }
115
+ const previous = active.get(key)
116
+ const startedAt = previous?.startedAt ?? now()
117
+ if (previous?.label === rendered) return
118
+ active.set(key, { label: rendered, startedAt })
119
+ render('⏳', rendered, startedAt)
120
+ },
121
+ done: key => {
122
+ if (completed.has(key)) return
123
+ const current = active.get(key)?.label ?? `${phase} · ${key}`
124
+ active.delete(key)
125
+ completed.add(key)
126
+ render('✅', current)
78
127
  },
79
- done: key => reporter.concernDone(key),
80
- stop: () => reporter.stop()
128
+ stop: () => {
129
+ clearIntervalFn(heartbeat)
130
+ }
81
131
  }
82
132
  }
83
133
 
@@ -181,6 +231,7 @@ function branchName(ref) {
181
231
  * @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
182
232
  * @param {Map<string,string>} worktrees branch→path
183
233
  * @param {Map<string,string>} worktreeCommits checkout HEAD OID→path
234
+ * @param {string[]} protectedBranches захищені policy branches
184
235
  * @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
185
236
  */
186
237
  export function dedupeRefs(refs, worktrees, worktreeCommits = new Map(), protectedBranches = ['main']) {
@@ -471,7 +522,7 @@ export async function callRunner(runner, prompt, cwd, deps = {}, tier = 'max') {
471
522
  * @returns {Promise<{ok:boolean,text:string,error:string|null,tier:'min'|'max',validation?:object,attempts:Array<object>}>} результат
472
523
  */
473
524
  export async function callWithValidatedFallback(args) {
474
- const { runner, prompt, cwd, validate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
525
+ const { runner, prompt, cwd, validate, remediate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
475
526
  const call = deps.callRunner ?? callRunner
476
527
  const attempts = []
477
528
  let retryPrompt = prompt
@@ -484,10 +535,18 @@ export async function callWithValidatedFallback(args) {
484
535
  attempts.push({ tier, ok: false, validation })
485
536
  return { ...outcome, tier, validation, attempts }
486
537
  }
487
- const validation = await validate(outcome)
488
- attempts.push({ tier, ok: outcome.ok, validation })
538
+ let validation = await validate(outcome)
539
+ let remediation = null
540
+ if (!validation.ok && tier === 'min' && remediate) {
541
+ remediation = await remediate(validation)
542
+ if (remediation?.attempted) {
543
+ validation = await validate(outcome)
544
+ if (validation.ok) log(`↺ ${label}: deterministic fixer усунув min validation failure`)
545
+ }
546
+ }
547
+ attempts.push({ tier, ok: outcome.ok, validation, ...(remediation && { remediation }) })
489
548
  if (validation.ok) {
490
- return { ...outcome, tier, validation, attempts }
549
+ return { ...outcome, tier, validation, attempts, ...(remediation?.attempted && { remediated: true }) }
491
550
  }
492
551
  if (tier === 'min') {
493
552
  const reason = (validation.error ?? outcome.error ?? 'невідома помилка validation').slice(0, PROMPT_TEXT_LIMIT)
@@ -655,6 +714,29 @@ function createReconcileWorktree(title, source, cwd, spawnFn) {
655
714
  return { branch, cwd: worktreeCwd }
656
715
  }
657
716
 
717
+ /**
718
+ * Додає `.worktrees/` до локального Git exclude без tracked-змін у consumer.
719
+ * Це не замінює repository Vitest excludes, але не лишає root checkout dirty
720
+ * через керовані або forensic worktree.
721
+ * @param {string} cwd корінь repository
722
+ * @param {typeof spawnSync} spawnFn інжект
723
+ * @returns {boolean} чи локальний exclude було змінено
724
+ */
725
+ export function ensureLocalWorktreeExclude(cwd, spawnFn = spawnSync) {
726
+ try {
727
+ const excludePath = git(['rev-parse', '--git-path', 'info/exclude'], cwd, spawnFn).stdout.trim()
728
+ if (!excludePath) return false
729
+ const absoluteExcludePath = isAbsolute(excludePath) ? excludePath : join(cwd, excludePath)
730
+ const existing = existsSync(absoluteExcludePath) ? readFileSync(absoluteExcludePath, 'utf8') : ''
731
+ if (existing.split('\n').some(line => line.trim() === '.worktrees/')) return false
732
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''
733
+ appendFileSync(absoluteExcludePath, `${separator}.worktrees/\n`)
734
+ return true
735
+ } catch {
736
+ return false
737
+ }
738
+ }
739
+
658
740
  /**
659
741
  * Прибирає reconciliation worktree або fail-closed лишає source неочищеним.
660
742
  * @param {{branch:string,cwd:string}} worktree створений worktree
@@ -811,7 +893,9 @@ export function sourceDirectories(paths) {
811
893
  * @returns {string[]} directories
812
894
  */
813
895
  function changedPaths(cwd, spawnFn) {
814
- const tracked = git(['diff', '--name-only', policyBaseRef(cwd), '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
896
+ const tracked = git(['diff', '--name-only', policyBaseRef(cwd), '--'], cwd, spawnFn)
897
+ .stdout.split('\n')
898
+ .filter(Boolean)
815
899
  const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
816
900
  return [...new Set([...tracked, ...untracked])]
817
901
  }
@@ -859,19 +943,67 @@ function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
859
943
  allowFailure: true
860
944
  })
861
945
  if (docs.status !== 0) {
862
- return { ok: false, error: `doc-files (${path}): ${docs.stderr || docs.stdout}` }
946
+ return {
947
+ ok: false,
948
+ error: `doc-files (${path}): ${docs.stderr || docs.stdout}`,
949
+ remediation: 'canonical-fixers'
950
+ }
863
951
  }
864
952
  onProgress(`scoped lint (${path})`)
865
953
  const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
866
954
  allowFailure: true
867
955
  })
868
956
  if (lint.status !== 0) {
869
- return { ok: false, error: `scoped lint (${path}): ${lint.stderr || lint.stdout}` }
957
+ return {
958
+ ok: false,
959
+ error: `scoped lint (${path}): ${lint.stderr || lint.stdout}`,
960
+ remediation: 'canonical-fixers'
961
+ }
870
962
  }
871
963
  }
872
964
  return { ok: true }
873
965
  }
874
966
 
967
+ /**
968
+ * Запускає canonical fixers у worktree до ескалації min→max. Це прибирає
969
+ * formatting/CSpell/doc/changelog дефекти без повторного behavioral LLM.
970
+ * @param {string} cwd worktree
971
+ * @param {typeof spawnSync} spawnFn інжект
972
+ * @param {{remediation?:string}} validation провалена validation
973
+ * @param {(stage:string)=>void} [onProgress] stage callback
974
+ * @returns {{attempted:boolean,ok:boolean,error?:string}} результат
975
+ */
976
+ export function remediateBehaviorState(cwd, spawnFn = spawnSync, validation = {}, onProgress = noop) {
977
+ if (validation.remediation !== 'canonical-fixers') return { attempted: false, ok: false }
978
+
979
+ for (const path of changedSourceDirectories(cwd, spawnFn)) {
980
+ onProgress(`deterministic fix (${path})`)
981
+ const fixed = run('npx', ['@7n/rules', 'lint', '--path', path], cwd, spawnFn, {
982
+ allowFailure: true
983
+ })
984
+ if (fixed.status !== 0) {
985
+ return {
986
+ attempted: true,
987
+ ok: false,
988
+ error: `canonical fix (${path}): ${fixed.stderr || fixed.stdout}`
989
+ }
990
+ }
991
+ }
992
+
993
+ onProgress('deterministic changelog fix')
994
+ const changelog = run('npx', ['@7n/rules', 'lint', 'changelog'], cwd, spawnFn, {
995
+ allowFailure: true
996
+ })
997
+ if (changelog.status !== 0) {
998
+ return {
999
+ attempted: true,
1000
+ ok: false,
1001
+ error: `canonical changelog fix: ${changelog.stderr || changelog.stdout}`
1002
+ }
1003
+ }
1004
+ return { attempted: true, ok: true }
1005
+ }
1006
+
875
1007
  /**
876
1008
  * Встановлює frozen Bun dependencies, якщо новий worktree їх ще не має.
877
1009
  * @param {string} cwd worktree
@@ -947,7 +1079,11 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null,
947
1079
  onProgress('changelog')
948
1080
  const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
949
1081
  if (changelog.status !== 0) {
950
- return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
1082
+ return {
1083
+ ok: false,
1084
+ error: `changelog gate: ${changelog.stderr || changelog.stdout}`,
1085
+ remediation: 'canonical-fixers'
1086
+ }
951
1087
  }
952
1088
  return { ok: true }
953
1089
  }
@@ -1064,7 +1200,9 @@ async function finalizeBehavior(args) {
1064
1200
  label: `behavior ${source}`,
1065
1201
  onAttempt: ({ tier }) => onProgress('behavior validation', tier),
1066
1202
  validate: () =>
1067
- validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`))
1203
+ validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`)),
1204
+ remediate: validation =>
1205
+ remediateBehaviorState(worktreeCwd, spawnFn, validation, step => onProgress(`behavior validation: ${step}`))
1068
1206
  })
1069
1207
  if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
1070
1208
  return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
@@ -1325,69 +1463,130 @@ async function triageCandidates(args) {
1325
1463
  }
1326
1464
 
1327
1465
  /**
1328
- * Матеріалізує одне triage-рішення у report results.
1466
+ * Матеріалізує одну PR-групу з наперед визначеним progress index.
1329
1467
  * @param {object} args orchestration context
1330
- * @returns {Promise<Array<object>>} results одного source
1468
+ * @returns {Promise<object>} result одного source/group
1331
1469
  */
1332
- async function materializeDecision(args) {
1333
- const { decision, candidate, runner, rootCwd, baselineCache, deps, spawnFn, log, createPr, progress, prState } = args
1334
- if (decision.action !== 'pr') {
1335
- return [
1336
- {
1337
- source: decision.source,
1338
- status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
1339
- rationale: decision.rationale ?? '',
1340
- ...(decision.incomplete === true && { incomplete: true }),
1341
- ...(candidate.oid && { oid: candidate.oid })
1342
- }
1343
- ]
1470
+ async function materializePrGroup(args) {
1471
+ const {
1472
+ decision,
1473
+ candidate,
1474
+ group,
1475
+ prIndex,
1476
+ prTotal,
1477
+ runner,
1478
+ rootCwd,
1479
+ baselineCache,
1480
+ deps,
1481
+ spawnFn,
1482
+ log,
1483
+ createPr,
1484
+ progress
1485
+ } = args
1486
+ const key = `pr-${prIndex}`
1487
+ const prefix = `${prIndex}/${prTotal} · ${decision.source}`
1488
+ progress.step(key, `${prefix} · worktree`)
1489
+ try {
1490
+ const result = await createPr({
1491
+ candidate: { ...candidate, rationale: decision.rationale },
1492
+ group,
1493
+ runner,
1494
+ rootCwd,
1495
+ baselineCache,
1496
+ deps,
1497
+ spawnFn,
1498
+ log,
1499
+ onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
1500
+ })
1501
+ return {
1502
+ source: decision.source,
1503
+ ...(candidate.oid && { oid: candidate.oid }),
1504
+ ...result
1505
+ }
1506
+ } finally {
1507
+ progress.done(key)
1344
1508
  }
1509
+ }
1345
1510
 
1346
- const results = []
1347
- for (const group of decision.groups) {
1348
- prState.index += 1
1349
- const key = `pr-${prState.index}`
1350
- const prefix = `${prState.index}/${prState.total} · ${decision.source}`
1351
- progress.step(key, `${prefix} · worktree`)
1352
- try {
1353
- const result = await createPr({
1354
- candidate: { ...candidate, rationale: decision.rationale },
1355
- group,
1356
- runner,
1357
- rootCwd,
1358
- baselineCache,
1359
- deps,
1360
- spawnFn,
1361
- log,
1362
- onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
1363
- })
1364
- results.push({
1365
- source: decision.source,
1366
- ...(candidate.oid && { oid: candidate.oid }),
1367
- ...result
1368
- })
1369
- } finally {
1370
- progress.done(key)
1511
+ /**
1512
+ * Виконує async jobs із bounded concurrency та стабільним порядком output.
1513
+ * @param {Array<()=>Promise<object>>} jobs jobs
1514
+ * @param {number} concurrency одночасні jobs
1515
+ * @returns {Promise<object[]>} результати в порядку jobs
1516
+ */
1517
+ export async function runWithConcurrency(jobs, concurrency) {
1518
+ const results = Array.from({ length: jobs.length })
1519
+ let next = 0
1520
+ /** Виконує наступні jobs до вичерпання спільної черги. */
1521
+ async function worker() {
1522
+ while (next < jobs.length) {
1523
+ const index = next
1524
+ next += 1
1525
+ results[index] = await jobs[index]()
1371
1526
  }
1372
1527
  }
1528
+ const workerCount = Math.min(jobs.length, Math.max(1, concurrency))
1529
+ await Promise.all(Array.from({ length: workerCount }, () => worker()))
1373
1530
  return results
1374
1531
  }
1375
1532
 
1376
1533
  /**
1377
- * Перетворює всі validated decisions у PR/keep/drop results.
1534
+ * Нормалізує bounded concurrency PR-фази.
1535
+ * @param {unknown} value override/env value
1536
+ * @returns {number} 1..MAX_PR_CONCURRENCY
1537
+ */
1538
+ export function normalizePrConcurrency(value) {
1539
+ const parsed = Math.trunc(Number(value))
1540
+ if (!Number.isFinite(parsed)) return DEFAULT_PR_CONCURRENCY
1541
+ return Math.min(MAX_PR_CONCURRENCY, Math.max(1, parsed))
1542
+ }
1543
+
1544
+ /**
1545
+ * Перетворює validated decisions у PR/keep/drop results. Незалежні PR-групи
1546
+ * виконуються паралельно з bounded concurrency, cleanup стартує лише після
1547
+ * завершення всіх jobs.
1378
1548
  * @param {object} args orchestration context
1379
1549
  * @returns {Promise<{bySource:Map<string,object>,results:Array<object>}>} materialized state
1380
1550
  */
1381
1551
  async function materializeDecisions(args) {
1382
- const { decisions, candidates } = args
1552
+ const { decisions, candidates, prConcurrency, prTotal } = args
1383
1553
  const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
1384
- const results = []
1554
+ const resultSlots = []
1555
+ const jobs = []
1556
+ let prIndex = 0
1385
1557
  for (const decision of decisions) {
1386
1558
  const candidate = bySource.get(decision.source)
1387
1559
  if (!candidate) continue
1388
- results.push(...(await materializeDecision({ ...args, decision, candidate })))
1560
+ if (decision.action !== 'pr') {
1561
+ resultSlots.push({
1562
+ source: decision.source,
1563
+ status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
1564
+ rationale: decision.rationale ?? '',
1565
+ ...(decision.incomplete === true && { incomplete: true }),
1566
+ ...(candidate.oid && { oid: candidate.oid })
1567
+ })
1568
+ continue
1569
+ }
1570
+ for (const group of decision.groups) {
1571
+ prIndex += 1
1572
+ const fixedIndex = prIndex
1573
+ const resultIndex = resultSlots.length
1574
+ resultSlots.push(null)
1575
+ jobs.push(async () => {
1576
+ resultSlots[resultIndex] = await materializePrGroup({
1577
+ ...args,
1578
+ decision,
1579
+ candidate,
1580
+ group,
1581
+ prIndex: fixedIndex,
1582
+ prTotal
1583
+ })
1584
+ return resultSlots[resultIndex]
1585
+ })
1586
+ }
1389
1587
  }
1390
- return { bySource, results }
1588
+ await runWithConcurrency(jobs, prConcurrency)
1589
+ return { bySource, results: resultSlots.filter(Boolean) }
1391
1590
  }
1392
1591
 
1393
1592
  /**
@@ -1476,14 +1675,20 @@ export async function runGitReconcileOrchestrator(options = {}) {
1476
1675
  const inventoryFn = deps.inventoryRepository ?? inventoryRepository
1477
1676
  const createPr = deps.createPullRequest ?? createPullRequest
1478
1677
  const cleanup = deps.cleanupSource ?? cleanupSource
1479
- const reporterFactory = deps.createProgressReporter ?? createProgressReporter
1480
- const isTTY = options.isTTY ?? process.stdout.isTTY === true
1481
1678
  const now = deps.now ?? (() => performance.now())
1679
+ const setIntervalFn = deps.setIntervalFn ?? setInterval
1680
+ const clearIntervalFn = deps.clearIntervalFn ?? clearInterval
1681
+ const heartbeatMs = deps.heartbeatMs ?? PROGRESS_HEARTBEAT_MS
1682
+ const prConcurrency = normalizePrConcurrency(deps.prConcurrency ?? env.N_GIT_RECONCILE_CONCURRENCY)
1482
1683
  const baselineCache = new Map()
1483
1684
 
1484
1685
  const inventoryStartedAt = now()
1485
1686
  log('⏳ 1/4 inventory')
1486
1687
  const inventory = inventoryFn(rootCwd, { spawnFn })
1688
+ if (deps.ensureLocalWorktreeExclude !== false) {
1689
+ const ensureExclude = deps.ensureLocalWorktreeExclude ?? ensureLocalWorktreeExclude
1690
+ if (ensureExclude(rootCwd, spawnFn)) log('🛡️ Додано `.worktrees/` до локального `.git/info/exclude`')
1691
+ }
1487
1692
  const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
1488
1693
  log(
1489
1694
  `✅ 1/4 inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
@@ -1495,8 +1700,10 @@ export async function runGitReconcileOrchestrator(options = {}) {
1495
1700
  unitLabel: 'triage-пакетів',
1496
1701
  phase: '2/4 triage',
1497
1702
  log,
1498
- isTTY,
1499
- reporterFactory
1703
+ now,
1704
+ heartbeatMs,
1705
+ setIntervalFn,
1706
+ clearIntervalFn
1500
1707
  })
1501
1708
  const triageStartedAt = now()
1502
1709
  let decisions
@@ -1523,11 +1730,12 @@ export async function runGitReconcileOrchestrator(options = {}) {
1523
1730
  unitLabel: 'PR-груп',
1524
1731
  phase: '3/4 PR',
1525
1732
  log,
1526
- isTTY,
1527
- reporterFactory
1733
+ now,
1734
+ heartbeatMs,
1735
+ setIntervalFn,
1736
+ clearIntervalFn
1528
1737
  })
1529
1738
  const prStartedAt = now()
1530
- const prState = { index: 0, total: prTotal }
1531
1739
  let materialized
1532
1740
  try {
1533
1741
  materialized = await materializeDecisions({
@@ -1541,7 +1749,8 @@ export async function runGitReconcileOrchestrator(options = {}) {
1541
1749
  log,
1542
1750
  createPr,
1543
1751
  progress: prProgress,
1544
- prState
1752
+ prTotal,
1753
+ prConcurrency
1545
1754
  })
1546
1755
  } finally {
1547
1756
  prProgress.stop()
@@ -1555,8 +1764,10 @@ export async function runGitReconcileOrchestrator(options = {}) {
1555
1764
  unitLabel: 'джерел',
1556
1765
  phase: '4/4 cleanup',
1557
1766
  log,
1558
- isTTY,
1559
- reporterFactory
1767
+ now,
1768
+ heartbeatMs,
1769
+ setIntervalFn,
1770
+ clearIntervalFn
1560
1771
  })
1561
1772
  const cleanupStartedAt = now()
1562
1773
  const cleanupState = { index: 0 }