@nitra/cursor 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
19
19
 
20
20
  import { applyVerdicts } from '../../../scripts/coverage-classify/apply.mjs'
21
21
  import { classify } from '../../../scripts/coverage-classify/index.mjs'
22
+ import { flowStatePath, readState } from '../../../scripts/dispatcher/lib/state-store.mjs'
23
+ import { collectChangedFilesSince } from '../../../scripts/lib/changed-files.mjs'
22
24
  import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
23
25
  import { withLock } from '../../../scripts/utils/with-lock.mjs'
24
26
 
@@ -194,18 +196,46 @@ async function readClassifyThreshold(cwd) {
194
196
  }
195
197
  }
196
198
 
199
+ /**
200
+ * Резолвить scope змінених файлів для `--changed`-режиму.
201
+ *
202
+ * Base — `metadata.base_commit` зі стану flow (sibling-файл `.flow.json` поруч із
203
+ * worktree-checkout). `git diff <base>` проти робочого дерева ловить committed і
204
+ * uncommitted однаково — тож scope не залежить від того, чи executor уже закомітив
205
+ * крок. Поза flow (нема/пошкоджений стан) — fallback на робоче дерево vs HEAD.
206
+ * @param {string} cwd корінь проєкту (= worktree-checkout у межах flow)
207
+ * @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
208
+ */
209
+ function resolveChangedScope(cwd) {
210
+ let base = null
211
+ try {
212
+ const state = readState(flowStatePath(cwd))
213
+ base = state?.metadata?.base_commit ?? null
214
+ } catch {
215
+ // пошкоджений/несумісний стан — попереджаємо й падаємо на HEAD (working-tree scope).
216
+ console.error('coverage --changed: стан flow нечитабельний — scope визначається від HEAD робочого дерева')
217
+ base = null
218
+ }
219
+ return { base, files: collectChangedFilesSince(base, cwd) }
220
+ }
221
+
197
222
  /**
198
223
  * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
199
224
  * detect+collect для кожного, агрегація, запис COVERAGE.md.
200
225
  * При `opts.fix === true` після запису COVERAGE.md запускає агента (coverage-fix.mjs)
201
226
  * для написання тестів по вцілілих мутантах.
202
- * @param {{cwd?:string, rulesDir?:string, fix?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix --fix режим
203
- * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних)
227
+ * При `opts.changed === true` провайдери звужують scope до змінених від base файлів
228
+ * (для flow-турнікета). Порожній scope (нема релевантних змін) це pass (exit 0)
229
+ * без перезапису наявного COVERAGE.md, а НЕ помилка «жодного провайдера».
230
+ * @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix — --fix режим; changed — scope лише змінених
231
+ * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних у full-режимі)
204
232
  */
205
233
  export async function runCoverageSteps(opts = {}) {
206
234
  const cwd = opts.cwd ?? process.cwd()
207
235
  const rulesDir = opts.rulesDir ?? RULES_DIR
208
236
  const config = await readNCursorConfigLite(cwd)
237
+ const scope = opts.changed ? resolveChangedScope(cwd) : null
238
+ const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
209
239
  const rows = []
210
240
 
211
241
  for (const ruleId of config.rules) {
@@ -214,14 +244,27 @@ export async function runCoverageSteps(opts = {}) {
214
244
  if (!provider) continue
215
245
  if (!(await provider.detect(cwd))) continue
216
246
  console.log(`→ ${ruleId} coverage…`)
217
- rows.push(...(await provider.collect(cwd)))
247
+ rows.push(...(await provider.collect(cwd, collectOpts)))
218
248
  }
219
249
 
220
250
  if (rows.length === 0) {
251
+ // --changed: порожній scope = «нема релевантних змін» → pass, не чіпаємо COVERAGE.md.
252
+ if (opts.changed) {
253
+ console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
254
+ return 0
255
+ }
221
256
  console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
222
257
  return 1
223
258
  }
224
259
 
260
+ // --changed (турнікет): рішення гейту визначає лише exit-код — vitest/Stryker кинули б помилку
261
+ // під час collect, якби тести/прогін упали. Тут НЕ перезаписуємо повний COVERAGE.md частковим
262
+ // scoped-звітом і не ганяємо LLM-класифікацію (зайвий кошт у per-step циклі).
263
+ if (opts.changed) {
264
+ console.log('✓ coverage --changed: змінені файли перевірено')
265
+ return 0
266
+ }
267
+
225
268
  // LLM-класифікація survived мутантів (graceful skip без API key)
226
269
  const allSurvived = rows.flatMap(r => r.survived ?? [])
227
270
  let augmentedRows = rows
@@ -256,18 +299,19 @@ export async function runCoverageSteps(opts = {}) {
256
299
  }
257
300
 
258
301
  /**
259
- * CLI entrypoint для `n-cursor coverage [--fix]`.
302
+ * CLI entrypoint для `n-cursor coverage [--fix] [--changed]`.
260
303
  * Із `--fix`: збирає метрики → запускає агента → повторно збирає метрики.
261
304
  * Без `--fix`: лише збирає метрики.
305
+ * Із `--changed`: звужує scope до змінених від base файлів (flow-турнікет).
262
306
  * Лок охоплює кожен coverage-прогін окремо.
263
- * @param {{fix?:boolean}} [opts] прапор --fix
307
+ * @param {{fix?:boolean, changed?:boolean}} [opts] прапори --fix / --changed
264
308
  * @returns {Promise<number>} exit code
265
309
  */
266
310
  export async function runCoverageCli(opts = {}) {
267
311
  const code = await withLock('coverage', () => runCoverageSteps(opts))
268
312
  if (code === 0 && opts.fix) {
269
313
  console.log('\n♻️ Повторний coverage після агента…\n')
270
- return withLock('coverage', () => runCoverageSteps({ fix: false }))
314
+ return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed }))
271
315
  }
