@nitra/cursor 3.11.0 → 3.13.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.13.0] - 2026-06-02
4
+
5
+ ### Changed
6
+
7
+ - k8s hasura_configmap: розширено перелік обов'язкових env у ConfigMap Hasura-Deployment — додатково до HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS="true" тепер вимагаються HASURA_GRAPHQL_ENABLE_RELAY="false", HASURA_GRAPHQL_ENABLE_TELEMETRY="false", HASURA_GRAPHQL_ENABLED_LOG_TYPES="startup,http-log" (точний рядок) і HASURA_GRAPHQL_DISABLE_EVENTING (ключ обов'язковий, значення довільне, за замовчуванням "true")
8
+
9
+ ## [3.12.0] - 2026-06-02
10
+
11
+ ### Added
12
+
13
+ - flow: cwd-незалежний резолвинг активного стану — spec/plan/verify/review/gate/release знаходять .flow.json поточної гілки навіть із головного дерева (швидкий шлях без git, toplevel-резолв, авторезолв єдиного активного flow), + опційний --branch <гілка>. Гейти виконуються у теці worktree.
14
+ - flow release: інференс зміненого воркспейсу з diff від base_commit — авто-додає --ws у change, якщо не задано явно (один змінений subworkspace → його .changes/; кілька → fail з підказкою явного --ws; лише корінь → дефолт). Усуває потрапляння change-файлу в корінь монорепо при змінах під підпакетом.
15
+
16
+ ### Fixed
17
+
18
+ - Усунуто суперечність 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-інваріант.
19
+ - trace: резолв лінків front-matter відносно теки артефакту (+ root-relative fallback) — file-relative spec/plan лінки (`../specs/…`) більше не дають хибний «розрив ланцюга»; поле `flow` (runtime `.flow.json`) показується, але не рахується розривом. Розрив визначають лише chain-поля (adr/spec/plan/change/task).
20
+
3
21
  ## [3.11.0] - 2026-06-02
4
22
 
5
23
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.11.0",
3
+ "version": "3.13.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 не дублюють.
@@ -2349,47 +2349,17 @@ export function isHasuraDeploymentManifest(manifest) {
2349
2349
  }
2350
2350
 
