@7n/rules 1.49.2 → 1.49.4

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.
@@ -1,13 +1,78 @@
1
1
  /** @see ./docs/orchestrate.md */
2
2
  import { spawnSync } from 'node:child_process'
3
- import { existsSync } from 'node:fs'
4
- import { join } from 'node:path'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { performance } from 'node:perf_hooks'
6
+
7
+ import { createProgressReporter } from '../../../scripts/lib/lint-surface/progress.mjs'
5
8
 
6
9
  const BASE_REF = 'origin/main'
10
+ const LLM_TIERS = ['min', 'max']
7
11
  const REVIEW_BATCH_SIZE = 10
8
12
  const PROMPT_TEXT_LIMIT = 12_000
9
13
  const SOURCE_BRANCH_PREFIX = 'branch:'
10
14
  const SOURCE_STASH_PREFIX = 'stash:'
15
+ const CONTENT_CONFLICT_RE = /^CONFLICT \(.+?\): Merge conflict in (.+)$/
16
+ const MODIFY_DELETE_CONFLICT_RE = /^CONFLICT \(modify\/delete\): (.+?) deleted in /
17
+ const REF_HEADS_RE = /^refs\/heads\//
18
+ const REF_ORIGIN_RE = /^refs\/remotes\/origin\//
19
+ const RENAME_DELETE_CONFLICT_RE = /^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/
20
+ const SOURCE_CODE_RE = /\.(?:js|mjs|ts|vue|rs|py)$/
21
+ const WHITESPACE_RE = /\s+/
22
+ const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '%(committer', 'date:iso-strict)'].join(
23
+ ''
24
+ )
25
+
26
+ /** Порожній callback для опційного progress log. */
27
+ function noop() {
28
+ // Навмисно порожньо: caller не запросив progress output.
29
+ }
30
+
31
+ /**
32
+ * Форматує elapsed time без удаваної точності.
33
+ * @param {number} startedAt початок у мілісекундах
34
+ * @param {() => number} now монотонне джерело часу
35
+ * @returns {string} коротка тривалість
36
+ */
37
+ function elapsedLabel(startedAt, now) {
38
+ const elapsed = Math.max(0, now() - startedAt)
39
+ return elapsed < 1000 ? `${Math.round(elapsed)} ms` : `${(elapsed / 1000).toFixed(1)} s`
40
+ }
41
+
42
+ /**
43
+ * Створює точний progress reporter для однієї фази. У TTY тримає живий bar,
44
+ * у non-TTY додає незмінювані рядки початку етапів і завершених одиниць.
45
+ * @param {object} args параметри фази
46
+ * @returns {{step:(key:string,detail:string,tier?:string)=>void,done:(key:string)=>void,stop:()=>void}} reporter
47
+ */
48
+ function createPhaseProgress(args) {
49
+ const { total, unitLabel, phase, log, isTTY, reporterFactory } = args
50
+ if (total === 0) return { step: noop, done: noop, stop: noop }
51
+
52
+ const baseLog = text => log(text.endsWith('\n') ? text.slice(0, -1) : text)
53
+ const reporter = reporterFactory({
54
+ total,
55
+ log: baseLog,
56
+ isTTY,
57
+ unitLabel,
58
+ withFixed: false
59
+ })
60
+ let lastNonTtyLabel = ''
61
+
62
+ return {
63
+ step: (_key, detail, tier) => {
64
+ const label = `${phase} · ${detail}`
65
+ reporter.concernStart(label, tier)
66
+ const rendered = tier ? `${label} · ${tier}` : label
67
+ if (!isTTY && rendered !== lastNonTtyLabel) {
68
+ log(`⏳ ${rendered}`)
69
+ lastNonTtyLabel = rendered
70
+ }
71
+ },
72
+ done: key => reporter.concernDone(key),
73
+ stop: () => reporter.stop()
74
+ }
75
+ }
11
76
 
12
77
  /**
13
78
  * Виконує команду без shell-інтерполяції.
@@ -19,11 +84,13 @@ const SOURCE_STASH_PREFIX = 'stash:'
19
84
  * @returns {{ status: number, stdout: string, stderr: string }} результат
20
85
  */