272
316
  return code
273
317
  }
@@ -130,6 +130,8 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
130
130
 
131
131
  Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`vitest run --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker з vitest-runner + `coverageAnalysis: 'perTest'`, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
132
132
 
133
+ **Scoped-режим `--changed`** (для flow-турнікета): `n-cursor coverage --changed` звужує scope до файлів, змінених від `base_commit` (зі стану flow `.flow.json`; поза flow — робоче дерево vs HEAD). `git diff <base>` проти робочого дерева ловить committed і uncommitted однаково, тож результат не залежить від того, чи крок уже закомічено. Недосяжний `base` (rebase/force-update) — fail-closed (помилка, не тихий pass). JS-провайдер ганяє `vitest --changed <base>` (лише зачеплені тести) і Stryker `--mutate` по змінених production-файлах (тест-файли відкидаються); roots без змінених JS пропускаються. Rust-провайдер пропускається, якщо не змінено `.rs`/`Cargo.*` (інакше — повний crate-прогін; per-file scoping cargo-mutants — окремий крок). Порожній scope (нема релевантних змін) — pass. У changed-режимі `COVERAGE.md` **не** перезаписується (рішення гейту — лише exit-код) і LLM-класифікація не запускається. `DEFAULT_GATES` турнікета викликає саме `coverage --changed`; повний coverage (увесь проєкт, запис `COVERAGE.md`) лишається для `bun run coverage` / `/n-coverage-fix`.
134
+
133
135
  Провайдери живуть у `npm/rules/<rule>/coverage/coverage.mjs` (постачаються правилами мови/рантайму: `js-lint`, `rust`, у майбутньому `python` тощо). Оркестратор — у `npm/rules/test/coverage/coverage.mjs`.
134
136
 
135
137
  У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
@@ -35,19 +35,52 @@ const USAGE = [
35
35
  */
36
36
  export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, gate, release, run, resume, cancel, repair }
37
37
 
38
+ /**
39
+ * Витягує опційний `--branch <гілка>` з аргументів (для cwd-незалежного резолву
40
+ * стану — беклог #1). Повертає очищені аргументи й значення гілки.
41
+ * @param {string[]} args аргументи після підкоманди
42
+ * @returns {{ rest: string[], branch: string | undefined }} очищені аргументи + гілка
43
+ */
44
+ export function extractBranchFlag(args) {
45
+ const rest = []
46
+ let branch
47
+ for (let i = 0; i < args.length; i++) {
48
+ if (args[i] === '--branch') {
49
+ const val = args[i + 1]
50
+ // Поглинаємо наступний аргумент як значення лише якщо це справді значення,
51
+ // а не інший прапорець / кінець аргументів (інакше `--branch` був би no-op,
52
+ // що тихо ковтав би сусідній прапорець).
53
+ if (val !== undefined && !val.startsWith('-')) {
54
+ branch = val
55
+ i++
56
+ }
57
+ continue
58
+ }
59
+ const inline = args[i].startsWith('--branch=') ? args[i].slice('--branch='.length) : null
60
+ if (inline !== null) {
61
+ if (inline !== '') branch = inline
62
+ continue
63
+ }
64
+ rest.push(args[i])
65
+ }
66
+ return { rest, branch }
67
+ }
68
+
38
69
  /**
39
70
  * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
40
71
  * маршрутизує до handler-а. Невідома/відсутня підкоманда → usage + код 1.
72
+ * Опційний `--branch <гілка>` прокидається в `deps.branch` (резолв стану поза worktree).
41
73
  * @param {string[]} args аргументи після `flow`
42
- * @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>> }} [deps] ін'єкція handler-ів (для тестів)
74
+ * @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>>, branch?: string }} [deps] ін'єкція handler-ів (для тестів)
43
75
  * @returns {Promise<number>} exit code
44
76
  */
45
77
  export async function runFlowCli(args, deps = {}) {
46
- const [sub, ...rest] = args
78
+ const [sub, ...raw] = args
47
79
  const handlers = deps.handlers ?? DEFAULT_HANDLERS
48
80
  if (!sub || ! Object.hasOwn(handlers, sub)) {
49
81
  console.error(USAGE)
50
82
  return 1
51
83
  }
52
- return await handlers[sub](rest, deps)
84
+ const { rest, branch } = extractBranchFlag(raw)
85
+ return await handlers[sub](rest, { ...deps, branch: deps.branch ?? branch })
53
86
  }
@@ -10,12 +10,15 @@ import { isAbsolute, join } from 'node:path'
10
10
  import { cwd as processCwd } from 'node:process'
11
11
 
12
12
  import { worktreePaths } from '../../lib/worktree.mjs'
13
+ import { collectChangedFilesSince } from '../../lib/changed-files.mjs'
13
14
  import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
15
+ import { getMonorepoProjectRootDirs } from '../../../rules/changelog/lib/package-manifest.mjs'
14
16
  import { flowEventsPath } from './events.mjs'
15
17
  import { detectLevel, detectRisk } from './level.mjs'
16
18
  import { runReview } from './reviewer.mjs'
17
19
  import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
18
20
  import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
21
+ import { resolveActiveFlowState } from './flow-resolve.mjs'
19
22
 
20
23
  /**
21
24
  * Реальний sync-runner із захопленням виводу.
@@ -127,12 +130,34 @@ export async function init(rest, deps = {}) {
127
130
  */
128
131
  export async function verify(_rest, deps = {}) {
129
132
  const run = deps.run ?? realRun
130
- const cwd = deps.cwd ?? processCwd()
133
+ const cwd0 = deps.cwd ?? processCwd()
131
134
  const log = deps.log ?? console.error
132
- const fingerprint = deps.fingerprint ?? (() => worktreeFingerprint())
133
135
 
134
- const statePath = flowStatePath(cwd)
135
- const state = readState(statePath)
136
+ // cwd-незалежний резолв активного flow. verify толерантний: без активного flow
137
+ // гейти все одно прогоняються (standalone) у поточному cwd, лише без запису стану.
138
+ const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
139
+ if (resolved.statePath && resolved.autoResolved) {
140
+ log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
141
+ }
142
+ // Явний `--branch`, що не резолвиться, — це помилка наміру: не деградуємо тихо
143
+ // на поточний cwd (інакше `flow verify --branch typo` міг би «зеленіти» в CI).
144
+ if (deps.branch && !resolved.statePath) {
145
+ log(`❌ verify: ${resolved.error}`)
146
+ return 1
147
+ }
148
+ const cwd = resolved.worktreeDir ?? cwd0
149
+ const fingerprint =
150
+ deps.fingerprint ?? (() => worktreeFingerprint((cmd, args, opts) => spawnSync(cmd, args, { ...opts, cwd })))
151
+
152
+ const statePath = resolved.statePath
153
+ const state = statePath ? readState(statePath) : null
154
+ if (!state) {
155
+ // statePath null → resolved.error пояснює (нема/кілька активних); statePath є,
156
+ // але стан не читається → пошкоджений .flow.json. В обох випадках verify
157
+ // толерантний: гейти прогоняються standalone у `cwd`, без запису стану.
158
+ if (resolved.error) log(`⚠️ verify: ${resolved.error}`)
159
+ log(`⚠️ verify: активного flow не визначено — гейти прогнано у ${cwd} без запису стану`)
160
+ }
136
161
  // М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
137
162
  if (state && !(state.plan?.length)) {
138
163
  log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
@@ -162,6 +187,56 @@ export async function verify(_rest, deps = {}) {
162
187
  return verdict.pass ? 0 : 1
163
188
  }
164
189
 
190
+ /**
191
+ * Які з subworkspace-тек мають змінені файли — для авто-`--ws` у `release`.
192
+ * Кожен файл відноситься до НАЙГЛИБШОГО воркспейсу-збігу, тож вкладені воркспейси
193
+ * (`apps` + `apps/web`) не дають хибного «кілька воркспейсів» для `apps/web/x`.
194
+ * @param {string[]} subWorkspaces теки воркспейсів без кореня (`.`)
195
+ * @param {string[]} changedFiles змінені шляхи відносно кореня репо (posix)
196
+ * @returns {string[]} підмножина `subWorkspaces`, під якими є зміни (у вхідному порядку)
197
+ */
198
+ export function matchChangedWorkspaces(subWorkspaces, changedFiles) {
199
+ const byDepthDesc = subWorkspaces.toSorted((a, b) => b.length - a.length)
200
+ const hit = new Set()
201
+ for (const f of changedFiles) {
202
+ const ws = byDepthDesc.find(w => f === w || f.startsWith(`${w}/`))
203
+ if (ws) hit.add(ws)
204
+ }
205
+ return subWorkspaces.filter(w => hit.has(w))
206
+ }
207
+
208
+ /**
209
+ * Додає `--ws <шлях>` до аргументів `change`, інферячи воркспейс зі змін від
210
+ * `base_commit`, якщо `--ws` не задано явно. Один змінений subworkspace → авто-`--ws`;
211
+ * кілька → `{ error: true }` (fail-hard, exit 1 у release); нуль / без subworkspace →
212
+ * лишаємо як є (change дефолтиться на `.`). Помилку самого інференсу (недосяжний base,
213
+ * збій `listWorkspaces`) трактуємо fail-soft — не блокуємо, лишаємо дефолт.
214
+ * @param {{ rest: string[], baseCommit: string | null, cwd: string, listWorkspaces: (cwd: string) => Promise<string[]>, changedFilesSince: (base: string | null, cwd: string) => string[], log: (m: string) => void }} input ін'єкції
215
+ * @returns {Promise<{ args: string[], error?: boolean }>} аргументи для `change` або `{ error: true }`
216
+ */
217
+ async function resolveChangeWsArgs({ rest, baseCommit, cwd, listWorkspaces, changedFilesSince, log }) {
218
+ // Поважаємо явно заданий воркспейс в обох формах (`--ws x` і `--ws=x`).
219
+ if (rest.includes('--ws') || rest.some(a => a.startsWith('--ws='))) return { args: rest }
220
+ try {
221
+ const workspaces = await listWorkspaces(cwd)
222
+ const subWs = workspaces.filter(w => w !== '.')
223
+ if (subWs.length === 0) return { args: rest }
224
+ const hits = matchChangedWorkspaces(subWs, changedFilesSince(baseCommit, cwd))
225
+ if (hits.length > 1) {
226
+ log(`release: зміни у кількох воркспейсах (${hits.join(', ')}) — вкажи --ws явно`)
227
+ return { args: rest, error: true }
228
+ }
229
+ if (hits.length === 1) {
230
+ log(`release: change → воркспейс «${hits[0]}» (інферено з diff від base)`)
231
+ return { args: [...rest, '--ws', hits[0]] }
232
+ }
233
+ return { args: rest }
234
+ } catch (error) {
235
+ log(`⚠️ release: інференс воркспейсу пропущено (${error instanceof Error ? error.message : String(error)})`)
236
+ return { args: rest }
237
+ }
238
+ }
239
+
165
240
  /**
166
241
  * `flow release [--bump … --section … --message …]` — генерує `.changes` і пише
167
242
  * completion snapshot (§3 Ф5, §7). Потребує наявного стану (`init`).
@@ -175,7 +250,14 @@ export async function release(rest, deps = {}) {
175
250
  const log = deps.log ?? console.error
176
251
  const now = deps.now ?? Date.now
177
252
 
178
- const statePath = flowStatePath(cwd)
253
+ const resolved = resolveActiveFlowState({ cwd, branch: deps.branch }, deps)
254
+ if (!resolved.statePath) {
255
+ log(`release: ${resolved.error}`)
256
+ return 1
257
+ }
258
+ if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
259
+ const effectiveCwd = resolved.worktreeDir ?? cwd
260
+ const statePath = resolved.statePath
179
261
  const state = readState(statePath)
180
262
  if (!state) {
181
263
  log('release: стану нема — спершу `flow init`')
@@ -186,7 +268,17 @@ export async function release(rest, deps = {}) {
186
268
  log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
187
269
  }
188
270
 
189
- const ch = run('npx', ['@nitra/cursor', 'change', ...rest], { cwd })
271
+ const wsResolved = await resolveChangeWsArgs({
272
+ rest,
273
+ baseCommit: state.metadata?.base_commit ?? null,
274
+ cwd: effectiveCwd,
275
+ listWorkspaces: deps.listWorkspaces ?? getMonorepoProjectRootDirs,
276
+ changedFilesSince: deps.changedFilesSince ?? collectChangedFilesSince,
277
+ log
278
+ })
279
+ if (wsResolved.error) return 1
280
+
281
+ const ch = run('npx', ['@nitra/cursor', 'change', ...wsResolved.args], { cwd: effectiveCwd })
190
282
  if ((ch.status ?? 1) !== 0) {
191
283
  const detail = ch.stderr ? `: ${ch.stderr.trim()}` : ''
192
284
  log(`release: change не вдався${detail}`)
@@ -195,13 +287,13 @@ export async function release(rest, deps = {}) {
195
287
 
196
288
  const snapshot = buildCompletionSnapshot({ ...state, status: 'done' }, now)
197
289
  recordTransition(
198
- { statePath, eventsPath: flowEventsPath(cwd) },
290
+ { statePath, eventsPath: flowEventsPath(effectiveCwd) },
199
291
  { type: 'release' },
200
292
  state_ => ({ ...state_, status: 'done', completion: snapshot }),
201
293
  now
202
294
  )
203
295
  if (state.task) {
204
- writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(cwd, state.task), snapshot)
296
+ writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(effectiveCwd, state.task), snapshot)
205
297
  }
206
298
  log('release: done')
207
299
  return 0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * cwd-незалежний резолвер активного flow (беклог адаптації #1).
3
+ *
4
+ * Команди `spec/plan/verify/review/gate/release` мають знаходити `.flow.json`
5
+ * поточної задачі навіть коли їх запущено НЕ з кореня worktree (напр. з головного
6
+ * дерева чи з підтеки worktree) — інакше `flowStatePath(cwd)` обчислює хибний шлях
7
+ * і видає «стану нема», хоча flow активний.
8
+ *
9
+ * Порядок (spec 2026-06-01-flow-cwd-state-resolution):
10
+ * 1. явний `branch` → `.worktrees/<sanitizeBranch>.flow.json`;
11
+ * 2. toplevel-резолвинг: `git rev-parse --show-toplevel` від `cwd`; якщо toplevel
12
+ * лежить безпосередньо під `<repoRoot>/.worktrees/` і для нього є стан — беремо;
13
+ * 3. скан `<repoRoot>/.worktrees/*.flow.json` зі `status: in_progress`: рівно один →
14
+ * авторезолв; кілька → помилка зі списком; нуль → «стану нема».
15
+ *
16
+ * Резолвер не пише на диск. `git`/FS ін'єктуються — тестується без репозиторію.
17
+ */
18
+ import { existsSync, readdirSync } from 'node:fs'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { basename, dirname, join } from 'node:path'
21
+ import { cwd as processCwd } from 'node:process'
22
+
23
+ import { sanitizeBranch, worktreePaths } from '../../lib/worktree.mjs'
24
+ import { flowStatePath, readState as defaultReadState } from './state-store.mjs'
25
+
26
+ const FLOW_STATE_SUFFIX = '.flow.json'
27
+
28
+ /**
29
+ * Реальний sync git-runner у заданому `cwd`.
30
+ * @param {string[]} args аргументи git
31
+ * @param {string} cwd робочий каталог
32
+ * @returns {{ status: number, stdout: string }} результат
33
+ */
34
+ function realGit(args, cwd) {
35
+ const r = spawnSync('git', args, { encoding: 'utf8', cwd })
36
+ return { status: r.status ?? 1, stdout: r.stdout ?? '' }
37
+ }
38
+
39
+ /**
40
+ * Корінь головного worktree через `git worktree list --porcelain` (перший запис).
41
+ * @param {(args: string[]) => { status: number, stdout: string }} git git-runner
42
+ * @returns {string | null} абсолютний шлях кореня репо або `null`
43
+ */
44
+ function mainRepoRoot(git) {
45
+ const r = git(['worktree', 'list', '--porcelain'])
46
+ if ((r.status ?? 1) !== 0) return null
47
+ const line = r.stdout.split('\n').find(l => l.startsWith('worktree '))
48
+ const root = line ? line.slice('worktree '.length).trim() : ''
49
+ return root.length > 0 ? root : null
50
+ }
51
+
52
+ /**
53
+ * Корінь поточного worktree (`git rev-parse --show-toplevel`).
54
+ * @param {(args: string[]) => { status: number, stdout: string }} git git-runner
55
+ * @returns {string | null} абсолютний шлях або `null`
56
+ */
57
+ function currentToplevel(git) {
58
+ const r = git(['rev-parse', '--show-toplevel'])
59
+ return (r.status ?? 1) === 0 && r.stdout.trim().length > 0 ? r.stdout.trim() : null
60
+ }
61
+
62
+ /**
63
+ * @typedef {object} ResolvedFlow
64
+ * @property {string | null} statePath абсолютний шлях `.flow.json` або `null`
65
+ * @property {string | null} worktreeDir тека worktree (ефективний cwd для гейтів) або `null`
66
+ * @property {string | null} label мітка flow (sanitized branch) або `null`
67
+ * @property {boolean} autoResolved `true`, якщо знайдено скануванням (cwd поза worktree)
68
+ * @property {string | null} error повідомлення для логу, якщо `statePath === null`
69
+ */
70
+
71
+ /**
72
+ * Резолвить активний flow незалежно від `cwd`.
73
+ * @param {{ cwd?: string, branch?: string }} [params] параметри
74
+ * @param {{ git?: (args: string[]) => { status: number, stdout: string }, exists?: (p: string) => boolean, readState?: (p: string) => object | null, readdir?: (d: string) => string[], repoRoot?: string }} [deps] ін'єкції
75
+ * @returns {ResolvedFlow} результат
76
+ */
77
+ export function resolveActiveFlowState({ cwd = processCwd(), branch } = {}, deps = {}) {
78
+ const git = deps.git ?? (args => realGit(args, cwd))
79
+ const exists = deps.exists ?? existsSync
80
+ const readState = deps.readState ?? defaultReadState
81
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
82
+
83
+ const resolveRoot = () => deps.repoRoot ?? mainRepoRoot(git)
84
+
85
+ // 1. Явний --branch завжди перемагає. Валідуємо існування теки worktree, щоб
86
+ // команда не пішла виконувати гейти в неіснуючому каталозі (ENOENT).
87
+ if (branch) {
88
+ const repoRoot = resolveRoot()
89
+ if (!repoRoot) return notFound('стану нема — спершу `flow init`')
90
+ const label = sanitizeBranch(branch)
91
+ const worktreeDir = worktreePaths(repoRoot, branch).checkout
92
+ if (!exists(worktreeDir)) {
93
+ return notFound(`worktree для гілки «${branch}» не знайдено (${worktreeDir}) — перевір назву або зроби \`flow init\``)
94
+ }
95
+ return { statePath: flowStatePath(worktreeDir), worktreeDir, label, autoResolved: false, error: null }
96
+ }
97
+
98
+ // 2. Швидкий шлях без git: `cwd` уже є текою worktree зі станом-sibling
99
+ // (звичайний запуск із кореня worktree).
100
+ const direct = flowStatePath(cwd)
101
+ if (exists(direct)) {
102
+ return { statePath: direct, worktreeDir: cwd, label: basename(cwd), autoResolved: false, error: null }
103
+ }
104
+
105
+ // Далі потрібен корінь репо (git). Якщо недоступний — трактуємо як «стану нема».
106
+ const repoRoot = resolveRoot()
107
+ if (!repoRoot) return notFound('стану нема — спершу `flow init`')
108
+ const worktreesDir = join(repoRoot, '.worktrees')
109
+
110
+ // 3. Якщо ми ВСЕРЕДИНІ worktree (toplevel під .worktrees/, у т.ч. з підтеки) —
111
+ // беремо стан саме цього worktree. Якщо його нема — це проблема цього worktree
112
+ // (`flow init` не зроблено); чужий активний flow НЕ підтягуємо.
113
+ const top = currentToplevel(git)
114
+ if (top && dirname(top) === worktreesDir) {
115
+ const statePath = flowStatePath(top)
116
+ if (exists(statePath)) {
117
+ return { statePath, worktreeDir: top, label: basename(top), autoResolved: false, error: null }
118
+ }
119
+ return notFound('стану нема — спершу `flow init`')
120
+ }
121
+
122
+ // 4. Поза worktree (головне дерево) — скан активних flow.
123
+ const active = []
124
+ for (const name of readdir(worktreesDir)) {
125
+ if (!name.endsWith(FLOW_STATE_SUFFIX)) continue
126
+ const statePath = join(worktreesDir, name)
127
+ let state
128
+ try {
129
+ state = readState(statePath)
130
+ } catch {
131
+ continue // пошкоджений стан — пропускаємо при скануванні
132
+ }
133
+ if (state?.status === 'in_progress') {
134
+ const label = name.slice(0, -FLOW_STATE_SUFFIX.length)
135
+ active.push({ statePath, worktreeDir: join(worktreesDir, label), label })
136
+ }
137
+ }
138
+ if (active.length === 1) {
139
+ return { ...active[0], autoResolved: true, error: null }
140
+ }
141
+ if (active.length > 1) {
142
+ const list = active.map(a => ` - ${a.label}`).join('\n')
143
+ return notFound(`кілька активних flow — уточни \`--branch <гілка>\` або \`cd\` у потрібний worktree:\n${list}`)
144
+ }
145
+ return notFound('стану нема — спершу `flow init`')
146
+ }
147
+
148
+ /**
149
+ * @param {string} error повідомлення
150
+ * @returns {ResolvedFlow} результат без statePath
151
+ */
152
+ function notFound(error) {
153
+ return { statePath: null, worktreeDir: null, label: null, autoResolved: false, error }
154
+ }
@@ -10,7 +10,8 @@
10
10
  import { cwd as processCwd } from 'node:process'
