@7n/rules 1.48.2 → 1.49.1

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/n-rules-cli.mjs +5 -8
  3. package/package.json +1 -1
  4. package/rules/changelog/.changes/260724-1500.md +5 -0
  5. package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
  6. package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
  7. package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
  8. package/rules/doc-files/docgen-gen/docs/index.md +9 -0
  9. package/rules/doc-files/docgen-gen/docs/main.md +40 -29
  10. package/rules/doc-files/docgen-gen/main.mjs +79 -25
  11. package/rules/test/coverage/fix-worker.mjs +9 -1
  12. package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
  13. package/scripts/docs/skills-cli.md +18 -24
  14. package/scripts/lib/acp-runner.mjs +1 -1
  15. package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
  16. package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
  17. package/scripts/lib/lint-surface/docs/index.md +1 -0
  18. package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
  19. package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
  20. package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
  21. package/scripts/lib/lint-surface/run-fix.mjs +209 -38
  22. package/scripts/lib/lint-surface/snapshot.mjs +6 -0
  23. package/scripts/lib/lint-surface/test-gate.mjs +87 -0
  24. package/scripts/skills-cli.mjs +60 -10
  25. package/scripts/utils/docs/glob-compat.md +20 -14
  26. package/scripts/utils/glob-compat.mjs +18 -3
  27. package/skills/git-reconcile/SKILL.md +58 -0
  28. package/skills/git-reconcile/js/docs/index.md +9 -0
  29. package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
  30. package/skills/git-reconcile/js/orchestrate.mjs +776 -0
  31. package/skills/git-reconcile/main.json +1 -0
  32. package/skills/taze/js/docs/orchestrate.md +40 -18
  33. package/skills/taze/js/orchestrate.mjs +6 -3
