@nitra/cursor 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.6.1] - 2026-06-01
4
+
5
+ ### Fixed
6
+
7
+ - flow: resolveArtifact обирає артефакт за mtime + пріоритетом slug гілки замість лексикографічного (spec/plan фіксували не той doc при кількох на одну дату)
8
+
9
+ ## [3.6.0] - 2026-06-01
10
+
11
+ ### Added
12
+
13
+ - flow: команда gate — структурований вердикт релізної готовності (PASS/CONCERNS/FAIL + score + причини, синтез verify-гейтів і review-findings); release м'яко попереджає на FAIL
14
+
3
15
  ## [3.5.0] - 2026-06-01
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -79,7 +79,17 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
79
79
  7. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
80
80
  **3 спроби**; якщо не вдається — зупинись і поклич людину.
81
81
 
82
- 8. **Фініш** лише після зеленого `verify`:
82
+ 8. **Gate (вердикт готовності)** перед фінішем:
83
+
84
+ ```
85
+ npx @nitra/cursor flow gate
86
+ ```
87
+
88
+ Синтезує verify-гейти і review-findings у `PASS / CONCERNS / FAIL` + score +
89
+ причини (пише у `.flow.json`). `FAIL` (провалений gate або high-severity
90
+ finding) → код 1; `release` на FAIL лише попередить (рішення за тобою).
91
+
92
+ 9. **Фініш** — після зеленого `verify` і бажано `gate` ≠ FAIL:
83
93
 
84
94
  ```
85
95
  npx @nitra/cursor flow release --bump <patch|minor|major> --section <Added|Changed|Fixed> --message "<що зроблено>"
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { cancel, repair, resume, run } from './lib/active.mjs'
11
11
  import { init, release, verify } from './lib/commands.mjs'
12
+ import { gate } from './lib/gate.mjs'
12
13
  import { plan } from './lib/plan.mjs'
13
14
  import { review } from './lib/review.mjs'
14
15
  import { spec } from './lib/spec.mjs'
@@ -20,6 +21,7 @@ const USAGE = [
20
21
  ' npx @nitra/cursor flow plan [--panel] # Фасад A: фаза плану → docs/plans/<…> + state',
21
22
  ' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
22
23
  ' npx @nitra/cursor flow review # Фасад A: adversarial diff-review (за level)',
24
+ ' npx @nitra/cursor flow gate # Фасад A: вердикт PASS/CONCERNS/FAIL (verify+review)',
23
25
  ' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
24
26
  ' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
25
27
  ' npx @nitra/cursor flow resume # продовжити з чекпойнта',
@@ -28,13 +30,13 @@ const USAGE = [
28
30
  ].join('\n')
29
31
 
30
32
  /** Підкоманди flow. */
31
- export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'release', 'run', 'resume', 'cancel', 'repair']
33
+ export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'gate', 'release', 'run', 'resume', 'cancel', 'repair']
32
34
 
33
35
  /**
34
36
  * Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
35
37
  * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
36
38
  */
37
- export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, release, run, resume, cancel, repair }
39
+ export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, gate, release, run, resume, cancel, repair }
38
40
 
39
41
  /**
40
42
  * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
@@ -6,24 +6,36 @@
6
6
  * Лінки front-matter (`spec.plan`/`plan.spec`/`plan.flow`) пише сам агент за
7
7
  * контрактом `flow.mdc` — тут лише ВЕРИФІКАЦІЯ (мутатора `trace link` нема).
8
8
  */
9
- import { existsSync, readdirSync } from 'node:fs'
9
+ import { existsSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
 
12
12
  import { runTraceCli } from '../trace.mjs'
13
13
 
14
14
  /**
15
- * Найсвіжіший `docs/<kind>/*.md` (лексикографічно дата у префіксі назви).
15
+ * Резолвить артефакт у `docs/<kind>/`. Пріоритет: файли, чия назва містить
16
+ * хвіст гілки (slug, напр. `flow-gate` з `claude/flow-gate`); серед них (або
17
+ * серед усіх, якщо збігу нема) — **найсвіжіший за mtime**. Лексикографічний
18
+ * вибір був хибним при кількох артефактах на одну дату (виявлено dogfood'ом).
16
19
  * @param {string} cwd корінь worktree
17
20
  * @param {'specs' | 'plans'} kind підкаталог `docs`
21
+ * @param {string} [branch] гілка задачі — для пріоритету за slug
18
22
  * @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
19
23
  */