11
11
 
12
12
  import { flowEventsPath } from './events.mjs'
13
- import { flowStatePath, readState, recordTransition } from './state-store.mjs'
13
+ import { readState, recordTransition } from './state-store.mjs'
14
+ import { resolveActiveFlowState } from './flow-resolve.mjs'
14
15
 
15
16
  /** Штрафи score за кожен тип проблеми. */
16
17
  const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
@@ -58,11 +59,18 @@ export function computeGate(state) {
58
59
  * @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
59
60
  */
60
61
  export async function gate(_rest, deps = {}) {
61
- const cwd = deps.cwd ?? processCwd()
62
+ const cwd0 = deps.cwd ?? processCwd()
62
63
  const log = deps.log ?? console.error
63
64
  const now = deps.now ?? Date.now
64
65
 
65
- const statePath = flowStatePath(cwd)
66
+ const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
67
+ if (!resolved.statePath) {
68
+ log(`gate: ${resolved.error}`)
69
+ return 1
70
+ }
71
+ if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
72
+ const cwd = resolved.worktreeDir ?? cwd0
73
+ const statePath = resolved.statePath
66
74
  const state = readState(statePath)
67
75
  if (!state) {
68
76
  log('gate: стану нема — спершу `flow init`')
@@ -15,7 +15,8 @@ import { flowEventsPath } from './events.mjs'
15
15
  import { parsePlan } from './planner.mjs'
16
16
  import { runPanel } from './plan-panel.mjs'
17
17
  import { createRunner } from './subagent-runner.mjs'
18
- import { flowStatePath, readState, recordTransition } from './state-store.mjs'
18
+ import { readState, recordTransition } from './state-store.mjs'
19
+ import { resolveActiveFlowState } from './flow-resolve.mjs'
19
20
 
20
21
  /**
21
22
  * @param {string[]} rest аргументи (`--panel`, опц. `<plan.md>`)
@@ -23,9 +24,16 @@ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
23
24
  * @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку/невалідний план)
24
25
  */
25
26
  export async function plan(rest, deps = {}) {
26
- const cwd = deps.cwd ?? processCwd()
27
+ const cwd0 = deps.cwd ?? processCwd()
27
28
  const log = deps.log ?? console.error
28
- const statePath = flowStatePath(cwd)
29
+ const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
30
+ if (!resolved.statePath) {
31
+ log(`plan: ${resolved.error}`)
32
+ return 1
33
+ }
34
+ if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
35
+ const cwd = resolved.worktreeDir ?? cwd0
36
+ const statePath = resolved.statePath
29
37
  const state = readState(statePath)
30
38
  if (!state) {
31
39
  log('plan: стану нема — спершу `flow init`')
@@ -12,7 +12,8 @@ import { cwd as processCwd } from 'node:process'
12
12
  import { realRun } from './commands.mjs'
13
13
  import { flowEventsPath } from './events.mjs'
14
14
  import { reviewersFor } from './level.mjs'
15
- import { flowStatePath, readState, recordTransition } from './state-store.mjs'
15
+ import { readState, recordTransition } from './state-store.mjs'
16
+ import { resolveActiveFlowState } from './flow-resolve.mjs'
16
17
  import { createRunner } from './subagent-runner.mjs'
17
18
 
18
19
  /** Ліміт diff у промпті (символів) — щоб не роздувати контекст рецензента. */
@@ -108,12 +109,19 @@ function severityIcon(severity) {
108
109
  * @returns {Promise<number>} exit code (0 завжди — інформативна; 1 лише якщо нема стану/runner)
109
110
  */
110
111
  export async function review(_rest, deps = {}) {
111
- const cwd = deps.cwd ?? processCwd()
112
+ const cwd0 = deps.cwd ?? processCwd()
112
113
  const log = deps.log ?? console.error
113
114
  const run = deps.run ?? realRun
114
115
  const now = deps.now ?? Date.now
115
116
 
116
- const statePath = flowStatePath(cwd)
117
+ const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
118
+ if (!resolved.statePath) {
119
+ log(`review: ${resolved.error}`)
120
+ return 1
121
+ }
122
+ if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
123
+ const cwd = resolved.worktreeDir ?? cwd0
124
+ const statePath = resolved.statePath
117
125
  const state = readState(statePath)
118
126
  if (!state) {
119
127
  log('review: стану нема — спершу `flow init`')
@@ -11,12 +11,16 @@
11
11
  import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
12
12
 
13
13
  /**
14
- * Канонічний gate verify лише `lint`. Coverage (vitest-покриття + Stryker-
15
- * мутації) навмисно ПОЗА turnstile: повний прогін надто довгий і ламкий у
16
- * worktree, тож тести/мутації запускаються окремо (`npx \@nitra/cursor coverage`)
17
- * або в CI, а не на кожному `flow verify`.
14
+ * Канонічні gate verify (lint + coverage; coverage включає тести+мутації).
15
+ * Обидва scoped до змінених файлів: `lint` через quick-режим (`changed-files.mjs`),
16
+ * `coverage --changed` через vitest `--changed`/Stryker `--mutate` по diff від base.
17
+ * Турнікет (`flow verify`/per-step) перевіряє лише змінене; повний coverage
18
+ * окремо (`bun run coverage`, `/n-coverage-fix`).
18
19
  */
19
- export const DEFAULT_GATES = [{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] }]
20
+ export const DEFAULT_GATES = [
21
+ { name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
22
+ { name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage', '--changed'] }
23
+ ]
20
24
 
21
25
  /**
22
26
  * Проганяє gate-и й повертає verdict.
@@ -14,7 +14,8 @@ import { resolveArtifact, verifyTrace } from './artifact.mjs'
14
14
  import { flowEventsPath } from './events.mjs'
15
15
  import { runPanel } from './plan-panel.mjs'
16
16
  import { createRunner } from './subagent-runner.mjs'
17
- import { flowStatePath, readState, recordTransition } from './state-store.mjs'
17
+ import { readState, recordTransition } from './state-store.mjs'
18
+ import { resolveActiveFlowState } from './flow-resolve.mjs'
18
19
  import { parseFrontMatter } from '../trace.mjs'
19
20
 
20
21
  /** Допустимі значення ризику у spec-frontmatter. */
@@ -42,9 +43,16 @@ function riskFromSpec(doc, current) {
42
43
  * @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку)
43
44
  */
44
45
  export async function spec(rest, deps = {}) {
45
- const cwd = deps.cwd ?? processCwd()
46
+ const cwd0 = deps.cwd ?? processCwd()
46
47
  const log = deps.log ?? console.error
47
- const statePath = flowStatePath(cwd)
48
+ const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
49
+ if (!resolved.statePath) {
50
+ log(`spec: ${resolved.error}`)
51
+ return 1
52
+ }
53
+ if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
54
+ const cwd = resolved.worktreeDir ?? cwd0
55
+ const statePath = resolved.statePath
48
56
  const state = readState(statePath)
49
57
  if (!state) {
50
58
  log('spec: стану нема — спершу `flow init`')