2351
2351
  /**
2352
- * Обов'язковий ключ у **`data`** ConfigMap для Hasura-Deployment (узгоджено з k8s.mdc).
2353
- */
2354
- export const HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY = 'HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS'
2355
-
2356
- /**
2357
- * Чи значення поля `data.<key>` у ConfigMap читається як логічне **true**.
2358
- * ConfigMap у Kubernetes тримає значення як рядки, але в YAML часто пишуть без лапок —
2359
- * тому приймаємо і булевий **true**, і рядок **"true"** (без регістрової залежності).
2360
- * @param {unknown} v значення з `data[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY]`
2361
- * @returns {boolean} true, якщо значення — `true` або рядок `'true'`
2362
- */
2363
- function isConfigMapValueTrue(v) {
2364
- if (v === true) return true
2365
- if (typeof v === 'string' && v.trim().toLowerCase() === 'true') return true
2366
- return false
2367
- }
2368
-
2369
- /**
2370
- * Чи порушує ConfigMap вимогу щодо **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: "true"`** (k8s.mdc).
2371
- * Перевірка застосовна, коли в тому ж каталозі є Hasura-Deployment (див. `isHasuraDeploymentManifest`).
2372
- * @param {unknown} manifest корінь YAML-документа ConfigMap
2373
- * @returns {string | null} текст порушення або null, якщо не ConfigMap / ключ є і значення `true`
2374
- */
2375
- export function hasuraConfigMapRemoteSchemaPermissionsViolation(manifest) {
2376
- if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
2377
- return null
2378
- const rec = /** @type {Record<string, unknown>} */ (manifest)
2379
- if (rec.kind !== 'ConfigMap') return null
2380
- const data = rec.data
2381
- if (data === null || data === undefined || typeof data !== 'object' || Array.isArray(data)) {
2382
- return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
2383
- }
2384
- const d = /** @type {Record<string, unknown>} */ (data)
2385
- if (!Object.hasOwn(d, HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY)) {
2386
- return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
2387
- }
2388
- if (!isConfigMapValueTrue(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])) {
2389
- return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: значення має бути "true" (зараз: ${JSON.stringify(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])}) (див. k8s.mdc)`
2390
- }
2391
- return null
2392
- }
2352
+ * Обов'язкові env-ключі у **`data`** ConfigMap для Hasura-Deployment (узгоджено з
2353
+ * rego-пакетом `k8s.hasura_configmap` та k8s.mdc). Лише для людиночитного pass-повідомлення —
2354
+ * авторитетна пер-документна валідація (наявність ключів і значення) живе в rego.
2355
+ */
2356
+ export const HASURA_REQUIRED_ENV_KEYS = [
2357
+ 'HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS',
2358
+ 'HASURA_GRAPHQL_ENABLE_RELAY',
2359
+ 'HASURA_GRAPHQL_ENABLE_TELEMETRY',
2360
+ 'HASURA_GRAPHQL_ENABLED_LOG_TYPES',
2361
+ 'HASURA_GRAPHQL_DISABLE_EVENTING'
2362
+ ]
2393
2363
 
2394
2364
  const K8S_YAML_EXT_RE = /\.ya?ml$/iu
2395
2365
 
@@ -3676,7 +3646,10 @@ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail,
3676
3646
 
3677
3647
  /**
3678
3648
  * Для кожного `k8s/base/configmap.yaml`, у каталозі якого поруч є Hasura-Deployment,
3679
- * вимагає у `data` ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (k8s.mdc).
3649
+ * вимагає у `data` обов'язкові env-ключі (`HASURA_REQUIRED_ENV_KEYS`) з очікуваними
3650
+ * значеннями (`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS="true"`,
3651
+ * `HASURA_GRAPHQL_ENABLE_RELAY="false"`, `HASURA_GRAPHQL_ENABLE_TELEMETRY="false"`,
3652
+ * `HASURA_GRAPHQL_ENABLED_LOG_TYPES="startup,http-log"`, `HASURA_GRAPHQL_DISABLE_EVENTING` — будь-яке) (k8s.mdc).
3680
3653
  * @param {string} root корінь репозиторію
3681
3654
  * @param {string[]} yamlFilesAbs yaml під k8s
3682
3655
  * @param {(msg: string) => void} fail callback при помилці
@@ -3688,8 +3661,8 @@ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs
3688
3661
  return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
3689
3662
  })
3690
3663
  // JS gating: відберемо ConfigMap-файли, у каталозі яких поруч є Hasura-Deployment.
3691
- // Per-document валідація `data.HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS == "true"`
3692
- // — у rego-пакеті `k8s.hasura_configmap`.
3664
+ // Per-document валідація обов'язкових `data.HASURA_GRAPHQL_*` env — у rego-пакеті
3665
+ // `k8s.hasura_configmap`.
3693
3666
  const paired = []
3694
3667
  for (const cmAbs of cmFiles) {
3695
3668
  const deployment = await findDeploymentDocInDir(dirname(cmAbs))
@@ -3708,7 +3681,7 @@ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs
3708
3681
  fail(`${rel}: ${v.message}`)
3709
3682
  }
3710
3683
  if (violations.length === 0) {
3711
- passFn(`Hasura-ConfigMap (${paired.length}) відповідає ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" (rego)`)
3684
+ passFn(`Hasura-ConfigMap (${paired.length}) містить обов'язкові env [${HASURA_REQUIRED_ENV_KEYS.join(', ')}] (rego)`)
3712
3685
  }
3713
3686
  }
3714
3687
 
package/rules/k8s/k8s.mdc CHANGED
@@ -297,11 +297,23 @@ spec:
297
297
 
298
298
  ## ConfigMap для Hasura-Deployment
299
299
 
300
- Якщо в `k8s/base/` поруч із **`configmap.yaml`** є **Deployment** з образом **`hasura/graphql-engine`**, у `data` ConfigMap **обов'язково** має бути ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`**. Точні умови перевірки — **`rules/k8s/fix.mjs`**.
300
+ Якщо в `k8s/base/` поруч із **`configmap.yaml`** є **Deployment** з образом **`hasura/graphql-engine`**, у `data` ConfigMap **обов'язково** мають бути env-ключі:
301
+
302
+ - **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`**;
303
+ - **`HASURA_GRAPHQL_ENABLE_RELAY`** зі значенням **`"false"`**;
304
+ - **`HASURA_GRAPHQL_ENABLE_TELEMETRY`** зі значенням **`"false"`**;
305
+ - **`HASURA_GRAPHQL_ENABLED_LOG_TYPES`** зі значенням **`"startup,http-log"`** (точний рядок);
306
+ - **`HASURA_GRAPHQL_DISABLE_EVENTING`** — ключ обов'язковий, значення довільне (за замовчуванням **`"true"`**).
307
+
308
+ Точні умови перевірки — rego-пакет **`k8s.hasura_configmap`** (cross-file прив'язка ConfigMap↔Deployment — у `rules/k8s/js/manifests.mjs`).
301
309
 
302
310
  ```yaml