20
- export function resolveArtifact(cwd, kind) {
24
+ export function resolveArtifact(cwd, kind, branch) {
21
25
  const dir = join(cwd, 'docs', kind)
22
26
  if (!existsSync(dir)) return null
23
- const md = readdirSync(dir)
24
- .filter(f => f.endsWith('.md'))
25
- .toSorted()
26
- return md.length > 0 ? join(dir, md.at(-1)) : null
27
+ const md = readdirSync(dir).filter(f => f.endsWith('.md'))
28
+ if (md.length === 0) return null
29
+
30
+ const slug = branch ? branch.split('/').pop() : null
31
+ const matched = slug ? md.filter(f => f.includes(slug)) : []
32
+ const pool = matched.length > 0 ? matched : md
33
+
34
+ const best = pool
35
+ .map(f => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
36
+ .toSorted((a, b) => a.mtime - b.mtime || (a.f < b.f ? -1 : 1))
37
+ .at(-1)
38
+ return join(dir, best.f)
27
39
  }
28
40
 
29
41
  /** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
@@ -179,6 +179,10 @@ export async function release(rest, deps = {}) {
179
179
  log('release: стану нема — спершу `flow init`')
180
180
  return 1
181
181
  }
182
+ // М'які ворота: FAIL-гейт — лише попередження, рішення за людиною.
183
+ if (state.gate?.verdict === 'FAIL') {
184
+ log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
185
+ }
182
186
 
183
187
  const ch = run('npx', ['@nitra/cursor', 'change', ...rest], { cwd })
184
188
  if ((ch.status ?? 1) !== 0) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `flow gate` — структурований вердикт релізної готовності (ідея BMAD qa-gate, у
3
+ * нашому стані). Синтезує механічні гейти `verify` (`state.gates`) і adversarial
4
+ * findings `review` (`state.review.findings`) у єдине PASS/CONCERNS/FAIL + score
5
+ * + причини. Дає traceability «чому готово/не готово». `gate` лише агрегує —
6
+ * рішення verify/review не дублює.
7
+ *
8
+ * Уся IO (`now`) ін'єктується; `computeGate` — чиста (тестується без стану на диску).
9
+ */
10
+ import { cwd as processCwd } from 'node:process'
11
+
12
+ import { flowEventsPath } from './events.mjs'
13
+ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
14
+
15
+ /** Штрафи score за кожен тип проблеми. */
16
+ const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
17
+
18
+ /**
19
+ * Чистий синтез вердикту з наявного стану.
20
+ * @param {{ gates?: { name: string, ok: boolean }[], review?: { findings?: { severity?: string }[] } }} state стан flow
21
+ * @returns {{ verdict: 'PASS' | 'CONCERNS' | 'FAIL', score: number, reasons: string[] }} вердикт
22
+ */
23
+ export function computeGate(state) {
24
+ const gates = state.gates ?? []
25
+ const findings = state.review?.findings ?? []
26
+ const failedGates = gates.filter(g => !g.ok)
27
+ const high = findings.filter(f => f.severity === 'high')
28
+ const med = findings.filter(f => f.severity === 'med')
29
+ const noVerify = gates.length === 0
30
+
31
+ const reasons = []
32
+ for (const g of failedGates) reasons.push(`gate «${g.name}» провалено`)
33
+ if (high.length > 0) reasons.push(`${high.length} high-severity review finding(s)`)
34
+ if (med.length > 0) reasons.push(`${med.length} med-severity review finding(s)`)
35
+ if (noVerify) reasons.push('verify ще не запускався')
36
+
37
+ let verdict = 'PASS'
38
+ if (failedGates.length > 0 || high.length > 0) {
39
+ verdict = 'FAIL'
40
+ } else if (med.length > 0 || noVerify) {
41
+ verdict = 'CONCERNS'
42
+ }
43
+
44
+ const penalty =
45
+ PENALTY.failedGate * failedGates.length +
46
+ PENALTY.high * high.length +
47
+ PENALTY.med * med.length +
48
+ (noVerify ? PENALTY.noVerify : 0)
49
+ const score = Math.max(0, Math.min(100, 100 - penalty))
50
+
51
+ return { verdict, score, reasons }
52
+ }
53
+
54
+ /**
55
+ * `flow gate` — обчислює й фіксує вердикт у `.flow.json`.
56
+ * @param {string[]} _rest аргументи (не використовуються)
57
+ * @param {{ cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
58
+ * @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
59
+ */
60
+ export async function gate(_rest, deps = {}) {
61
+ const cwd = deps.cwd ?? processCwd()
62
+ const log = deps.log ?? console.error
63
+ const now = deps.now ?? Date.now
64
+
65
+ const statePath = flowStatePath(cwd)
66
+ const state = readState(statePath)
67
+ if (!state) {
68
+ log('gate: стану нема — спершу `flow init`')
69
+ return 1
70
+ }
71
+
72
+ const result = computeGate(state)
73
+ recordTransition(
74
+ { statePath, eventsPath: flowEventsPath(cwd) },
75
+ { type: 'gate', verdict: result.verdict },
76
+ s => ({ ...s, gate: { ...result, at: new Date(now()).toISOString() } }),
77
+ now
78
+ )
79
+
80
+ log(`gate: ${result.verdict} (score ${result.score})`)
81
+ for (const r of result.reasons) log(` · ${r}`)
82
+ return result.verdict === 'FAIL' ? 1 : 0
83
+ }
@@ -35,7 +35,7 @@ export async function plan(rest, deps = {}) {
35
35
  log('plan: дизайн ще не зафіксовано — рекомендовано спершу `flow spec` (не блокує)')
36
36
  }
37
37
 
38
- const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'plans')
38
+ const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'plans', state.branch)
39
39
  let steps
40
40
  if (rest.includes('--panel')) {
41
41
  let runner = deps.runner
@@ -48,7 +48,7 @@ export async function spec(rest, deps = {}) {
48
48
  }
49
49
  }
50
50
 
51
- const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'specs')
51
+ const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'specs', state.branch)
52
52
  if (!doc || !existsSync(doc)) {
53
53
  log('spec: нема docs/specs/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
54
54
  return 1