@nitra/cursor 3.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.12.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - flow: cwd-незалежний резолвинг активного стану — spec/plan/verify/review/gate/release знаходять .flow.json поточної гілки навіть із головного дерева (швидкий шлях без git, toplevel-резолв, авторезолв єдиного активного flow), + опційний --branch <гілка>. Гейти виконуються у теці worktree.
8
+ - flow release: інференс зміненого воркспейсу з diff від base_commit — авто-додає --ws у change, якщо не задано явно (один змінений subworkspace → його .changes/; кілька → fail з підказкою явного --ws; лише корінь → дефолт). Усуває потрапляння change-файлу в корінь монорепо при змінах під підпакетом.
9
+
10
+ ### Fixed
11
+
12
+ - Усунуто суперечність n-changelog.mdc ↔ n-npm-module.mdc: прибрано перевірки version/CHANGELOG у package_structure.mjs, що штовхали до ручного bump (єдиний артефакт змін — change-файл; узгодженість валідує changelog/consistency.mjs); npm-module.mdc делегує bump/CHANGELOG у changelog.mdc, який отримав post-release-інваріант.
13
+ - trace: резолв лінків front-matter відносно теки артефакту (+ root-relative fallback) — file-relative spec/plan лінки (`../specs/…`) більше не дають хибний «розрив ланцюга»; поле `flow` (runtime `.flow.json`) показується, але не рахується розривом. Розрив визначають лише chain-поля (adr/spec/plan/change/task).
14
+
3
15
  ## [3.11.0] - 2026-06-02
4
16
 
5
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
- version: '3.1'
3
+ version: '3.2'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -97,3 +97,9 @@ alwaysApply: true
97
97
  ```
98
98
 
99
99
  Секції — підмножина `### Added`, `### Changed`, `### Fixed`, `### Removed` (одна або кілька).
100
+
101
+ ## Post-release інваріант (гарантує CI)
102
+
103
+ Перша (верхня) секція `## [version]` у `CHANGELOG.md` дорівнює полю `version` у маніфесті — але це **post-release** твердження, яке забезпечує `n-cursor release` у CI, агрегуючи change-файли (bump `version` + генерація секції + git-тег `<name>@<version>`). **Локально цю рівність руками не підтримують**: у feature-флоу `version`/`CHANGELOG.md` не чіпають, тож верхня секція може відставати від майбутньої версії — це нормально. Drift `version` поза CI (vs реєстр / vs git-база) ловить `check changelog` як заборонений ручний bump.
104
+
105
+ Інструкції щодо bump `version` і редагування `CHANGELOG.md` живуть **лише** в цьому правилі — джерелі істини. Інші правила (зокрема `n-npm-module.mdc`) їй підпорядковані й власних інструкцій bump/CHANGELOG не дублюють.
@@ -20,16 +20,15 @@
20
20
  * test-фреймворків (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, `jest`, `ava`, …).
21
21
  * Виняток: `*_test.rego` дозволені поруч з полісі — це конвенція conftest.
22
22
  *
23
- * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
24
- * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
25
- * файлі має відрізнятися від `HEAD` інакше типовий пропуск bump після правок у пакеті.
23
+ * Версія та CHANGELOG тут НЕ перевіряються: єдиний артефакт зміни change-файл, а узгодженість
24
+ * `version`/`CHANGELOG.md` (включно з drift від ручного bump) валідує `changelog/js/consistency.mjs`
25
+ * за моделлю `n-changelog.mdc`. Інваріант «верхня секція CHANGELOG == package.json.version» істинний
26
+ * лише post-release і його гарантує `n-cursor release` у CI — локально його не підтримують руками.
26
27
  * @param {string} cwd корінь репозиторію
27
28
  */
28
- import { execFile } from 'node:child_process'
29
29
  import { existsSync } from 'node:fs'
30
30
  import { readFile, stat } from 'node:fs/promises'
31
31
  import { join, sep } from 'node:path'
32
- import { promisify } from 'node:util'
33
32
 
34
33
  import { parseSync } from 'oxc-parser'
35
34
 
@@ -43,14 +42,6 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
43
42
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
44
43
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
45
44
 
46
- const execFileAsync = promisify(execFile)
47
-
48
- /** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
49
- const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
50
-
51
- /** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
52
- const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
53
-
54
45
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
55
46
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
56
47
 
@@ -91,9 +82,9 @@ const GLOBSTAR_TRAILING_RE = /\/__GLOBSTAR__$/u
91
82
 
92
83
  /**
93
84
  * Чи є під `npm/src` хоча б один `.js` (рекурсивно).
85
+ * @param {string} cwd корінь репозиторію
94
86
  * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
95
87
  * @returns {Promise<boolean>} `true`, якщо знайдено хоча б один `.js`
96
- * @param {string} cwd корінь репозиторію
97
88
  */