303
311
  data:
304
312
  HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: 'true'
313
+ HASURA_GRAPHQL_ENABLE_RELAY: 'false'
314
+ HASURA_GRAPHQL_ENABLE_TELEMETRY: 'false'
315
+ HASURA_GRAPHQL_ENABLED_LOG_TYPES: 'startup,http-log'
316
+ HASURA_GRAPHQL_DISABLE_EVENTING: 'true'
305
317
  ```
306
318
 
307
319
  ## Kustomize: структура каталогів (`base` / overlays)
@@ -1,18 +1,30 @@
1
1
  # Порт перевірки ConfigMap для Hasura-Deployment з
2
2
  # `npm/scripts/rules/k8s/fix.mjs` (k8s.mdc): у ConfigMap, що сусідствує з
3
- # Hasura-Deployment, у `data` обов'язково має бути ключ
4
- # `HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS` зі значенням `"true"`.
3
+ # Hasura-Deployment, у `data` обов'язково мають бути env-ключі зі списку
4
+ # `required_env` з очікуваними значеннями:
5
+ # "HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS" → "true"
6
+ # "HASURA_GRAPHQL_ENABLE_RELAY" → "false"
7
+ # "HASURA_GRAPHQL_ENABLE_TELEMETRY" → "false"
8
+ # "HASURA_GRAPHQL_ENABLED_LOG_TYPES" → "startup,http-log" (точний рядок)
9
+ # "HASURA_GRAPHQL_DISABLE_EVENTING" → null (ключ обов'язковий,
10
+ # значення довільне; за замовчуванням рекомендовано "true")
11
+ #
12
+ # Семантика очікуваного значення у `required_env`:
13
+ # "true" — має читатись як логічне true (boolean true або рядок "true", case-insensitive);
14
+ # "false" — має читатись як логічне false (boolean false або рядок "false", case-insensitive);
15
+ # null — ключ обов'язковий, значення довільне (за замовчуванням "true");
16
+ # інший рядок — значення має точно дорівнювати рядку (exact match).
5
17
  #
6
18
  # Запуск (локально, лише для ConfigMap у каталозі з Hasura-Deployment):
7
19
  # conftest test path/to/k8s/.../configmap.yaml \
8
20
  # -p npm/policy/k8s/hasura_configmap \
9
21
  # --namespace k8s.hasura_configmap
10
22
  #
11
- # Прив'язка ConfigMap-Deployment cross-file — у JS (`rules/k8s/fix.mjs`:
23
+ # Прив'язка ConfigMap-Deployment cross-file — у JS (`rules/k8s/js/manifests.mjs`:
12
24
  # `validateHasuraConfigMapRemoteSchemaPermissions` шукає Hasura-Deployment
13
25
  # у тому ж dir-у і викликає conftest з цією намеспейс лише для відповідних
14
- # ConfigMap-ів). JS authoritative (`hasuraConfigMapRemoteSchemaPermissionsViolation`,
15
- # константа `HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY`).
26
+ # ConfigMap-ів). Rego authoritative для пер-документної валідації; JS лишає
27
+ # лише cross-file orchestration.
16
28
  #
17
29
  # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
18
30
  # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`.
@@ -20,45 +32,82 @@ package k8s.hasura_configmap
20
32
 
21
33
  import rego.v1
22
34
 
23
- # Обов'язковий ключ у `data` (узгоджено з `rules/k8s/fix.mjs`).
24
- required_key := "HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS"
35
+ # Обов'язкові env-ключі у `data` (узгоджено з `rules/k8s/js/manifests.mjs` та k8s.mdc).
36
+ # Значення — очікуваний стан ключа (семантика — у шапці файлу).
37
+ required_env := {
38
+ "HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS": "true",
39
+ "HASURA_GRAPHQL_ENABLE_RELAY": "false",
40
+ "HASURA_GRAPHQL_ENABLE_TELEMETRY": "false",
41
+ "HASURA_GRAPHQL_ENABLED_LOG_TYPES": "startup,http-log",
42
+ "HASURA_GRAPHQL_DISABLE_EVENTING": null,
43
+ }
44
+
45
+ # Множина "boolean-подібних" очікувань — для них значення читається як логічне,
46
+ # а не звіряється точним рядком.
47
+ bool_expected := {"true", "false"}
48
+
49
+ # Підказка про очікуване значення для повідомлення про відсутній ключ.
50
+ expected_hint(null) := "(значення довільне, за замовчуванням \"true\")"
25
51
 