@@ -0,0 +1,776 @@
1
+ /** @see ./docs/orchestrate.md */
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ const BASE_REF = 'origin/main'
7
+ const REVIEW_BATCH_SIZE = 10
8
+ const PROMPT_TEXT_LIMIT = 12_000
9
+ const SOURCE_BRANCH_PREFIX = 'branch:'
10
+ const SOURCE_STASH_PREFIX = 'stash:'
11
+
12
+ /**
13
+ * Виконує команду без shell-інтерполяції.
14
+ * @param {string} command виконуваний файл
15
+ * @param {string[]} args аргументи
16
+ * @param {string} cwd робочий каталог
17
+ * @param {typeof spawnSync} spawnFn інжект для тестів
18
+ * @param {{ allowFailure?: boolean, input?: string }} [options] режим помилки та stdin
19
+ * @returns {{ status: number, stdout: string, stderr: string }} результат
20
+ */
21
+ function run(command, args, cwd, spawnFn, options = {}) {
22
+ const result = spawnFn(command, args, {
23
+ cwd,
24
+ encoding: 'utf8',
25
+ input: options.input,
26
+ env: { ...process.env, GIT_EDITOR: 'true' },
27
+ maxBuffer: 16 * 1024 * 1024
28
+ })
29
+ const normalized = {
30
+ status: result.status ?? 1,
31
+ stdout: result.stdout ?? '',
32
+ stderr: result.stderr ?? ''
33
+ }
34
+ if (!options.allowFailure && normalized.status !== 0) {
35
+ throw new Error(
36
+ `${command} ${args.join(' ')} → exit ${normalized.status}: ${normalized.stderr || normalized.stdout}`
37
+ )
38
+ }
39
+ return normalized
40
+ }
41
+
42
+ /**
43
+ * Запускає git у конкретному checkout.
44
+ * @param {string[]} args аргументи git
45
+ * @param {string} cwd checkout
46
+ * @param {typeof spawnSync} spawnFn інжект
47
+ * @param {{ allowFailure?: boolean, input?: string }} [options] режим
48
+ * @returns {{ status: number, stdout: string, stderr: string }} результат
49
+ */
50
+ function git(args, cwd, spawnFn, options) {
51
+ return run('git', args, cwd, spawnFn, options)
52
+ }
53
+
54
+ /**
55
+ * Розбирає JSON без падіння оркестратора.
56
+ * @param {string} text JSON-текст
57
+ * @param {unknown} fallback fallback
58
+ * @returns {unknown} розібране значення або fallback
59
+ */
60
+ function parseJson(text, fallback) {
61
+ try {
62
+ return JSON.parse(text)
63
+ } catch {
64
+ return fallback
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Парсить `git worktree list --porcelain` у branch→path.
70
+ * @param {string} text porcelain
71
+ * @returns {Map<string, string>} повний ref гілки → checkout
72
+ */
73
+ export function parseWorktrees(text) {
74
+ const out = new Map()
75
+ let path = ''
76
+ for (const line of text.split('\n')) {
77
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
78
+ if (line.startsWith('branch ')) out.set(line.slice('branch '.length), path)
79
+ if (line.length === 0) path = ''
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * Нормалізує branch ref у назву для зіставлення з GitHub PR.
86
+ * @param {string} ref повний ref
87
+ * @returns {string} коротке ім'я
88
+ */
89
+ function branchName(ref) {
90
+ return ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '')
91
+ }
92
+
93
+ /**
94
+ * Дедуплікує local/remote refs одного commit: remote має пріоритет, але
95
+ * worktree-protection локального ref переноситься у запис.
96
+ * @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
97
+ * @param {Map<string,string>} worktrees branch→path
98
+ * @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
99
+ */
100
+ export function dedupeRefs(refs, worktrees) {
101
+ const byOid = new Map()
102
+ for (const item of refs) {
103
+ if (item.ref === 'refs/remotes/origin/HEAD' || branchName(item.ref) === 'main') continue
104
+ const existing = byOid.get(item.oid)
105
+ const worktree = worktrees.get(item.ref) ?? existing?.worktree ?? null
106
+ const isRemote = item.ref.startsWith('refs/remotes/origin/')
107
+ const aliases = [...new Set([...(existing?.aliases ?? []), item.ref])].toSorted()
108
+ if (!existing || isRemote) {
109
+ byOid.set(item.oid, { ...item, worktree, aliases })
110
+ } else if (worktree) {
111
+ existing.worktree = worktree
112
+ existing.aliases = aliases
113
+ } else {
114
+ existing.aliases = aliases
115
+ }
116
+ }
117
+ return [...byOid.values()].toSorted((a, b) => a.ref.localeCompare(b.ref))
118
+ }
119
+
120
+ /**
121
+ * Збирає відкриті PR; недоступний gh не блокує git-inventory.
122
+ * @param {string} cwd корінь
123
+ * @param {typeof spawnSync} spawnFn інжект
124
+ * @returns {Map<string,{number:number,url:string}>} head branch → PR
125
+ */
126
+ function openPullRequests(cwd, spawnFn) {
127
+ const result = run(
128
+ 'gh',
129
+ ['pr', 'list', '--state', 'open', '--limit', '500', '--json', 'headRefName,number,url'],
130
+ cwd,
131
+ spawnFn,
132
+ { allowFailure: true }
133
+ )
134
+ if (result.status !== 0) return new Map()
135
+ const rows = /** @type {Array<{headRefName:string,number:number,url:string}>} */ (parseJson(result.stdout, []))
136
+ return new Map(rows.map(row => [row.headRefName, { number: row.number, url: row.url }]))
137
+ }
138
+
139
+ /**
140
+ * Збирає compact commit metadata лише для patch-унікальних non-merge комітів.
141
+ * @param {string[]} oids commit ids
142
+ * @param {string} cwd корінь
143
+ * @param {typeof spawnSync} spawnFn інжект
144
+ * @returns {Array<{oid:string,subject:string}>} коміти
145
+ */
146
+ function commitMetadata(oids, cwd, spawnFn) {
147
+ return oids.map(oid => {
148
+ const subject = git(['show', '-s', '--format=%s', oid], cwd, spawnFn).stdout.trim()
149
+ return { oid, subject }
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Витягає конфліктні файли з `git merge-tree`.
155
+ * @param {string} text merge-tree output
156
+ * @returns {string[]} унікальні шляхи
157
+ */
158
+ export function conflictFiles(text) {
159
+ const files = new Set()
160
+ for (const line of text.split('\n')) {
161
+ const content = line.match(/^CONFLICT \(.+?\): Merge conflict in (.+)$/)
162
+ const rename = line.match(/^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/)
163
+ const modifyDelete = line.match(/^CONFLICT \(modify\/delete\): (.+?) deleted in /)
164
+ const path = content?.[1] ?? rename?.[1] ?? modifyDelete?.[1]
165
+ if (path) files.add(path)
166
+ }
167
+ return [...files].toSorted()
168
+ }
169
+
170
+ /**
171
+ * Збирає детермінований Git inventory. Нічого не видаляє і не змінює у
172
+ * checkout, крім оновлення remote refs через fetch --prune.
173
+ * @param {string} cwd корінь репо
174
+ * @param {{ spawnFn?: typeof spawnSync }} [deps] інжекти
175
+ * @returns {{base:string,branches:Array<object>,stashes:Array<object>,warnings:string[]}} inventory
176
+ */
177
+ export function inventoryRepository(cwd, deps = {}) {
178
+ const spawnFn = deps.spawnFn ?? spawnSync
179
+ git(['fetch', '--prune', 'origin'], cwd, spawnFn)
180
+ git(['rev-parse', '--verify', BASE_REF], cwd, spawnFn)
181
+
182
+ const worktrees = parseWorktrees(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
183
+ const refLines = git(
184
+ [
185
+ 'for-each-ref',
186
+ '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)',
187
+ 'refs/heads',
188
+ 'refs/remotes/origin'
189
+ ],
190
+ cwd,
191
+ spawnFn
192
+ ).stdout
193
+ .split('\n')
194
+ .filter(Boolean)
195
+ const refs = dedupeRefs(
196
+ refLines.map(line => {
197
+ const [ref, oid, date] = line.split('\0')
198
+ return { ref, oid, date }
199
+ }),
200
+ worktrees
201
+ )
202
+ const prs = openPullRequests(cwd, spawnFn)
203
+ const warnings = []
204
+ if (prs.size === 0) warnings.push('GitHub PR inventory порожній або gh недоступний')
205
+
206
+ const branches = refs.map(item => {
207
+ const name = branchName(item.ref)
208
+ const merged = git(['merge-base', '--is-ancestor', item.ref, BASE_REF], cwd, spawnFn, {
209
+ allowFailure: true
210
+ }).status === 0
211
+ const novelOids = merged
212
+ ? []
213
+ : git(
214
+ ['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`],
215
+ cwd,
216
+ spawnFn
217
+ ).stdout
218
+ .split('\n')
219
+ .filter(Boolean)
220
+ .toReversed()
221
+ const counts = git(['rev-list', '--left-right', '--count', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
222
+ .stdout.trim()
223
+ .split(/\s+/)
224
+ .map(Number)
225
+ const pr = prs.get(name) ?? null
226
+ const state = merged
227
+ ? 'merged'
228
+ : novelOids.length === 0
229
+ ? 'patch-equivalent'
230
+ : pr
231
+ ? 'open-pr'
232
+ : item.worktree
233
+ ? 'protected'
234
+ : 'review'
235
+ const changedFiles =
236
+ state === 'review'
237
+ ? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn).stdout
238
+ .split('\n')
239
+ .filter(Boolean)
240
+ .slice(0, 200)
241
+ : []
242
+ const mergeTree =
243
+ state === 'review'
244
+ ? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout
245
+ : ''
246
+ return {
247
+ source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
248
+ ref: item.ref,
249
+ name,
250
+ oid: item.oid,
251
+ date: item.date,
252
+ state,
253
+ worktree: item.worktree,
254
+ pr,
255
+ behind: counts[0] ?? 0,
256
+ ahead: counts[1] ?? 0,
257
+ commits: commitMetadata(novelOids, cwd, spawnFn),
258
+ changedFiles,
259
+ conflicts: conflictFiles(mergeTree)
260
+ }
261
+ })
262
+
263
+ const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout
264
+ .split('\n')
265
+ .filter(Boolean)
266
+ const stashes = stashRows.map(line => {
267
+ const [ref, oid, subject] = line.split('\0')
268
+ const changedFiles = git(['stash', 'show', '--name-status', ref], cwd, spawnFn).stdout
269
+ .split('\n')
270
+ .filter(Boolean)
271
+ return {
272
+ source: `${SOURCE_STASH_PREFIX}${ref}`,
273
+ ref,
274
+ oid,
275
+ subject,
276
+ state: 'review',
277
+ changedFiles
278
+ }
279
+ })
280
+
281
+ return { base: BASE_REF, branches, stashes, warnings }
282
+ }
283
+
284
+ /**
285
+ * Формує bounded semantic-triage prompt. Git-факти вже пораховані JS; модель
286
+ * не виконує shell-команди й повертає лише JSON-рішення.
287
+ * @param {Array<object>} candidates review branches/stashes
288
+ * @param {string} task додатковий намір користувача
289
+ * @returns {string} промпт
290
+ */
291
+ export function buildTriagePrompt(candidates, task = '') {
292
+ return [
293
+ 'Ти виконуєш лише semantic triage уже зібраних Git-фактів.',
294
+ 'Не запускай команди, не редагуй файли, не створюй PR і не видаляй refs.',
295
+ task ? `Намір користувача: ${task}` : '',
296
+ 'Для кожного source поверни action: pr, keep або drop.',
297
+ 'pr — лише завершена корисна поведінка; groups розділяють незалежні PR.',
298
+ 'keep — бракує доказів або робота активна/незавершена. drop — явно артефакт/застаріле.',
299
+ 'Для branch group commits — непорожній subset commit oid із facts. Для stash commits не потрібні.',
300
+ 'Відповідь — лише JSON без markdown:',
301
+ '{"decisions":[{"source":"branch:refs/remotes/origin/x","action":"pr","rationale":"...","groups":[{"title":"fix: ...","commits":["oid"]}]}]}',
302
+ JSON.stringify(candidates)
303
+ ]
304
+ .filter(Boolean)
305
+ .join('\n\n')
306
+ }
307
+
308
+ /**
309
+ * Витягає JSON object із чистої або fenced відповіді.
310
+ * @param {string} text відповідь моделі
311
+ * @returns {object|null} object або null
312
+ */
313
+ export function parseDecisionEnvelope(text) {
314
+ const trimmed = text.trim()
315
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
316
+ const first = unfenced.indexOf('{')
317
+ const last = unfenced.lastIndexOf('}')
318
+ if (first === -1 || last <= first) return null
319
+ const parsed = parseJson(unfenced.slice(first, last + 1), null)
320
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
321
+ }
322
+
323
+ /**
324
+ * Викликає вибраний LLM runner для одного bounded-завдання.
325
+ * @param {'pi'|'cursor'|'codex'} runner раннер
326
+ * @param {string} prompt промпт
327
+ * @param {string} cwd робочий каталог
328
+ * @param {object} deps інжекти
329
+ * @returns {Promise<{ok:boolean,text:string,error:string|null}>} результат
330
+ */
331
+ export async function callRunner(runner, prompt, cwd, deps = {}) {
332
+ if (runner === 'pi') {
333
+ let runAgentSkill = deps.runAgentSkill
334
+ if (!runAgentSkill) {
335
+ const module = await import('@7n/llm-lib/agent-skill')
336
+ runAgentSkill = module.runAgentSkill
337
+ }
338
+ let text = ''
339
+ const result = await runAgentSkill(prompt, {
340
+ skillId: 'git-reconcile',
341
+ tier: 'max',
342
+ cwd,
343
+ deps: { out: chunk => (text += chunk) }
344
+ })
345
+ return { ok: result.ok, text, error: result.error }
346
+ }
347
+
348
+ let runAcpAgent = deps.runAcpAgent
349
+ if (!runAcpAgent) {
350
+ const module = await import('@7n/llm-lib/acp')
351
+ runAcpAgent = module.runAcpAgent
352
+ }
353
+ try {
354
+ const text = await runAcpAgent(runner, prompt, cwd, { tier: 'max' })
355
+ return { ok: true, text, error: null }
356
+ } catch (error) {
357
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Перетворює довільний title/ref на branch slug.
363
+ * @param {string} value title/ref
364
+ * @returns {string} slug
365
+ */
366
+ export function branchSlug(value) {
367
+ const slug = value
368
+ .toLowerCase()
369
+ .replaceAll(/[^a-z0-9]+/g, '-')
370
+ .replaceAll(/^-|-$/g, '')
371
+ .slice(0, 40)
372
+ return slug || 'change'
373
+ }
374
+
375
+ /**
376
+ * Обирає вільну rescue-гілку.
377
+ * @param {string} title PR title
378
+ * @param {string} cwd корінь
379
+ * @param {typeof spawnSync} spawnFn інжект
380
+ * @returns {string} branch
381
+ */
382
+ function chooseBranch(title, cwd, spawnFn) {
383
+ const base = `codex/reconcile-${branchSlug(title)}`
384
+ let candidate = base
385
+ let suffix = 2
386
+ while (
387
+ git(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], cwd, spawnFn, {
388
+ allowFailure: true
389
+ }).status === 0 ||
390
+ git(['ls-remote', '--exit-code', '--heads', 'origin', candidate], cwd, spawnFn, {
391
+ allowFailure: true
392
+ }).status === 0
393
+ ) {
394
+ candidate = `${base}-${suffix}`
395
+ suffix += 1
396
+ }
397
+ return candidate
398
+ }
399
+
400
+ /**
401
+ * Створює керований worktree та детерміновано пересаджує його branch на
402
+ * origin/main без зміни вихідного checkout.
403
+ * @param {string} title PR title
404
+ * @param {string} source source id
405
+ * @param {string} cwd корінь
406
+ * @param {typeof spawnSync} spawnFn інжект
407
+ * @returns {{branch:string,cwd:string}} worktree
408
+ */
409
+ function createReconcileWorktree(title, source, cwd, spawnFn) {
410
+ const branch = chooseBranch(title, cwd, spawnFn)
411
+ run('npx', ['@7n/mt', 'worktree', 'create', branch, `git-reconcile: ${source}`], cwd, spawnFn)
412
+ const worktreeCwd = join(cwd, '.worktrees', branch.replaceAll('/', '-'))
413
+ git(['switch', '--detach', BASE_REF], worktreeCwd, spawnFn)
414
+ git(['branch', '-f', branch, BASE_REF], worktreeCwd, spawnFn)
415
+ git(['switch', branch], worktreeCwd, spawnFn)
416
+ return { branch, cwd: worktreeCwd }
417
+ }
418
+
419
+ /**
420
+ * Перевіряє, чи лишились unmerged paths.
421
+ * @param {string} cwd worktree
422
+ * @param {typeof spawnSync} spawnFn інжект
423
+ * @returns {string[]} шляхи
424
+ */
425
+ function unresolvedFiles(cwd, spawnFn) {
426
+ return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout
427
+ .split('\n')
428
+ .filter(Boolean)
429
+ }
430
+
431
+ /**
432
+ * Просить LLM розв'язати лише вже матеріалізований конфлікт.
433
+ * @param {object} args контекст
434
+ * @returns {Promise<void>}
435
+ */
436
+ async function resolveConflict({ runner, source, worktreeCwd, deps, spawnFn }) {
437
+ const unresolved = unresolvedFiles(worktreeCwd, spawnFn)
438
+ const prompt = [
439
+ `У worktree ${worktreeCwd} JS уже застосував ${source} до свіжого origin/main.`,
440
+ `Розв'яжи лише змістові конфлікти: ${unresolved.join(', ')}.`,
441
+ 'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs всліпу.',
442
+ 'Збережи актуальну поведінку main, перенеси лише відсутню корисну частину.',
443
+ 'За потреби онови regression test. Не commit, не push, не створюй PR і не видаляй refs.',
444
+ 'Наприкінці прибери conflict markers і коротко переліч перевірки.'
445
+ ].join('\n\n')
446
+ const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
447
+ if (!outcome.ok) throw new Error(`LLM conflict resolution: ${outcome.error}`)
448
+ const remaining = unresolvedFiles(worktreeCwd, spawnFn)
449
+ if (remaining.length > 0) throw new Error(`Нерозв'язані конфлікти: ${remaining.join(', ')}`)
450
+ git(['add', '-A'], worktreeCwd, spawnFn)
451
+ }
452
+
453
+ /**
454
+ * Застосовує один branch group або stash у worktree.
455
+ * @param {object} args контекст
456
+ * @returns {Promise<void>}
457
+ */
458
+ async function applySource({ source, sourceOid, commits, runner, rootCwd, worktreeCwd, deps, spawnFn }) {
459
+ if (source.startsWith(SOURCE_BRANCH_PREFIX)) {
460
+ for (const oid of commits) {
461
+ const result = git(['cherry-pick', oid], worktreeCwd, spawnFn, { allowFailure: true })
462
+ if (result.status === 0) continue
463
+ if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
464
+ throw new Error(`cherry-pick ${oid}: ${result.stderr || result.stdout}`)
465
+ }
466
+ await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn })
467
+ if (
468
+ git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], worktreeCwd, spawnFn, {
469
+ allowFailure: true
470
+ }).status === 0
471
+ ) {
472
+ git(['cherry-pick', '--continue'], worktreeCwd, spawnFn)
473
+ }
474
+ }
475
+ return
476
+ }
477
+
478
+ const stashRef = source.slice(SOURCE_STASH_PREFIX.length)
479
+ const patch = git(['stash', 'show', '-p', '--binary', sourceOid ?? stashRef], rootCwd, spawnFn).stdout
480
+ const applied = git(['apply', '--3way', '-'], worktreeCwd, spawnFn, {
481
+ allowFailure: true,
482
+ input: patch
483
+ })
484
+ if (applied.status !== 0) {
485
+ if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
486
+ throw new Error(`git apply ${stashRef}: ${applied.stderr || applied.stdout}`)
487
+ }
488
+ await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn })
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Делегує LLM лише behavioral verification/fix у вже зібраному worktree.
494
+ * @param {object} args контекст
495
+ * @returns {Promise<string>} текст відповіді
496
+ */
497
+ async function finalizeBehavior({ runner, source, rationale, worktreeCwd, deps }) {
498
+ const prompt = [
499
+ `JS переніс ${source} на свіжий origin/main у ${worktreeCwd}.`,
500
+ `Очікувана користь: ${rationale}`,
501
+ 'Перевір реальний diff і call sites. Доведи лише перенесену поведінку до готовності:',
502
+ '- додай/онови regression test, якщо це bug fix;',
503
+ '- виконай найвужчі релевантні тести;',
504
+ '- виконай repository-required docs/change checks;',
505
+ '- не роби unrelated refactor або formatting churn.',
506
+ 'Не commit, не push, не створюй PR і не видаляй refs. Якщо поведінку неможливо безпечно підтвердити — нічого не маскуй, поверни чіткий blocker.'
507
+ ].join('\n\n')
508
+ const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
509
+ if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
510
+ return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
511
+ }
512
+
513
+ /**
514
+ * Створює один готовий PR. При будь-якому провалі worktree лишається для
515
+ * ручного відновлення; прибирається тільки після успішного gh pr create.
516
+ * @param {object} args параметри
517
+ * @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
518
+ */
519
+ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spawnFn, log }) {
520
+ const source = candidate.source
521
+ const validOids = new Set(candidate.commits?.map(commit => commit.oid) ?? [])
522
+ const commits = source.startsWith(SOURCE_BRANCH_PREFIX)
523
+ ? (group.commits ?? []).filter(oid => validOids.has(oid))
524
+ : []
525
+ if (source.startsWith(SOURCE_BRANCH_PREFIX) && commits.length === 0) {
526
+ return { status: 'kept', error: 'LLM не вибрала жодного валідного commit oid' }
527
+ }
528
+
529
+ const worktree = createReconcileWorktree(group.title, source, rootCwd, spawnFn)
530
+ log(`🌿 ${source} → ${worktree.branch}`)
531
+ try {
532
+ await applySource({
533
+ source,
534
+ sourceOid: candidate.oid,
535
+ commits,
536
+ runner,
537
+ rootCwd,
538
+ worktreeCwd: worktree.cwd,
539
+ deps,
540
+ spawnFn
541
+ })
542
+ const verification = await finalizeBehavior({
543
+ runner,
544
+ source,
545
+ rationale: group.rationale ?? candidate.rationale ?? '',
546
+ worktreeCwd: worktree.cwd,
547
+ deps
548
+ })
549
+ const unresolved = unresolvedFiles(worktree.cwd, spawnFn)
550
+ if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
551
+ git(['diff', '--check'], worktree.cwd, spawnFn)
552
+ git(['add', '-A'], worktree.cwd, spawnFn)
553
+ const staged = git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
554
+ allowFailure: true
555
+ }).status !== 0
556
+ if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
557
+ const ahead = Number(git(['rev-list', '--count', `${BASE_REF}..HEAD`], worktree.cwd, spawnFn).stdout.trim())
558
+ if (!ahead) throw new Error('Після reconciliation немає змін відносно origin/main')
559
+
560
+ const changelog = run(
561
+ 'npx',
562
+ ['@7n/rules', 'lint', 'changelog', '--no-fix'],
563
+ worktree.cwd,
564
+ spawnFn,
565
+ { allowFailure: true }
566
+ )
567
+ if (changelog.status !== 0) {
568
+ throw new Error(`changelog gate: ${changelog.stderr || changelog.stdout}`)
569
+ }
570
+ git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
571
+ git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
572
+
573
+ const body = [
574
+ `Джерело: \`${source}\`.`,
575
+ '',
576
+ group.rationale ?? candidate.rationale ?? 'Корисну поведінку перенесено на актуальний main.',
577
+ '',
578
+ 'Перевірки:',
579
+ '- `git diff --check origin/main...HEAD`',
580
+ '- `npx @7n/rules lint changelog --no-fix`',
581
+ verification ? `- LLM behavioral verification: ${verification.slice(0, 1000)}` : ''
582
+ ]
583
+ .filter(Boolean)
584
+ .join('\n')
585
+ const pr = run(
586
+ 'gh',
587
+ ['pr', 'create', '--base', 'main', '--head', worktree.branch, '--title', group.title, '--body', body],
588
+ worktree.cwd,
589
+ spawnFn
590
+ ).stdout.trim()
591
+ run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
592
+ allowFailure: true
593
+ })
594
+ return { status: 'pr-created', url: pr, branch: worktree.branch }
595
+ } catch (error) {
596
+ return {
597
+ status: 'failed',
598
+ error: error instanceof Error ? error.message : String(error),
599
+ branch: worktree.branch,
600
+ worktree: worktree.cwd
601
+ }
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Видаляє точний source після Git-доказу неактуальності або успішного
607
+ * перенесення. Protected/open-PR refs не потрапляють у цей крок.
608
+ * @param {object} candidate inventory source
609
+ * @param {string} rootCwd корінь репо
610
+ * @param {typeof spawnSync} spawnFn інжект
611
+ * @returns {{status:string,error?:string}} cleanup result
612
+ */
613
+ export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
614
+ try {
615
+ if (candidate.source.startsWith(SOURCE_STASH_PREFIX)) {
616
+ const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn).stdout
617
+ .split('\n')
618
+ .filter(Boolean)
619
+ .map(line => line.split('\0'))
620
+ const row = rows.find(([, oid]) => oid === candidate.oid)
621
+ if (!row) return { status: 'already-removed' }
622
+ git(['stash', 'drop', row[0]], rootCwd, spawnFn)
623
+ return { status: 'removed' }
624
+ }
625
+
626
+ for (const ref of candidate.aliases ?? [candidate.ref]) {
627
+ if (ref.startsWith('refs/remotes/origin/')) {
628
+ git(['push', 'origin', '--delete', branchName(ref)], rootCwd, spawnFn)
629
+ } else if (ref.startsWith('refs/heads/')) {
630
+ git(['branch', '-D', branchName(ref)], rootCwd, spawnFn)
631
+ }
632
+ }
633
+ return { status: 'removed' }
634
+ } catch (error) {
635
+ return {
636
+ status: 'cleanup-failed',
637
+ error: error instanceof Error ? error.message : String(error)
638
+ }
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Формує deterministic report.
644
+ * @param {{inventory:object,results:Array<object>}} args дані
645
+ * @returns {string} markdown
646
+ */
647
+ export function formatReport({ inventory, results }) {
648
+ const lines = ['## git-reconcile: підсумок']
649
+ for (const branch of inventory.branches) {
650
+ if (branch.state === 'review') continue
651
+ const suffix = branch.pr?.url ? ` — ${branch.pr.url}` : branch.worktree ? ` — ${branch.worktree}` : ''
652
+ const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}` : ''
653
+ lines.push(`- \`${branch.name}\`: ${branch.state}${cleanup}${suffix}`)
654
+ }
655
+ for (const result of results) {
656
+ const suffix = result.url
657
+ ? ` — ${result.url}`
658
+ : result.error
659
+ ? ` — ${result.error}`
660
+ : result.rationale
661
+ ? ` — ${result.rationale}`
662
+ : ''
663
+ const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}` : ''
664
+ lines.push(`- \`${result.source}\`: ${result.status}${cleanup}${suffix}`)
665
+ }
666
+ for (const warning of inventory.warnings) lines.push(`- ⚠️ ${warning}`)
667
+ return lines.join('\n')
668
+ }
669
+
670
+ /**
671
+ * JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
672
+ * Нічого не видаляє; `drop` є лише рекомендацією у звіті.
673
+ * @param {{cwd?:string,runner?:'pi'|'cursor'|'codex',task?:string,log?:(line:string)=>void,deps?:object}} [options] опції
674
+ * @returns {Promise<{ok:boolean,report:string,inventory:object,results:Array<object>}>} результат
675
+ */
676
+ export async function runGitReconcileOrchestrator(options = {}) {
677
+ const rootCwd = options.cwd ?? process.cwd()
678
+ const runner = options.runner ?? 'pi'
679
+ const task = options.task ?? ''
680
+ const log = options.log ?? (line => console.log(line))
681
+ const deps = options.deps ?? {}
682
+ const spawnFn = deps.spawnFn ?? spawnSync
683
+ const call = deps.callRunner ?? callRunner
684
+ const inventoryFn = deps.inventoryRepository ?? inventoryRepository
685
+ const createPr = deps.createPullRequest ?? createPullRequest
686
+ const cleanup = deps.cleanupSource ?? cleanupSource
687
+ const inventory = inventoryFn(rootCwd, { spawnFn })
688
+ const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
689
+ const decisions = []
690
+
691
+ for (let offset = 0; offset < candidates.length; offset += REVIEW_BATCH_SIZE) {
692
+ const batch = candidates.slice(offset, offset + REVIEW_BATCH_SIZE)
693
+ log(`🧠 semantic triage ${offset + 1}-${offset + batch.length}/${candidates.length}`)
694
+ const outcome = await call(runner, buildTriagePrompt(batch, task), rootCwd, deps)
695
+ if (!outcome.ok) {
696
+ for (const candidate of batch) {
697
+ decisions.push({
698
+ source: candidate.source,
699
+ action: 'keep',
700
+ rationale: `LLM triage failed: ${outcome.error}`,
701
+ groups: []
702
+ })
703
+ }
704
+ continue
705
+ }
706
+ const envelope = parseDecisionEnvelope(outcome.text)
707
+ const returned = Array.isArray(envelope?.decisions) ? envelope.decisions : []
708
+ const bySource = new Map(returned.map(decision => [decision.source, decision]))
709
+ for (const candidate of batch) {
710
+ const decision = bySource.get(candidate.source)
711
+ decisions.push(
712
+ decision && ['pr', 'keep', 'drop'].includes(decision.action)
713
+ ? decision
714
+ : { source: candidate.source, action: 'keep', rationale: 'Невалідна LLM-відповідь', groups: [] }
715
+ )
716
+ }
717
+ }
718
+
719
+ const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
720
+ const results = []
721
+ for (const decision of decisions) {
722
+ const candidate = bySource.get(decision.source)
723
+ if (!candidate) continue
724
+ if (decision.action !== 'pr') {
725
+ results.push({
726
+ source: decision.source,
727
+ status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
728
+ rationale: decision.rationale ?? ''
729
+ })
730
+ continue
731
+ }
732
+ const groups = Array.isArray(decision.groups) ? decision.groups : []
733
+ if (groups.length === 0) {
734
+ results.push({
735
+ source: decision.source,
736
+ status: 'kept',
737
+ error: 'LLM позначила pr без groups'
738
+ })
739
+ continue
740
+ }
741
+ for (const group of groups) {
742
+ results.push(
743
+ await createPr({
744
+ candidate: { ...candidate, rationale: decision.rationale },
745
+ group,
746
+ runner,
747
+ rootCwd,
748
+ deps,
749
+ spawnFn,
750
+ log
751
+ }).then(result => ({ source: decision.source, ...result }))
752
+ )
753
+ }
754
+ }
755
+
756
+ for (const branch of inventory.branches) {
757
+ if (['merged', 'patch-equivalent'].includes(branch.state) && !branch.worktree && !branch.pr) {
758
+ branch.cleanup = cleanup(branch, rootCwd, spawnFn)
759
+ }
760
+ }
761
+ for (const [source, candidate] of bySource) {
762
+ const sourceResults = results.filter(result => result.source === source)
763
+ const shouldRemove =
764
+ sourceResults.some(result => result.status === 'drop-recommended') ||
765
+ (sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created'))
766
+ if (shouldRemove) {
767
+ const cleanupResult = cleanup(candidate, rootCwd, spawnFn)
768
+ for (const result of sourceResults) result.cleanup = cleanupResult
769
+ }
770
+ }
771
+
772
+ const report = formatReport({ inventory, results })
773
+ log(report)
774
+ const ok = results.every(result => result.status !== 'failed')
775
+ return { ok, report, inventory, results }
776
+ }