98
89
  async function npmSrcTreeHasJsFile(cwd, ignorePaths = []) {
99
90
  const root = join(cwd, 'npm/src')
@@ -215,136 +206,6 @@ function checkEmitTypesConfig(passFn, failFn, cwd) {
215
206
  passFn(`${EMIT_TYPES_CONFIG} є (структуру перевіряє npx @nitra/cursor fix → npm_module.emit_types_config)`)
216
207
  }
217
208
 
218
- /**
219
- * Перевіряє npm-publish.yml workflow.
220
- * @param {(msg: string) => void} passFn callback при успішній перевірці
221
- * @param {(msg: string) => void} failFn callback при помилці
222
- * @param {string} cwd корінь репозиторію
223
- */
224
- /**
225
- * Чи виконано `git` у корені робочого дерева.
226
- * @returns {Promise<boolean>} true, якщо процес запущено в межах git work tree
227
- * @param {string} cwd корінь репозиторію
228
- */
229
- async function gitInsideWorkTree(cwd) {
230
- try {
231
- const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', cwd })
232
- return stdout.trim() === 'true'
233
- } catch {
234
- return false
235
- }
236
- }
237
-
238
- /**
239
- * Список незакомічених шляхів під `npm/` відносно `HEAD`.
240
- * @param {string} cwd корінь репозиторію
241
- * @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
242
- */
243
- async function gitDiffNameOnlyNpm(cwd) {
244
- try {
245
- const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], {
246
- encoding: 'utf8',
247
- cwd
248
- })
249
- return stdout.trim().split('\n').filter(Boolean)
250
- } catch {
251
- return null
252
- }
253
- }
254
-
255
- /**
256
- * Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
257
- * @param {string} refPath на кшталт `HEAD:npm/package.json`
258
- * @param {string} cwd корінь репозиторію
259
- * @returns {Promise<string | null>} значення поля `version` або `null`, якщо ref недоступний
260
- */
261
- async function gitShowNpmPackageVersionAt(refPath, cwd) {
262
- try {
263
- const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8', cwd })
264
- const m = stdout.match(PACKAGE_JSON_VERSION_RE)
265
- return m ? m[1] : null
266
- } catch {
267
- return null
268
- }
269
- }
270
-
271
- /**
272
- * Версія з першого заголовка `## […]` у тексті CHANGELOG.
273
- * @param {string} changelogText вміст файлу CHANGELOG.md
274
- * @returns {string | null} версія з першої секції або `null`, якщо заголовка немає
275
- */
276
- function firstChangelogSectionVersion(changelogText) {
277
- const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
278
- return m ? m[1] : null
279
- }
280
-
281
- /**
282
- * Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
283
- * @param {(msg: string) => void} passFn callback при успішній перевірці
284
- * @param {(msg: string) => void} failFn callback при виявленому порушенні
285
- * @returns {Promise<void>}
286
- * @param {string} cwd корінь репозиторію
287
- */
288
- async function checkChangelogTopMatchesPackageVersion(passFn, failFn, cwd) {
289
- if (!existsSync(join(cwd, 'npm/CHANGELOG.md')) || !existsSync(join(cwd, 'npm/package.json'))) return
290
- const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
291
- const ver = typeof pkg.version === 'string' ? pkg.version : null
292
- if (!ver) {
293
- failFn('npm/package.json: відсутнє поле version')
294
- return
295
- }
296
- const cl = await readFile(join(cwd, 'npm/CHANGELOG.md'), 'utf8')
297
- const first = firstChangelogSectionVersion(cl)
298
- if (!first) {
299
- failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
300
- return
301
- }
302
- if (first !== ver) {
303
- failFn(
304
- `npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
305
- '(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
306
- )
307
- return
308
- }
309
- passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
310
- }
311
-
312
- /**
313
- * Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
314
- * @param {(msg: string) => void} passFn callback при успішній перевірці
315
- * @param {(msg: string) => void} failFn callback при виявленому порушенні
316
- * @returns {Promise<void>}
317
- * @param {string} cwd корінь репозиторію
318
- */
319
- async function checkDirtyNpmRequiresVersionBump(passFn, failFn, cwd) {
320
- if (!(await gitInsideWorkTree(cwd))) {
321
- passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
322
- return
323
- }
324
- const changed = await gitDiffNameOnlyNpm(cwd)
325
- if (changed === null) {
326
- passFn('npm-module: git diff під npm/ недоступний — пропущено')
327
- return
328
- }
329
- if (changed.length === 0) return
330
-
331
- const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json', cwd)
332
- if (headVer === null) return
333
-
334
- const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
335
- const cur = typeof pkg.version === 'string' ? pkg.version : null
336
- if (!cur) return
337
-
338
- if (cur === headVer) {
339
- failFn(
340
- `Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
341
- '(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
342
- )
343
- return
344
- }
345
- passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
346
- }
347
-
348
209
  /**
349
210
  * FS-existence для `npm-publish.yml` workflow. Поля workflow (`on.push.paths`,
350
211
  * `branches`, `id-token: write`, JS-DevTools/npm-publish step) валідує
@@ -614,8 +475,5 @@ export async function check(cwd = process.cwd()) {
614
475
 
615
476
  await checkPublishWorkflow(pass, fail, cwd)
616
477
 
617
- await checkChangelogTopMatchesPackageVersion(pass, fail, cwd)
618
- await checkDirtyNpmRequiresVersionBump(pass, fail, cwd)
619
-
620
478
  return reporter.getExitCode()
621
479
  }
@@ -2,7 +2,7 @@
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  globs: "npm/**,**/package.json,**/hk.pkl,.github/workflows/npm-publish.yml,**/tsconfig*.json"
4
4
  alwaysApply: false