26
- key_missing_template := concat(" ", [
27
- "data.%s: додай ключ зі значенням \"true\"",
28
- "(Deployment з hasura/graphql-engine — k8s.mdc)",
29
- ])
52
+ expected_hint(expected) := sprintf("зі значенням \"%s\"", [expected]) if is_string(expected)
30
53
 
31
- key_value_wrong_template := concat(" ", ["data.%s: значення має бути \"true\" (зараз: %v) (k8s.mdc)"])
54
+ key_value_wrong_template := concat(" ", ["data.%s: значення має бути \"%s\" (зараз: %v) (k8s.mdc)"])
55
+
56
+ # Ключ відсутній: `data` не об'єкт або в ньому немає обов'язкового ключа.
57
+ deny contains msg if {
58
+ input.kind == "ConfigMap"
59
+ some key, expected in required_env
60
+ not key_present(key)
61
+ msg := sprintf(
62
+ "data.%s: додай ключ %s (Deployment з hasura/graphql-engine — k8s.mdc)",
63
+ [key, expected_hint(expected)],
64
+ )
65
+ }
32
66
 
67
+ # Очікуване "true", а значення не читається як логічне true.
33
68
  deny contains msg if {
34
69
  input.kind == "ConfigMap"
35
- not is_object(object.get(input, "data", null))
36
- msg := sprintf(key_missing_template, [required_key])
70
+ d := object.get(input, "data", null)
71
+ is_object(d)
72
+ some key, expected in required_env
73
+ expected == "true"
74
+ key in object.keys(d)
75
+ not is_value_true(d[key])
76
+ msg := sprintf(key_value_wrong_template, [key, "true", d[key]])
37
77
  }
38
78
 
79
+ # Очікуване "false", а значення не читається як логічне false.
39
80
  deny contains msg if {
40
81
  input.kind == "ConfigMap"
41
82
  d := object.get(input, "data", null)
42
83
  is_object(d)
43
- not key_present(d)
44
- msg := sprintf(key_missing_template, [required_key])
84
+ some key, expected in required_env
85
+ expected == "false"
86
+ key in object.keys(d)
87
+ not is_value_false(d[key])
88
+ msg := sprintf(key_value_wrong_template, [key, "false", d[key]])
45
89
  }
46
90
 
91
+ # Очікуване — точний рядок (не "true"/"false"/null), а значення не збігається.
47
92
  deny contains msg if {
48
93
  input.kind == "ConfigMap"
49
94
  d := object.get(input, "data", null)
50
95
  is_object(d)
51
- key_present(d)
52
- value := d[required_key]
53
- not is_value_true(value)
54
- msg := sprintf(key_value_wrong_template, [required_key, value])
96
+ some key, expected in required_env
97
+ is_string(expected)
98
+ not expected in bool_expected
99
+ key in object.keys(d)
100
+ d[key] != expected
101
+ msg := sprintf(key_value_wrong_template, [key, expected, d[key]])
55
102
  }
56
103
 
57
- key_present(d) if {
58
- required_key in object.keys(d)
104
+ key_present(key) if {
105
+ d := object.get(input, "data", null)
106
+ is_object(d)
107
+ key in object.keys(d)
59
108
  }
60
109
 
61
- # Значення вважається "true", якщо це boolean true або рядок "true"
110
+ # Значення вважається "true"/"false", якщо це відповідний boolean або рядок
62
111
  # (case-insensitive). ConfigMap у Kubernetes тримає рядки, але YAML без лапок
63
112
  # дає boolean — приймаємо обидва варіанти.
64
113
  is_value_true(true)
@@ -67,3 +116,10 @@ is_value_true(v) if {
67
116
  is_string(v)
68
117
  lower(trim_space(v)) == "true"
69
118
  }
119
+
120
+ is_value_false(false)
121
+
122
+ is_value_false(v) if {
123
+ is_string(v)
124
+ lower(trim_space(v)) == "false"
125
+ }
@@ -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
  }