21
86
  function run(command, args, cwd, spawnFn, options = {}) {
87
+ const env = { ...process.env, GIT_EDITOR: 'true' }
88
+ if (command === 'npx') delete env.npm_config_package
22
89
  const result = spawnFn(command, args, {
23
90
  cwd,
24
91
  encoding: 'utf8',
25
92
  input: options.input,
26
- env: { ...process.env, GIT_EDITOR: 'true' },
93
+ env,
27
94
  maxBuffer: 16 * 1024 * 1024
28
95
  })
29
96
  const normalized = {
@@ -66,19 +133,30 @@ function parseJson(text, fallback) {
66
133
  }
67
134
 
68
135
  /**
69
- * Парсить `git worktree list --porcelain` у branch→path.
136
+ * Парсить branch refs і всі checkout HEAD OID, включно з detached worktree.
70
137
  * @param {string} text porcelain
71
- * @returns {Map<string, string>} повний ref гілки → checkout
138
+ * @returns {{branches:Map<string,string>,commits:Map<string,string>}} захищені checkout
72
139
  */
73
- export function parseWorktrees(text) {
74
- const out = new Map()
140
+ function parseWorktreeState(text) {
141
+ const branches = new Map()
142
+ const commits = new Map()
75
143
  let path = ''
76
144
  for (const line of text.split('\n')) {
77
145
  if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
78
- if (line.startsWith('branch ')) out.set(line.slice('branch '.length), path)
146
+ if (line.startsWith('HEAD ')) commits.set(line.slice('HEAD '.length), path)
147
+ if (line.startsWith('branch ')) branches.set(line.slice('branch '.length), path)
79
148
  if (line.length === 0) path = ''
80
149
  }
81
- return out
150
+ return { branches, commits }
151
+ }
152
+
153
+ /**
154
+ * Парсить `git worktree list --porcelain` у branch→path.
155
+ * @param {string} text porcelain
156
+ * @returns {Map<string, string>} повний ref гілки → checkout
157
+ */
158
+ export function parseWorktrees(text) {
159
+ return parseWorktreeState(text).branches
82
160
  }
83
161
 
84
162
  /**
@@ -87,7 +165,7 @@ export function parseWorktrees(text) {
87
165
  * @returns {string} коротке ім'я
88
166
  */
89
167
  function branchName(ref) {
90
- return ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '')
168
+ return ref.replace(REF_HEADS_RE, '').replace(REF_ORIGIN_RE, '')
91
169
  }
92
170
 
93
171
  /**
@@ -95,14 +173,15 @@ function branchName(ref) {
95
173
  * worktree-protection локального ref переноситься у запис.
96
174
  * @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
97
175
  * @param {Map<string,string>} worktrees branch→path
176
+ * @param {Map<string,string>} worktreeCommits checkout HEAD OID→path
98
177
  * @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
99
178
  */
100
- export function dedupeRefs(refs, worktrees) {
179
+ export function dedupeRefs(refs, worktrees, worktreeCommits = new Map()) {
101
180
  const byOid = new Map()
102
181
  for (const item of refs) {
103
182
  if (item.ref === 'refs/remotes/origin/HEAD' || branchName(item.ref) === 'main') continue
104
183
  const existing = byOid.get(item.oid)
105
- const worktree = worktrees.get(item.ref) ?? existing?.worktree ?? null
184
+ const worktree = worktrees.get(item.ref) ?? worktreeCommits.get(item.oid) ?? existing?.worktree ?? null
106
185
  const isRemote = item.ref.startsWith('refs/remotes/origin/')
107
186
  const aliases = [...new Set([...(existing?.aliases ?? []), item.ref])].toSorted()
108
187
  if (!existing || isRemote) {
@@ -114,7 +193,23 @@ export function dedupeRefs(refs, worktrees) {
114
193
  existing.aliases = aliases
115
194
  }
116
195
  }
117
- return [...byOid.values()].toSorted((a, b) => a.ref.localeCompare(b.ref))
196
+ return byOid
197
+ .values()
198
+ .toArray()
199
+ .toSorted((a, b) => a.ref.localeCompare(b.ref))
200
+ }
201
+
202
+ /**
203
+ * Класифікує branch лише за вже зібраними Git-фактами.
204
+ * @param {{merged:boolean,novelCommitIds:string[],pr:object|null,worktree:string|null}} facts факти
205
+ * @returns {string} state
206
+ */
207
+ function branchState({ merged, novelCommitIds, pr, worktree }) {
208
+ if (merged) return 'merged'
209
+ if (novelCommitIds.length === 0) return 'patch-equivalent'
210
+ if (pr) return 'open-pr'
211
+ if (worktree) return 'protected'
212
+ return 'review'
118
213
  }
119
214
 
120
215
  /**
@@ -138,13 +233,13 @@ function openPullRequests(cwd, spawnFn) {
138
233
 
139
234
  /**
140
235
  * Збирає compact commit metadata лише для patch-унікальних non-merge комітів.
141
- * @param {string[]} oids commit ids
236
+ * @param {string[]} commitIds commit ids
142
237
  * @param {string} cwd корінь
143
238
  * @param {typeof spawnSync} spawnFn інжект
144
239
  * @returns {Array<{oid:string,subject:string}>} коміти
145
240
  */
146
- function commitMetadata(oids, cwd, spawnFn) {
147
- return oids.map(oid => {
241
+ function commitMetadata(commitIds, cwd, spawnFn) {
242
+ return commitIds.map(oid => {
148
243
  const subject = git(['show', '-s', '--format=%s', oid], cwd, spawnFn).stdout.trim()
149
244
  return { oid, subject }
150
245
  })
@@ -158,9 +253,9 @@ function commitMetadata(oids, cwd, spawnFn) {
158
253
  export function conflictFiles(text) {
159
254
  const files = new Set()
160
255
  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 /)
256
+ const content = line.match(CONTENT_CONFLICT_RE)
257
+ const rename = line.match(RENAME_DELETE_CONFLICT_RE)
258
+ const modifyDelete = line.match(MODIFY_DELETE_CONFLICT_RE)
164
259
  const path = content?.[1] ?? rename?.[1] ?? modifyDelete?.[1]
165
260
  if (path) files.add(path)
166
261
  }
@@ -179,25 +274,21 @@ export function inventoryRepository(cwd, deps = {}) {
179
274
  git(['fetch', '--prune', 'origin'], cwd, spawnFn)
180
275
  git(['rev-parse', '--verify', BASE_REF], cwd, spawnFn)
181
276
 
182
- const worktrees = parseWorktrees(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
277
+ const worktreeState = parseWorktreeState(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
183
278
  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
- ],
279
+ ['for-each-ref', `--format=${REF_INVENTORY_FORMAT}`, 'refs/heads', 'refs/remotes/origin'],
190
280
  cwd,
191
281
  spawnFn
192
- ).stdout
193
- .split('\n')
282
+ )
283
+ .stdout.split('\n')
194
284
  .filter(Boolean)
195
285
  const refs = dedupeRefs(
196
286
  refLines.map(line => {
197
287
  const [ref, oid, date] = line.split('\0')
198
288
  return { ref, oid, date }
199
289
  }),
200
- worktrees
290
+ worktreeState.branches,
291
+ worktreeState.commits
201
292
  )
202
293
  const prs = openPullRequests(cwd, spawnFn)
203
294
  const warnings = []
@@ -205,44 +296,31 @@ export function inventoryRepository(cwd, deps = {}) {
205
296
 
206
297
  const branches = refs.map(item => {
207
298
  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
299
+ const merged =
300
+ git(['merge-base', '--is-ancestor', item.ref, BASE_REF], cwd, spawnFn, {
301
+ allowFailure: true
302
+ }).status === 0
303
+ const novelCommitIds = merged
212
304
  ? []
213
- : git(
214
- ['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`],
215
- cwd,
216
- spawnFn
217
- ).stdout
218
- .split('\n')
305
+ : git(['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
306
+ .stdout.split('\n')
219
307
  .filter(Boolean)
220
308
  .toReversed()
221
309
  const counts = git(['rev-list', '--left-right', '--count', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
222
310
  .stdout.trim()
223
- .split(/\s+/)
311
+ .split(WHITESPACE_RE)
224
312
  .map(Number)
225
313
  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'
314
+ const state = branchState({ merged, novelCommitIds, pr, worktree: item.worktree })
235
315
  const changedFiles =
236
316
  state === 'review'
237
- ? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn).stdout
238
- .split('\n')
317
+ ? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
318
+ .stdout.split('\n')
239
319
  .filter(Boolean)
240
320
  .slice(0, 200)
241
321
  : []
242
322
  const mergeTree =
243
- state === 'review'
244
- ? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout
245
- : ''
323
+ state === 'review' ? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout : ''
246
324
  return {
247
325
  source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
248
326
  ref: item.ref,
@@ -254,20 +332,16 @@ export function inventoryRepository(cwd, deps = {}) {
254
332
  pr,
255
333
  behind: counts[0] ?? 0,
256
334
  ahead: counts[1] ?? 0,
257
- commits: commitMetadata(novelOids, cwd, spawnFn),
335
+ commits: commitMetadata(novelCommitIds, cwd, spawnFn),
258
336
  changedFiles,
259
337
  conflicts: conflictFiles(mergeTree)
260
338
  }
261
339
  })
262
340
 
263
- const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout
264
- .split('\n')
265
- .filter(Boolean)
341
+ const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
266
342
  const stashes = stashRows.map(line => {
267
343
  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)
344
+ const changedFiles = git(['stash', 'show', '--name-status', ref], cwd, spawnFn).stdout.split('\n').filter(Boolean)
271
345
  return {
272
346
  source: `${SOURCE_STASH_PREFIX}${ref}`,
273
347
  ref,
@@ -312,7 +386,12 @@ export function buildTriagePrompt(candidates, task = '') {
312
386
  */
313
387
  export function parseDecisionEnvelope(text) {
314
388
  const trimmed = text.trim()
315
- const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
389
+ let unfenced = trimmed
390
+ if (unfenced.startsWith('```')) {
391
+ const firstLineEnd = unfenced.indexOf('\n')
392
+ unfenced = firstLineEnd === -1 ? '' : unfenced.slice(firstLineEnd + 1)
393
+ if (unfenced.endsWith('```')) unfenced = unfenced.slice(0, -3).trim()
394
+ }
316
395
  const first = unfenced.indexOf('{')
317
396
  const last = unfenced.lastIndexOf('}')
318
397
  if (first === -1 || last <= first) return null
@@ -326,9 +405,10 @@ export function parseDecisionEnvelope(text) {
326
405
  * @param {string} prompt промпт
327
406
  * @param {string} cwd робочий каталог
328
407
  * @param {object} deps інжекти
408
+ * @param {'min'|'max'} [tier] model tier
329
409
  * @returns {Promise<{ok:boolean,text:string,error:string|null}>} результат
330
410
  */
331
- export async function callRunner(runner, prompt, cwd, deps = {}) {
411
+ export async function callRunner(runner, prompt, cwd, deps = {}, tier = 'max') {
332
412
  if (runner === 'pi') {
333
413
  let runAgentSkill = deps.runAgentSkill
334
414
  if (!runAgentSkill) {
@@ -338,7 +418,7 @@ export async function callRunner(runner, prompt, cwd, deps = {}) {
338
418
  let text = ''
339
419
  const result = await runAgentSkill(prompt, {
340
420
  skillId: 'git-reconcile',
341
- tier: 'max',
421
+ tier,
342
422
  cwd,
343
423
  deps: { out: chunk => (text += chunk) }
344
424
  })
@@ -351,13 +431,145 @@ export async function callRunner(runner, prompt, cwd, deps = {}) {
351
431
  runAcpAgent = module.runAcpAgent
352
432
  }
353
433
  try {
354
- const text = await runAcpAgent(runner, prompt, cwd, { tier: 'max' })
434
+ const text = await runAcpAgent(runner, prompt, cwd, { tier })
355
435
  return { ok: true, text, error: null }
356
436
  } catch (error) {
357
437
  return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
358
438
  }
359
439
  }
360
440
 
441
+ /**
442
+ * Виконує bounded LLM-крок через min, валідовує результат JS-функцією і
443
+ * викликає max лише після конкретного провалу.
444
+ * @param {object} args параметри
445
+ * @returns {Promise<{ok:boolean,text:string,error:string|null,tier:'min'|'max',validation?:object,attempts:Array<object>}>} результат
446
+ */
447
+ export async function callWithValidatedFallback(args) {
448
+ const { runner, prompt, cwd, validate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
449
+ const call = deps.callRunner ?? callRunner
450
+ const attempts = []
451
+ let retryPrompt = prompt
452
+
453
+ for (const tier of LLM_TIERS) {
454
+ onAttempt({ label, tier })
455
+ const outcome = await call(runner, retryPrompt, cwd, deps, tier)
456
+ if (!outcome.ok) {
457
+ const validation = { ok: false, error: outcome.error ?? `${tier} runner failed` }
458
+ attempts.push({ tier, ok: false, validation })
459
+ return { ...outcome, tier, validation, attempts }
460
+ }
461
+ const validation = await validate(outcome)
462
+ attempts.push({ tier, ok: outcome.ok, validation })
463
+ if (validation.ok) {
464
+ return { ...outcome, tier, validation, attempts }
465
+ }
466
+ if (tier === 'min') {
467
+ const reason = (validation.error ?? outcome.error ?? 'невідома помилка validation').slice(0, PROMPT_TEXT_LIMIT)
468
+ log(`↗ ${label}: min не пройшов validation (${reason}); повторюю на max`)
469
+ retryPrompt = [
470
+ prompt,
471
+ `Попередня min-спроба не пройшла deterministic validation: ${reason}.`,
472
+ 'Виправ причину validation; не розширюй scope.'
473
+ ].join('\n\n')
474
+ } else {
475
+ return {
476
+ ok: false,
477
+ text: outcome.text,
478
+ error: validation.error ?? outcome.error ?? 'max validation failed',
479
+ tier,
480
+ validation,
481
+ attempts
482
+ }
483
+ }
484
+ }
485
+
486
+ throw new Error('Недосяжний стан LLM fallback')
487
+ }
488
+
489
+ /**
490
+ * Перевіряє branch groups та їх commit OID.
491
+ * @param {object} decision LLM decision
492
+ * @param {object} candidate inventory candidate
493
+ * @returns {string|null} validation error
494
+ */
495
+ function validateBranchGroups(decision, candidate) {
496
+ const validCommitIds = new Set(candidate.commits?.map(commit => commit.oid))
497
+ const selectedCommitIds = new Set()
498
+ for (const group of decision.groups) {
499
+ if (!Array.isArray(group.commits) || group.commits.length === 0) {
500
+ return `branch group без commits для ${decision.source}`
501
+ }
502
+ if (group.commits.some(oid => !validCommitIds.has(oid))) {
503
+ return `branch group містить невідомий commit для ${decision.source}`
504
+ }
505
+ if (group.commits.some(oid => selectedCommitIds.has(oid))) {
506
+ return `commit повторюється між groups для ${decision.source}`
507
+ }
508
+ for (const oid of group.commits) selectedCommitIds.add(oid)
509
+ }
510
+ return null
511
+ }
512
+
513
+ /**
514
+ * Перевіряє groups одного PR-рішення.
515
+ * @param {object} decision LLM decision
516
+ * @param {object} candidate inventory candidate
517
+ * @returns {string|null} validation error
518
+ */
519
+ function validatePrDecision(decision, candidate) {
520
+ if (!Array.isArray(decision.groups) || decision.groups.length === 0) {
521
+ return `pr без groups для ${decision.source}`
522
+ }
523
+ if (decision.groups.some(group => typeof group.title !== 'string' || group.title.trim().length === 0)) {
524
+ return `pr group без title для ${decision.source}`
525
+ }
526
+ if (!decision.source.startsWith(SOURCE_BRANCH_PREFIX)) {
527
+ return decision.groups.length === 1 ? null : `неподільний stash має містити рівно одну pr group: ${decision.source}`
528
+ }
529
+ return validateBranchGroups(decision, candidate)
530
+ }
531
+
532
+ /**
533
+ * Перевіряє одне triage-рішення.
534
+ * @param {object} decision LLM decision
535
+ * @param {object|undefined} candidate inventory candidate
536
+ * @param {Set<string>} seen уже оброблені source
537
+ * @returns {string|null} validation error
538
+ */
539
+ function validateDecision(decision, candidate, seen) {
540
+ if (!candidate || seen.has(decision.source)) {
541
+ return `невідомий або дубльований source: ${decision.source}`
542
+ }
543
+ seen.add(decision.source)
544
+ if (!['pr', 'keep', 'drop'].includes(decision.action)) {
545
+ return `невалідний action для ${decision.source}`
546
+ }
547
+ return decision.action === 'pr' ? validatePrDecision(decision, candidate) : null
548
+ }
549
+
550
+ /**
551
+ * Структурно перевіряє triage-рішення: рівно один verdict на candidate,
552
+ * валідні actions/groups і лише відомі commit OID.
553
+ * @param {{text:string}} outcome LLM output
554
+ * @param {Array<object>} candidates batch
555
+ * @returns {{ok:boolean,error?:string,value?:object}} validation
556
+ */
557
+ export function validateTriageOutcome(outcome, candidates) {
558
+ const envelope = parseDecisionEnvelope(outcome.text)
559
+ if (!Array.isArray(envelope?.decisions)) return { ok: false, error: 'відсутній decisions array' }
560
+ if (envelope.decisions.length !== candidates.length) {
561
+ return { ok: false, error: 'кількість decisions не збігається з candidates' }
562
+ }
563
+
564
+ const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
565
+ const seen = new Set()
566
+ for (const decision of envelope.decisions) {
567
+ const error = validateDecision(decision, bySource.get(decision.source), seen)
568
+ if (error) return { ok: false, error }
569
+ }
570
+ return { ok: true, value: envelope }
571
+ }
572
+
361
573
  /**
362
574
  * Перетворює довільний title/ref на branch slug.
363
575
  * @param {string} value title/ref
@@ -423,9 +635,191 @@ function createReconcileWorktree(title, source, cwd, spawnFn) {
423
635
  * @returns {string[]} шляхи
424
636
  */
425
637
  function unresolvedFiles(cwd, spawnFn) {
426
- return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout
427
- .split('\n')
428
- .filter(Boolean)
638
+ return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
639
+ }
640
+
641
+ /**
642
+ * Пропускає лише підтверджений empty cherry-pick: sequencer активний,
643
+ * конфліктів немає, staged diff порожній.
644
+ * @param {string} cwd worktree
645
+ * @param {typeof spawnSync} spawnFn інжект
646
+ * @returns {boolean} чи виконано cherry-pick --skip
647
+ */
648
+ export function skipEmptyCherryPick(cwd, spawnFn = spawnSync) {
649
+ const inProgress =
650
+ git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], cwd, spawnFn, {
651
+ allowFailure: true
652
+ }).status === 0
653
+ if (!inProgress || unresolvedFiles(cwd, spawnFn).length > 0) return false
654
+ const stagedEmpty =
655
+ git(['diff', '--cached', '--quiet'], cwd, spawnFn, {
656
+ allowFailure: true
657
+ }).status === 0
658
+ if (!stagedEmpty) return false
659
+ git(['cherry-pick', '--skip'], cwd, spawnFn)
660
+ return true
661
+ }
662
+
663
+ /**
664
+ * Перевіряє, що agentic-крок залишив Git у консистентному стані.
665
+ * @param {string} cwd worktree
666
+ * @param {typeof spawnSync} spawnFn інжект
667
+ * @returns {{ok:boolean,error?:string}} validation
668
+ */
669
+ function validateGitState(cwd, spawnFn) {
670
+ const unresolved = unresolvedFiles(cwd, spawnFn)
671
+ if (unresolved.length > 0) {
672
+ return { ok: false, error: `нерозв'язані конфлікти: ${unresolved.join(', ')}` }
673
+ }
674
+ const diffCheck = git(['diff', '--check'], cwd, spawnFn, { allowFailure: true })
675
+ if (diffCheck.status !== 0) {
676
+ return { ok: false, error: `git diff --check: ${diffCheck.stderr || diffCheck.stdout}` }
677
+ }
678
+ return { ok: true }
679
+ }
680
+
681
+ /** Межі стабільного Vitest failure identifier. */
682
+ const ANSI_ESCAPE = String.fromCodePoint(27)
683
+ const TEST_FAILURE_PREFIX = 'FAIL '
684
+
685
+ /**
686
+ * Витягає стабільні Vitest failure identifiers без summary/timing.
687
+ * @param {string} output stdout + stderr
688
+ * @returns {Set<string>} suite/test failures
689
+ */
690
+ export function testFailureSignatures(output) {
691
+ const signatures = new Set()
692
+ for (const line of output.split('\n')) {
693
+ const start = line.indexOf(TEST_FAILURE_PREFIX)
694
+ if (start === -1) continue
695
+ const signature = line
696
+ .slice(start + TEST_FAILURE_PREFIX.length)
697
+ .split(ANSI_ESCAPE, 1)[0]
698
+ .trim()
699
+ if (signature) signatures.add(signature)
700
+ }
701
+ return signatures
702
+ }
703
+
704
+ /**
705
+ * Дозволяє red baseline лише якщо після перенесення не з'явилось нових
706
+ * Vitest failures. Нерозпізнаний red output завжди fail-closed.
707
+ * @param {{status:number,stdout:string,stderr:string}|null} baseline до змін
708
+ * @param {{status:number,stdout:string,stderr:string}} current після змін
709
+ * @returns {boolean} чи test gate пройдено
710
+ */
711
+ export function acceptsTestOutcome(baseline, current) {
712
+ if (current.status === 0) return true
713
+ if (!baseline || baseline.status === 0) return false
714
+ const before = testFailureSignatures(`${baseline.stdout}\n${baseline.stderr}`)
715
+ const after = testFailureSignatures(`${current.stdout}\n${current.stderr}`)
716
+ return before.size > 0 && after.size > 0 && [...after].every(signature => before.has(signature))
717
+ }
718
+
719
+ /**
720
+ * Зводить змінені code paths до найвужчих директорій для scoped gates.
721
+ * @param {string[]} paths relative paths
722
+ * @returns {string[]} унікальні sorted directories
723
+ */
724
+ export function sourceDirectories(paths) {
725
+ return [...new Set(paths.filter(path => SOURCE_CODE_RE.test(path)).map(path => dirname(path)))].toSorted()
726
+ }
727
+
728
+ /**
729
+ * Збирає tracked і untracked code directories відносно origin/main.
730
+ * @param {string} cwd worktree
731
+ * @param {typeof spawnSync} spawnFn інжект
732
+ * @returns {string[]} directories
733
+ */
734
+ function changedSourceDirectories(cwd, spawnFn) {
735
+ const tracked = git(['diff', '--name-only', BASE_REF, '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
736
+ 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)))
738
+ }
739
+
740
+ /**
741
+ * Генерує docs і запускає unified read-only lint лише в директоріях
742
+ * зміненого коду, не торкаючись repository-wide baseline.
743
+ * @param {string} cwd worktree
744
+ * @param {typeof spawnSync} spawnFn інжект
745
+ * @returns {{ok:boolean,error?:string}} gate
746
+ */
747
+ function validateScopedProjectGates(cwd, spawnFn) {
748
+ for (const path of changedSourceDirectories(cwd, spawnFn)) {
749
+ const docs = run('npx', ['@7n/rules', 'lint', 'doc-files', '--path', path], cwd, spawnFn, {
750
+ allowFailure: true
751
+ })
752
+ if (docs.status !== 0) {
753
+ return { ok: false, error: `doc-files (${path}): ${docs.stderr || docs.stdout}` }
754
+ }
755
+ const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
756
+ allowFailure: true
757
+ })
758
+ if (lint.status !== 0) {
759
+ return { ok: false, error: `scoped lint (${path}): ${lint.stderr || lint.stdout}` }
760
+ }
761
+ }
762
+ return { ok: true }
763
+ }
764
+
765
+ /**
766
+ * Встановлює frozen Bun dependencies, якщо новий worktree їх ще не має.
767
+ * @param {string} cwd worktree
768
+ * @param {typeof spawnSync} spawnFn інжект
769
+ */
770
+ function ensureWorktreeDependencies(cwd, spawnFn) {
771
+ if (!existsSync(join(cwd, 'package.json')) || !existsSync(join(cwd, 'bun.lock'))) return
772
+ if (existsSync(join(cwd, 'node_modules'))) return
773
+ run('bun', ['install', '--frozen-lockfile'], cwd, spawnFn)
774
+ }
775
+
776
+ /**
777
+ * Фіксує test baseline на чистому origin/main до перенесення source.
778
+ * @param {string} cwd worktree
779
+ * @param {typeof spawnSync} spawnFn інжект
780
+ * @returns {{tests:{status:number,stdout:string,stderr:string}|null}} baseline
781
+ */
782
+ export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
783
+ ensureWorktreeDependencies(cwd, spawnFn)
784
+ const packageJsonPath = join(cwd, 'package.json')
785
+ if (!existsSync(packageJsonPath)) return { tests: null }
786
+ const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
787
+ const tests = packageJson?.scripts?.test ? run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true }) : null
788
+ return { tests }
789
+ }
790
+
791
+ /**
792
+ * Додає до Git-state validation test script із репозиторію і changelog gate.
793
+ * Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
794
+ * @param {string} cwd worktree
795
+ * @param {typeof spawnSync} spawnFn інжект
796
+ * @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан origin/main
797
+ * @returns {{ok:boolean,error?:string}} validation
798
+ */
799
+ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null) {
800
+ const gitState = validateGitState(cwd, spawnFn)
801
+ if (!gitState.ok) return gitState
802
+ const projectGates = validateScopedProjectGates(cwd, spawnFn)
803
+ if (!projectGates.ok) return projectGates
804
+ const postGateGitState = validateGitState(cwd, spawnFn)
805
+ if (!postGateGitState.ok) return postGateGitState
806
+
807
+ const packageJsonPath = join(cwd, 'package.json')
808
+ if (existsSync(packageJsonPath)) {
809
+ const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
810
+ if (packageJson?.scripts?.test) {
811
+ const tests = run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true })
812
+ if (!acceptsTestOutcome(baseline?.tests ?? null, tests)) {
813
+ return { ok: false, error: `bun run test: ${tests.stderr || tests.stdout}` }
814
+ }
815
+ }
816
+ }
817
+
818
+ const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
819
+ if (changelog.status !== 0) {
820
+ return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
821
+ }
822
+ return { ok: true }
429
823
  }
430
824
 
431
825
  /**
@@ -433,20 +827,28 @@ function unresolvedFiles(cwd, spawnFn) {
433
827
  * @param {object} args контекст
434
828
  * @returns {Promise<void>}
435
829
  */
436
- async function resolveConflict({ runner, source, worktreeCwd, deps, spawnFn }) {
830
+ async function resolveConflict(args) {
831
+ const { runner, source, worktreeCwd, deps, spawnFn, log, onProgress = noop } = args
437
832
  const unresolved = unresolvedFiles(worktreeCwd, spawnFn)
438
833
  const prompt = [
439
834
  `У worktree ${worktreeCwd} JS уже застосував ${source} до свіжого origin/main.`,
440
835
  `Розв'яжи лише змістові конфлікти: ${unresolved.join(', ')}.`,
441
- 'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs всліпу.',
836
+ 'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs механічно.',
442
837
  'Збережи актуальну поведінку main, перенеси лише відсутню корисну частину.',
443
838
  'За потреби онови regression test. Не commit, не push, не створюй PR і не видаляй refs.',
444
- 'Наприкінці прибери conflict markers і коротко переліч перевірки.'
839
+ 'Наприкінці прибери conflict markers і коротко наведи виконані перевірки.'
445
840
  ].join('\n\n')
446
- const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
841
+ const outcome = await callWithValidatedFallback({
842
+ runner,
843
+ prompt,
844
+ cwd: worktreeCwd,
845
+ deps,
846
+ log,
847
+ label: `conflict ${source}`,
848
+ onAttempt: ({ tier }) => onProgress('resolve conflict', tier),
849
+ validate: () => validateGitState(worktreeCwd, spawnFn)
850
+ })
447
851
  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
852
  git(['add', '-A'], worktreeCwd, spawnFn)
451
853
  }
452
854
 
@@ -455,15 +857,17 @@ async function resolveConflict({ runner, source, worktreeCwd, deps, spawnFn }) {
455
857
  * @param {object} args контекст
456
858
  * @returns {Promise<void>}
457
859
  */
458
- async function applySource({ source, sourceOid, commits, runner, rootCwd, worktreeCwd, deps, spawnFn }) {
860
+ async function applySource(args) {
861
+ const { source, sourceOid, commits, runner, rootCwd, worktreeCwd, deps, spawnFn, log, onProgress = noop } = args
459
862
  if (source.startsWith(SOURCE_BRANCH_PREFIX)) {
460
863
  for (const oid of commits) {
461
864
  const result = git(['cherry-pick', oid], worktreeCwd, spawnFn, { allowFailure: true })
462
865
  if (result.status === 0) continue
463
866
  if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
867
+ if (skipEmptyCherryPick(worktreeCwd, spawnFn)) continue
464
868
  throw new Error(`cherry-pick ${oid}: ${result.stderr || result.stdout}`)
465
869
  }
466
- await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn })
870
+ await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn, log, onProgress })
467
871
  if (
468
872
  git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], worktreeCwd, spawnFn, {
469
873
  allowFailure: true
@@ -485,7 +889,7 @@ async function applySource({ source, sourceOid, commits, runner, rootCwd, worktr
485
889
  if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
486
890
  throw new Error(`git apply ${stashRef}: ${applied.stderr || applied.stdout}`)
487
891
  }
488
- await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn })
892
+ await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn, log, onProgress })
489
893
  }
490
894
  }
491
895
 
@@ -494,18 +898,28 @@ async function applySource({ source, sourceOid, commits, runner, rootCwd, worktr
494
898
  * @param {object} args контекст
495
899
  * @returns {Promise<string>} текст відповіді
496
900
  */
497
- async function finalizeBehavior({ runner, source, rationale, worktreeCwd, deps }) {
901
+ async function finalizeBehavior(args) {
902
+ const { runner, source, rationale, worktreeCwd, baseline, deps, spawnFn, log, onProgress = noop } = args
498
903
  const prompt = [
499
904
  `JS переніс ${source} на свіжий origin/main у ${worktreeCwd}.`,
500
905
  `Очікувана користь: ${rationale}`,
501
906
  'Перевір реальний diff і call sites. Доведи лише перенесену поведінку до готовності:',
502
907
  '- додай/онови regression test, якщо це bug fix;',
503
908
  '- виконай найвужчі релевантні тести;',
504
- '- виконай repository-required docs/change checks;',
505
909
  '- не роби unrelated refactor або formatting churn.',
910
+ 'Не запускай full repository tests, doc generation, lint або changelog: після твоїх правок це детерміновано виконає JS.',
506
911
  'Не commit, не push, не створюй PR і не видаляй refs. Якщо поведінку неможливо безпечно підтвердити — нічого не маскуй, поверни чіткий blocker.'
507
912
  ].join('\n\n')
508
- const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
913
+ const outcome = await callWithValidatedFallback({
914
+ runner,
915
+ prompt,
916
+ cwd: worktreeCwd,
917
+ deps,
918
+ log,
919
+ label: `behavior ${source}`,
920
+ onAttempt: ({ tier }) => onProgress('behavior validation', tier),
921
+ validate: () => validateBehaviorState(worktreeCwd, spawnFn, baseline)
922
+ })
509
923
  if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
510
924
  return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
511
925
  }
@@ -516,19 +930,24 @@ async function finalizeBehavior({ runner, source, rationale, worktreeCwd, deps }
516
930
  * @param {object} args параметри
517
931
  * @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
518
932
  */
519
- async function createPullRequest({ candidate, group, runner, rootCwd, deps, spawnFn, log }) {
933
+ async function createPullRequest(args) {
934
+ const { candidate, group, runner, rootCwd, deps, spawnFn, log, onProgress = noop } = args
520
935
  const source = candidate.source
521
- const validOids = new Set(candidate.commits?.map(commit => commit.oid) ?? [])
936
+ const validCommitIds = new Set(candidate.commits?.map(commit => commit.oid))
522
937
  const commits = source.startsWith(SOURCE_BRANCH_PREFIX)
523
- ? (group.commits ?? []).filter(oid => validOids.has(oid))
938
+ ? (group.commits ?? []).filter(oid => validCommitIds.has(oid))
524
939
  : []
525
940
  if (source.startsWith(SOURCE_BRANCH_PREFIX) && commits.length === 0) {
526
941
  return { status: 'kept', error: 'LLM не вибрала жодного валідного commit oid' }
527
942
  }
528
943
 
944
+ onProgress('worktree')
529
945
  const worktree = createReconcileWorktree(group.title, source, rootCwd, spawnFn)
530
946
  log(`🌿 ${source} → ${worktree.branch}`)
531
947
  try {
948
+ onProgress('baseline tests')
949
+ const baseline = captureBehaviorBaseline(worktree.cwd, spawnFn)
950
+ onProgress('apply')
532
951
  await applySource({
533
952
  source,
534
953
  sourceOid: candidate.oid,
@@ -537,39 +956,45 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
537
956
  rootCwd,
538
957
  worktreeCwd: worktree.cwd,
539
958
  deps,
540
- spawnFn
959
+ spawnFn,
960
+ log,
961
+ onProgress
541
962
  })
542
963
  const verification = await finalizeBehavior({
543
964
  runner,
544
965
  source,
545
966
  rationale: group.rationale ?? candidate.rationale ?? '',
546
967
  worktreeCwd: worktree.cwd,
547
- deps
968
+ baseline,
969
+ deps,
970
+ spawnFn,
971
+ log,
972
+ onProgress
548
973
  })
974
+ onProgress('Git validation')
549
975
  const unresolved = unresolvedFiles(worktree.cwd, spawnFn)
550
976
  if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
551
977
  git(['diff', '--check'], worktree.cwd, spawnFn)
552
978
  git(['add', '-A'], worktree.cwd, spawnFn)
553
- const staged = git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
554
- allowFailure: true
555
- }).status !== 0
979
+ const staged =
980
+ git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
981
+ allowFailure: true
982
+ }).status !== 0
556
983
  if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
557
984
  const ahead = Number(git(['rev-list', '--count', `${BASE_REF}..HEAD`], worktree.cwd, spawnFn).stdout.trim())
558
985
  if (!ahead) throw new Error('Після reconciliation немає змін відносно origin/main')
559
986
 
560
- const changelog = run(
561
- 'npx',
562
- ['@7n/rules', 'lint', 'changelog', '--no-fix'],
563
- worktree.cwd,
564
- spawnFn,
565
- { allowFailure: true }
566
- )
987
+ const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], worktree.cwd, spawnFn, {
988
+ allowFailure: true
989
+ })
567
990
  if (changelog.status !== 0) {
568
991
  throw new Error(`changelog gate: ${changelog.stderr || changelog.stdout}`)
569
992
  }
570
993
  git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
994
+ onProgress('push')
571
995
  git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
572
996
 
997
+ onProgress('create PR')
573
998
  const body = [
574
999
  `Джерело: \`${source}\`.`,
575
1000
  '',
@@ -588,6 +1013,7 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
588
1013
  worktree.cwd,
589
1014
  spawnFn
590
1015
  ).stdout.trim()
1016
+ onProgress('remove worktree')
591
1017
  run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
592
1018
  allowFailure: true
593
1019
  })
@@ -613,8 +1039,8 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
613
1039
  export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
614
1040
  try {
615
1041
  if (candidate.source.startsWith(SOURCE_STASH_PREFIX)) {
616
- const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn).stdout
617
- .split('\n')
1042
+ const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn)
1043
+ .stdout.split('\n')
618
1044
  .filter(Boolean)
619
1045
  .map(line => line.split('\0'))
620
1046
  const row = rows.find(([, oid]) => oid === candidate.oid)
@@ -648,19 +1074,20 @@ export function formatReport({ inventory, results }) {
648
1074
  const lines = ['## git-reconcile: підсумок']
649
1075
  for (const branch of inventory.branches) {
650
1076
  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}` : ''
1077
+ let suffix = ''
1078
+ if (branch.pr?.url) suffix = ` ${branch.pr.url}`
1079
+ else if (branch.worktree) suffix = ` — ${branch.worktree}`
1080
+ const cleanupOid = branch.oid ? `; oid=${branch.oid}` : ''
1081
+ const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}${cleanupOid}` : ''
653
1082
  lines.push(`- \`${branch.name}\`: ${branch.state}${cleanup}${suffix}`)
654
1083
  }
655
1084
  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}` : ''
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}`
1089
+ const cleanupOid = result.oid ? `; oid=${result.oid}` : ''
1090
+ const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}${cleanupOid}` : ''
664
1091
  lines.push(`- \`${result.source}\`: ${result.status}${cleanup}${suffix}`)
665
1092
  }
666
1093
  for (const warning of inventory.warnings) lines.push(`- ⚠️ ${warning}`)
@@ -668,109 +1095,309 @@ export function formatReport({ inventory, results }) {
668
1095
  }
669
1096
 
670
1097
  /**
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>}>} результат
1098
+ * Повертає fail-closed keep-рішення для всього batch.
1099
+ * @param {Array<object>} batch candidates
1100
+ * @param {string} error validation/runner error
1101
+ * @returns {Array<object>} keep decisions
675
1102
  */
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 = []
1103
+ function failedTriageDecisions(batch, error) {
1104
+ return batch.map(candidate => ({
1105
+ source: candidate.source,
1106
+ action: 'keep',
1107
+ rationale: `LLM triage failed: ${error}`,
1108
+ incomplete: true,
1109
+ groups: []
1110
+ }))
1111
+ }
690
1112
 
1113
+ /**
1114
+ * Виконує min→validation→max triage по bounded batches.
1115
+ * @param {object} args orchestration context
1116
+ * @returns {Promise<Array<object>>} decisions
1117
+ */
1118
+ async function triageCandidates(args) {
1119
+ const { candidates, runner, task, rootCwd, deps, log, progress } = args
1120
+ const decisions = []
691
1121
  for (let offset = 0; offset < candidates.length; offset += REVIEW_BATCH_SIZE) {
692
1122
  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
- )
1123
+ const key = `triage-${offset}`
1124
+ const detail = `${offset + 1}-${offset + batch.length}/${candidates.length}`
1125
+ progress.step(key, detail)
1126
+ let outcome
1127
+ try {
1128
+ outcome = await callWithValidatedFallback({
1129
+ runner,
1130
+ prompt: buildTriagePrompt(batch, task),
1131
+ cwd: rootCwd,
1132
+ deps,
1133
+ log,
1134
+ label: `triage ${detail}`,
1135
+ onAttempt: ({ tier }) => progress.step(key, detail, tier),
1136
+ validate: result => validateTriageOutcome(result, batch)
1137
+ })
1138
+ } finally {
1139
+ progress.done(key)
716
1140
  }
1141
+ if (outcome.ok) decisions.push(...outcome.validation.value.decisions)
1142
+ else decisions.push(...failedTriageDecisions(batch, outcome.error))
717
1143
  }
1144
+ return decisions
1145
+ }
718
1146
 
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({
1147
+ /**
1148
+ * Матеріалізує одне triage-рішення у report results.
1149
+ * @param {object} args orchestration context
1150
+ * @returns {Promise<Array<object>>} results одного source
1151
+ */
1152
+ async function materializeDecision(args) {
1153
+ const { decision, candidate, runner, rootCwd, deps, spawnFn, log, createPr, progress, prState } = args
1154
+ if (decision.action !== 'pr') {
1155
+ return [
1156
+ {
726
1157
  source: decision.source,
727
1158
  status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
728
- rationale: decision.rationale ?? ''
1159
+ rationale: decision.rationale ?? '',
1160
+ ...(decision.incomplete === true && { incomplete: true }),
1161
+ ...(candidate.oid && { oid: candidate.oid })
1162
+ }
1163
+ ]
1164
+ }
1165
+
1166
+ const results = []
1167
+ for (const group of decision.groups) {
1168
+ prState.index += 1
1169
+ const key = `pr-${prState.index}`
1170
+ const prefix = `${prState.index}/${prState.total} · ${decision.source}`
1171
+ progress.step(key, `${prefix} · worktree`)
1172
+ try {
1173
+ const result = await createPr({
1174
+ candidate: { ...candidate, rationale: decision.rationale },
1175
+ group,
1176
+ runner,
1177
+ rootCwd,
1178
+ deps,
1179
+ spawnFn,
1180
+ log,
1181
+ onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
729
1182
  })
730
- continue
731
- }
732
- const groups = Array.isArray(decision.groups) ? decision.groups : []
733
- if (groups.length === 0) {
734
1183
  results.push({
735
1184
  source: decision.source,
736
- status: 'kept',
737
- error: 'LLM позначила pr без groups'
1185
+ ...(candidate.oid && { oid: candidate.oid }),
1186
+ ...result
738
1187
  })
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
- )
1188
+ } finally {
1189
+ progress.done(key)
753
1190
  }
754
1191
  }
1192
+ return results
1193
+ }
1194
+
1195
+ /**
1196
+ * Перетворює всі validated decisions у PR/keep/drop results.
1197
+ * @param {object} args orchestration context
1198
+ * @returns {Promise<{bySource:Map<string,object>,results:Array<object>}>} materialized state
1199
+ */
1200
+ async function materializeDecisions(args) {
1201
+ const { decisions, candidates } = args
1202
+ const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
1203
+ const results = []
1204
+ for (const decision of decisions) {
1205
+ const candidate = bySource.get(decision.source)
1206
+ if (!candidate) continue
1207
+ results.push(...(await materializeDecision({ ...args, decision, candidate })))
1208
+ }
1209
+ return { bySource, results }
1210
+ }
755
1211
 
1212
+ /**
1213
+ * Прибирає Git-доведені merged/patch-equivalent branches.
1214
+ * @param {object} args cleanup context
1215
+ */
1216
+ function cleanupInactiveBranches(args) {
1217
+ const { inventory, cleanup, rootCwd, spawnFn, progress, cleanupState } = args
756
1218
  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)
1219
+ const inactive = ['merged', 'patch-equivalent'].includes(branch.state)
1220
+ if (inactive && !branch.worktree && !branch.pr) {
1221
+ cleanupState.index += 1
1222
+ const key = `cleanup-${cleanupState.index}`
1223
+ progress.step(key, branch.source)
1224
+ try {
1225
+ branch.cleanup = cleanup(branch, rootCwd, spawnFn)
1226
+ } finally {
1227
+ progress.done(key)
1228
+ }
759
1229
  }
760
1230
  }
1231
+ }
1232
+
1233
+ /**
1234
+ * Прибирає source лише після drop або успіху всіх його PR groups.
1235
+ * @param {object} args cleanup context
1236
+ */
1237
+ function cleanupMaterializedSources(args) {
1238
+ const { bySource, results, cleanup, rootCwd, spawnFn, progress, cleanupState } = args
761
1239
  for (const [source, candidate] of bySource) {
762
1240
  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) {
1241
+ 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
1244
+ cleanupState.index += 1
1245
+ const key = `cleanup-${cleanupState.index}`
1246
+ progress.step(key, source)
1247
+ try {
767
1248
  const cleanupResult = cleanup(candidate, rootCwd, spawnFn)
768
1249
  for (const result of sourceResults) result.cleanup = cleanupResult
1250
+ } finally {
1251
+ progress.done(key)
769
1252
  }
770
1253
  }
1254
+ }
1255
+
1256
+ /**
1257
+ * Рахує точний cleanup total після завершення PR-фази.
1258
+ * @param {object} inventory Git inventory
1259
+ * @param {Map<string,object>} bySource review sources
1260
+ * @param {Array<object>} results результати PR-фази
1261
+ * @returns {number} кількість sources
1262
+ */
1263
+ function countCleanupSources(inventory, bySource, results) {
1264
+ const inactive = inventory.branches.filter(branch => {
1265
+ return ['merged', 'patch-equivalent'].includes(branch.state) && !branch.worktree && !branch.pr
1266
+ }).length
1267
+ const reviewed = bySource
1268
+ .keys()
1269
+ .filter(source => {
1270
+ const sourceResults = results.filter(result => result.source === source)
1271
+ 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
1274
+ })
1275
+ .toArray().length
1276
+ return inactive + reviewed
1277
+ }
1278
+
1279
+ /**
1280
+ * JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
1281
+ * @param {{cwd?:string,runner?:'pi'|'cursor'|'codex',task?:string,log?:(line:string)=>void,isTTY?:boolean,deps?:object}} [options] опції
1282
+ * @returns {Promise<{ok:boolean,report:string,inventory:object,results:Array<object>}>} результат
1283
+ */
1284
+ export async function runGitReconcileOrchestrator(options = {}) {
1285
+ const rootCwd = options.cwd ?? process.cwd()
1286
+ const runner = options.runner ?? 'pi'
1287
+ const task = options.task ?? ''
1288
+ const log = options.log ?? (line => console.log(line))
1289
+ const deps = options.deps ?? {}
1290
+ const spawnFn = deps.spawnFn ?? spawnSync
1291
+ const inventoryFn = deps.inventoryRepository ?? inventoryRepository
1292
+ const createPr = deps.createPullRequest ?? createPullRequest
1293
+ const cleanup = deps.cleanupSource ?? cleanupSource
1294
+ const reporterFactory = deps.createProgressReporter ?? createProgressReporter
1295
+ const isTTY = options.isTTY ?? process.stdout.isTTY === true
1296
+ const now = deps.now ?? (() => performance.now())
1297
+
1298
+ const inventoryStartedAt = now()
1299
+ log('⏳ 1/4 inventory')
1300
+ const inventory = inventoryFn(rootCwd, { spawnFn })
1301
+ const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
1302
+ log(
1303
+ `✅ 1/4 inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
1304
+ )
1305
+
1306
+ const triageTotal = Math.ceil(candidates.length / REVIEW_BATCH_SIZE)
1307
+ const triageProgress = createPhaseProgress({
1308
+ total: triageTotal,
1309
+ unitLabel: 'triage-пакетів',
1310
+ phase: '2/4 triage',
1311
+ log,
1312
+ isTTY,
1313
+ reporterFactory
1314
+ })
1315
+ const triageStartedAt = now()
1316
+ let decisions
1317
+ try {
1318
+ decisions = await triageCandidates({
1319
+ candidates,
1320
+ runner,
1321
+ task,
1322
+ rootCwd,
1323
+ deps,
1324
+ log,
1325
+ progress: triageProgress
1326
+ })
1327
+ } finally {
1328
+ triageProgress.stop()
1329
+ }
1330
+ log(`✅ 2/4 triage · ${elapsedLabel(triageStartedAt, now)} · ${triageTotal} batches`)
1331
+
1332
+ const prTotal = decisions.reduce((total, decision) => {
1333
+ return total + (decision.action === 'pr' && Array.isArray(decision.groups) ? decision.groups.length : 0)
1334
+ }, 0)
1335
+ const prProgress = createPhaseProgress({
1336
+ total: prTotal,
1337
+ unitLabel: 'PR-груп',
1338
+ phase: '3/4 PR',
1339
+ log,
1340
+ isTTY,
1341
+ reporterFactory
1342
+ })
1343
+ const prStartedAt = now()
1344
+ const prState = { index: 0, total: prTotal }
1345
+ let materialized
1346
+ try {
1347
+ materialized = await materializeDecisions({
1348
+ decisions,
1349
+ candidates,
1350
+ runner,
1351
+ rootCwd,
1352
+ deps,
1353
+ spawnFn,
1354
+ log,
1355
+ createPr,
1356
+ progress: prProgress,
1357
+ prState
1358
+ })
1359
+ } finally {
1360
+ prProgress.stop()
1361
+ }
1362
+ log(`✅ 3/4 PR · ${elapsedLabel(prStartedAt, now)} · ${prTotal} groups`)
1363
+
1364
+ const { bySource, results } = materialized
1365
+ const cleanupCount = countCleanupSources(inventory, bySource, results)
1366
+ const cleanupProgress = createPhaseProgress({
1367
+ total: cleanupCount,
1368
+ unitLabel: 'джерел',
1369
+ phase: '4/4 cleanup',
1370
+ log,
1371
+ isTTY,
1372
+ reporterFactory
1373
+ })
1374
+ const cleanupStartedAt = now()
1375
+ const cleanupState = { index: 0 }
1376
+ try {
1377
+ cleanupInactiveBranches({
1378
+ inventory,
1379
+ cleanup,
1380
+ rootCwd,
1381
+ spawnFn,
1382
+ progress: cleanupProgress,
1383
+ cleanupState
1384
+ })
1385
+ cleanupMaterializedSources({
1386
+ bySource,
1387
+ results,
1388
+ cleanup,
1389
+ rootCwd,
1390
+ spawnFn,
1391
+ progress: cleanupProgress,
1392
+ cleanupState
1393
+ })
1394
+ } finally {
1395
+ cleanupProgress.stop()
1396
+ }
1397
+ log(`✅ 4/4 cleanup · ${elapsedLabel(cleanupStartedAt, now)} · ${cleanupCount} sources`)
771
1398
 
772
1399
  const report = formatReport({ inventory, results })
773
1400
  log(report)
774
- const ok = results.every(result => result.status !== 'failed')
1401
+ const ok = results.every(result => result.status !== 'failed' && result.incomplete !== true)
775
1402
  return { ok, report, inventory, results }
776
1403
  }