5
- version: '1.13'
5
+ version: '1.14'
6
6
  ---
7
7
 
8
8
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
@@ -55,21 +55,11 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
55
55
 
56
56
  Після додавання **`hk.pkl`**: **`hk install`**.
57
57
 
58
- ## Build версія
58
+ ## Версія та CHANGELOG
59
59
 
60
- Після змін у **`npm/`** обовʼязково підвищ **build**-версію в **`npm/package.json`**, але не роби зайвих підвищень: між номером у файлі й тим, що вже збережено в **git** (`HEAD`), має лишатися не більше одного кроку **+1**.
60
+ Версію (`version` у **`npm/package.json`**) і **`npm/CHANGELOG.md`** **не редагуй вручну** — навіть для hotfix. Єдиний артефакт зміни — **change-файл** (`npx @nitra/cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<…>"`); bump `version` і генерацію секції CHANGELOG робить `n-cursor release` у CI на `main`. Будь-який ручний bump `version` поза CI завалює `check changelog` навіть із change-файлом.
61
61
 
62
- У робочій копії не повинно бути більше одного незбереженого в **git** підвищення **build**-версії за раз.
63
-
64
- **Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
65
-
66
- **Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
67
-
68
- **Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
69
-
70
- ## CHANGELOG
71
-
72
- Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`**.
62
+ Повна модель (база порівняння, інверсія шляхів, формат CHANGELOG, post-release-інваріант «верхня секція CHANGELOG == `version`») — у **`n-changelog.mdc`** (джерело істини). Це правило їй підпорядковане й власних інструкцій bump/CHANGELOG не дублює.
73
63
 
74
64
  ## npm publish
75
65
 
@@ -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`')
@@ -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`')
@@ -7,12 +7,20 @@
7
7
  * FS-доступ (`readdir`/`readFile`/`exists`) ін'єктується — тестується без диска.
8
8
  */
9
9
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
- import { join } from 'node:path'
10
+ import { dirname, join } from 'node:path'
11
11
  import { cwd as processCwd } from 'node:process'
12
12
 
13
- /** Поля-лінки у front-matter, що утворюють ланцюг. */
13
+ /** Поля-лінки у front-matter (порядок відображення). */
14
14
  const LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']
15
15
 
16
+ /**
17
+ * Інформаційні лінк-поля: показуються, але їх відсутність НЕ є розривом ланцюга.
18
+ * `flow` вказує на runtime-стан `.worktrees/<branch>.flow.json` — gitignored, поза
19
+ * `docs/`, існує лише під час задачі; у чистому checkout/CI його нема ніколи, тож
20
+ * рахувати його розривом — хибний сигнал. Решта полів — ланки ланцюга (breaking).
21
+ */
22
+ const INFO_LINK_FIELDS = new Set(['flow'])
23
+
16
24
  /** Каталоги з traceable-артефактами. */
17
25
  const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
18
26
 
@@ -52,20 +60,40 @@ function isSimpleKey(key) {
52
60
 
53
61
  /**
54
62
  * Будує аналіз: для кожного артефакту — його лінки зі статусом ok/розрив.
63
+ * `breaking` — чи відсутність цього лінка рве ланцюг (chain-поля) чи лише
64
+ * інформаційна (`flow` → runtime-стан).
55
65
  * @param {{ file: string, fm: Record<string, string | null> }[]} artifacts артефакти з front-matter
56
- * @param {(target: string) => boolean} exists чи існує цільовий файл лінка
57
- * @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean }[] }[]} аналіз
66
+ * @param {(target: string, artifactFile: string) => boolean} resolve чи резолвиться лінк (відносно артефакту/кореня)
67
+ * @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean, breaking: boolean }[] }[]} аналіз
58
68
  */
59
- export function analyze(artifacts, exists) {
69
+ export function analyze(artifacts, resolve) {
60
70
  return artifacts.map(({ file, fm }) => ({
61
71
  file,
62
72
  kind: fm.kind ?? null,
63
73
  id: fm.id ?? null,
64
74
  status: fm.status ?? null,
65
- links: LINK_FIELDS.filter(f => fm[f]).map(f => ({ field: f, target: fm[f], ok: exists(fm[f]) }))
75
+ links: LINK_FIELDS.filter(f => fm[f]).map(f => ({
76
+ field: f,
77
+ target: fm[f],
78
+ ok: resolve(fm[f], file),
79
+ breaking: !INFO_LINK_FIELDS.has(f)
80
+ }))
66
81
  }))
67
82
  }
68
83
 
84
+ /**
85
+ * Чи резолвиться лінк: спершу відносно теки артефакту (конвенція доків `../specs/…`),
86
+ * далі fallback на root-relative (`docs/specs/…`). Обидві форми вважаються валідними.
87
+ * @param {string} root корінь репо
88
+ * @param {string} artifactFile rel-шлях артефакту (напр. `docs/plans/x.md`)
89
+ * @param {string} target значення лінка
90
+ * @param {(absPath: string) => boolean} exists перевірка існування
91
+ * @returns {boolean} чи знайдено цільовий файл
92
+ */
93
+ function resolveLink(root, artifactFile, target, exists) {
94
+ return exists(join(root, dirname(artifactFile), target)) || exists(join(root, target))
95
+ }
96
+
69
97
  /**
70
98
  * Текстовий рендер аналізу.
71
99
  * @param {object[]} analysis результат `analyze`
@@ -77,14 +105,24 @@ export function render(analysis) {
77
105
  for (const a of analysis) {
78
106
  lines.push(`${a.kind ?? '?'} · ${a.id ?? a.file} [${a.status ?? '—'}]`)
79
107
  for (const l of a.links) {
80
- const mark = l.ok ? '→' : '✗'
81
- const note = l.ok ? '' : ' (РОЗРИВ — файл відсутній)'
82
- lines.push(` ${mark} ${l.field}: ${l.target}${note}`)
108
+ lines.push(` ${renderLink(l)}`)
83
109
  }
84
110
  }
85
111
  return lines.join('\n')
86
112
  }
87
113
 
114
+ /**
115
+ * Рядок одного лінка: `→` ok; `✗ … (РОЗРИВ)` — нерезолвлене chain-поле;
116
+ * `~ … (не рве ланцюг)` — нерезолвлене інформаційне поле (`flow`, runtime-стан).
117
+ * @param {{ field: string, target: string, ok: boolean, breaking: boolean }} l лінк
118
+ * @returns {string} рядок
119
+ */
120
+ function renderLink(l) {
121
+ if (l.ok) return `→ ${l.field}: ${l.target}`
122
+ if (l.breaking) return `✗ ${l.field}: ${l.target} (РОЗРИВ — файл відсутній)`
123
+ return `~ ${l.field}: ${l.target} (runtime-стан — не рве ланцюг)`
124
+ }
125
+
88
126
  /**
89
127
  * CLI `n-cursor trace [--json]`. Повертає 1, якщо є розриви ланцюга.
90
128
  * @param {string[]} args аргументи
@@ -108,7 +146,8 @@ export function runTraceCli(args, deps = {}) {
108
146
  }
109
147
  }
110
148
 
111
- const analysis = analyze(artifacts, target => exists(join(root, target)))
149
+ const analysis = analyze(artifacts, (target, file) => resolveLink(root, file, target, exists))
112
150
  log(args.includes('--json') ? JSON.stringify(analysis, null, 2) : render(analysis))
113
- return analysis.some(a => a.links.some(l => !l.ok)) ? 1 : 0
151
+ // Розрив ланцюга лише нерезолвлене chain-поле; нерезолвлений `flow` (runtime-стан) не рахуємо.
152
+ return analysis.some(a => a.links.some(l => l.breaking && !l.ok)) ? 1 : 0
114
153
  }