@nitra/cursor 3.29.0 → 4.1.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.
@@ -1,79 +0,0 @@
1
- /**
2
- * Спільні утиліти фаз `spec`/`plan` (Пасивний Турнікет): резолв traceable-
3
- * артефакту в `docs/<kind>/`, екстракт кроків плану зі секції `## Кроки`, і
4
- * read-only перевірка цілісності ланцюга через `n-cursor trace` (`trace.mjs`).
5
- *
6
- * Лінки front-matter (`spec.plan`/`plan.spec`/`plan.flow`) пише сам агент за
7
- * контрактом `flow.mdc` — тут лише ВЕРИФІКАЦІЯ (мутатора `trace link` нема).
8
- */
9
- import { existsSync, readdirSync, statSync } from 'node:fs'
10
- import { join } from 'node:path'
11
-
12
- import { runTraceCli } from '../trace.mjs'
13
-
14
- /**
15
- * Резолвить артефакт у `docs/<kind>/`. Пріоритет: файли, чия назва містить
16
- * хвіст гілки (slug, напр. `flow-gate` з `claude/flow-gate`); серед них (або
17
- * серед усіх, якщо збігу нема) — **найсвіжіший за mtime**. Лексикографічний
18
- * вибір був хибним при кількох артефактах на одну дату (виявлено dogfood'ом).
19
- * @param {string} cwd корінь worktree
20
- * @param {'specs' | 'plans'} kind підкаталог `docs`
21
- * @param {string} [branch] гілка задачі — для пріоритету за slug
22
- * @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
23
- */
24
- export function resolveArtifact(cwd, kind, branch) {
25
- const dir = join(cwd, 'docs', kind)
26
- if (!existsSync(dir)) return 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)
39
- }
40
-
41
- /** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
42
- const ACCEPTANCE_MARK = '— acceptance:'
43
- /** Лише цифри — перевірка нумерації кроку (лінійний, без backtracking). */
44
- const DIGITS_RE = /^\d+$/u
45
-
46
- /**
47
- * Кроки зі секції плану — нумерований список `N. <task> — acceptance: <crit>`.
48
- * Best-effort парсинг через `indexOf` (без regex-backtracking): рядки поза
49
- * форматом ігноруються.
50
- * @param {string} text вміст plan-doc
51
- * @returns {{ task: string, acceptance?: string }[]} кроки у порядку появи
52
- */
53
- export function extractSteps(text) {
54
- const steps = []
55
- for (const raw of String(text).split('\n')) {
56
- const line = raw.trim()
57
- const dot = line.indexOf('. ')
58
- if (dot <= 0 || !DIGITS_RE.test(line.slice(0, dot))) continue
59
- const body = line.slice(dot + 2).trim()
60
- const sep = body.toLowerCase().indexOf(ACCEPTANCE_MARK)
61
- if (sep === -1) {
62
- steps.push({ task: body })
63
- } else {
64
- steps.push({ task: body.slice(0, sep).trim(), acceptance: body.slice(sep + ACCEPTANCE_MARK.length).trim() })
65
- }
66
- }
67
- return steps
68
- }
69
-
70
- /**
71
- * Read-only перевірка цілісності ланцюга артефактів (не мутує — лише сигнал).
72
- * @param {string} cwd корінь worktree
73
- * @param {(cwd: string) => number} [runTrace] runner trace (0 — цілісно, 1 — розрив); ін'єкція для тестів
74
- * @returns {boolean} true, якщо ланцюг цілісний
75
- */
76
- export function verifyTrace(cwd, runTrace) {
77
- const run = runTrace ?? (c => runTraceCli([], { cwd: c, log: () => {} }))
78
- return run(cwd) === 0
79
- }
@@ -1,36 +0,0 @@
1
- /**
2
- * Budget guard для автономного режиму (spec §9.4): обгортає SubagentRunner
3
- * лічильником викликів і кидає `BudgetExceeded` при перевищенні `maxApiCalls`.
4
- * Це запобіжник проти неконтрольованих витрат на сервері (де нема людини).
5
- *
6
- * (`maxCostUsd` — коли runner повертатиме tokens/cost; наразі рахуємо виклики.)
7
- */
8
-
9
- /** Помилка перевищення бюджету (ловиться в `run`, §9.4). */
10
- export class BudgetExceeded extends Error {}
11
-
12
- /**
13
- * Обгортає runner лічильником API-викликів.
14
- * @param {{ backend?: string, runStep: (prompt: string, opts?: object) => object }} runner базовий runner
15
- * @param {{ maxApiCalls?: number, log?: (m: string) => void }} [opts] ліміт і лог
16
- * @returns {{ backend: string, runStep: (prompt: string, opts?: object) => Promise<object>, readonly calls: number }} обгорнутий runner
17
- */
18
- export function withBudget(runner, opts = {}) {
19
- const maxApiCalls = opts.maxApiCalls ?? Number.POSITIVE_INFINITY
20
- const log = opts.log ?? (() => {})
21
- let calls = 0
22
- return {
23
- backend: runner.backend,
24
- get calls() {
25
- return calls
26
- },
27
- async runStep(prompt, stepOpts) {
28
- if (calls >= maxApiCalls) {
29
- throw new BudgetExceeded(`budget: вичерпано maxApiCalls=${maxApiCalls}`)
30
- }
31
- calls += 1
32
- log(`budget: API-виклик ${calls}/${maxApiCalls}`)
33
- return runner.runStep(prompt, stepOpts)
34
- }
35
- }
36
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * Capability Router — резолвер режиму оркестрації (`native` vs `polyfill`)
3
- * за **явною декларацією моделі** (spec §2.2).
4
- *
5
- * Рантайм-детекції моделі в кодобазі немає — тому модель НЕ вгадуємо, а
6
- * оголошуємо за пріоритетом: CLI `--model` > env `N_CURSOR_FLOW_MODEL` >
7
- * config `flow.model`. Default-режим (`polyfill`) дозволений ЛИШЕ за наявного
8
- * `SubagentRunner` (§15.1); інакше — fail (caller кидає помилку), бо polyfill
9
- * без runner-а не «працює з будь-якою моделлю».
10
- *
11
- * Усі функції чисті (без I/O) — джерела (`args`/`env`/`config`/`matrix`/
12
- * `hasRunner`) передаються ззовні, що робить модуль тривіально тестованим.
13
- */
14
-
15
- export const DEFAULT_ORCHESTRATION = 'polyfill'
16
-
17
- /**
18
- * Витягує значення `--model <value>` з argv. Не мутує вхід.
19
- * @param {string[]} args аргументи підкоманди flow
20
- * @returns {string | null} оголошена модель або null
21
- */
22
- export function parseModelFlag(args) {
23
- const i = args.indexOf('--model')
24
- return i !== -1 && i + 1 < args.length ? args[i + 1] : null
25
- }
26
-
27
- /**
28
- * Оголошена модель за пріоритетом CLI > env > config.
29
- * @param {{ cliModel?: string | null, envModel?: string | null, configModel?: string | null }} sources джерела декларації
30
- * @returns {string | null} модель або null, якщо ніде не оголошено
31
- */
32
- export function declaredModel({ cliModel = null, envModel = null, configModel = null } = {}) {
33
- return cliModel || envModel || configModel || null
34
- }
35
-
36
- /**
37
- * Режим оркестрації для оголошеної моделі за `capability-matrix`.
38
- * Невідома/неоголошена модель → `matrix.default` → `DEFAULT_ORCHESTRATION`.
39
- * @param {string | null} model оголошена модель
40
- * @param {{ models?: Record<string, { orchestration?: string }>, default?: { orchestration?: string } }} matrix матриця можливостей
41
- * @returns {'native' | 'polyfill'} режим
42
- */
43
- export function orchestrationFor(model, matrix) {
44
- const entry = model && matrix && matrix.models ? matrix.models[model] : null
45
- return (
46
- (entry && entry.orchestration) ||
47
- (matrix && matrix.default && matrix.default.orchestration) ||
48
- DEFAULT_ORCHESTRATION
49
- )
50
- }
51
-
52
- /**
53
- * Чи стартує polyfill: потрібен доступний `SubagentRunner`.
54
- * @param {{ hasRunner: boolean }} ctx контекст середовища
55
- * @returns {boolean} true, якщо runner у наявності
56
- */
57
- export function polyfillStartable({ hasRunner }) {
58
- return hasRunner === true
59
- }
@@ -1,296 +0,0 @@
1
- /**
2
- * Handler-и підкоманд `flow` (spec §8). Уся IO — через ін'єктовані `run`/`log`/
3
- * `fingerprint`/`now`, щоб логіку тестувати без реальних процесів.
4
- *
5
- * Ф2 (Пасивний Турнікет): `init` (worktree + стан), `verify` (Суддя), `release`
6
- * (change + completion snapshot). `run`/`resume`/`cancel`/`repair` — Ф4.
7
- */
8
- import { spawnSync } from 'node:child_process'
9
- import { isAbsolute, join } from 'node:path'
10
- import { cwd as processCwd } from 'node:process'
11
-
12
- import { worktreePaths } from '../../lib/worktree.mjs'
13
- import { collectChangedFilesSince } from '../../lib/changed-files.mjs'
14
- import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
15
- import { getMonorepoProjectRootDirs } from '../../../rules/changelog/lib/package-manifest.mjs'
16
- import { flowEventsPath } from './events.mjs'
17
- import { detectLevel, detectRisk } from './level.mjs'
18
- import { runReview } from './reviewer.mjs'
19
- import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
20
- import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
21
- import { resolveActiveFlowState } from './flow-resolve.mjs'
22
-
23
- /**
24
- * Реальний sync-runner із захопленням виводу.
25
- * @param {string} cmd виконуваний
26
- * @param {string[]} args аргументи
27
- * @param {object} [opts] додаткові опції spawnSync (напр. `cwd`)
28
- * @returns {{ status: number, stdout: string, stderr: string }} результат
29
- */
30
- export function realRun(cmd, args, opts = {}) {
31
- const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
32
- return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
33
- }
34
-
35
- /**
36
- * Чи `cwd` — уже linked worktree (не основний checkout і не submodule).
37
- * @param {(cmd: string, args: string[], opts: object) => { status: number, stdout: string }} run runner
38
- * @param {string} cwd робочий каталог
39
- * @returns {boolean} true, якщо вже в worktree
40
- */
41
- function inLinkedWorktree(run, cwd) {
42
- const gitDir = run('git', ['rev-parse', '--git-dir'], { cwd })
43
- const gitCommon = run('git', ['rev-parse', '--git-common-dir'], { cwd })
44
- if ((gitDir.status ?? 1) !== 0 || (gitCommon.status ?? 1) !== 0) return false
45
- const superproject = run('git', ['rev-parse', '--show-superproject-working-tree'], { cwd })
46
- const isSubmodule = (superproject.stdout ?? '').trim() !== ''
47
- return !isSubmodule && (gitDir.stdout ?? '').trim() !== (gitCommon.stdout ?? '').trim()
48
- }
49
-
50
- /**
51
- * `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1). Якщо вже
52
- * в worktree — не вкладає новий (detect existing isolation).
53
- * @param {string[]} rest аргументи: `<branch> <опис...>`
54
- * @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
55
- * @returns {Promise<number>} exit code
56
- */
57
- /**
58
- * Гарантує worktree для задачі: парсить `<branch> <опис>`, детектить існуючу
59
- * ізоляцію (§8.1) або створює новий worktree, читає `base_commit`. Спільне для
60
- * `init` (Фасад A) і `run` (Фасад B).
61
- * @param {string[]} rest аргументи `<branch> <опис...>`
62
- * @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
63
- * @returns {{ code: number, worktreeDir?: string, branch?: string, desc?: string, baseCommit?: string | null }} результат
64
- */
65
- export function ensureWorktree(rest, deps = {}) {
66
- const run = deps.run ?? realRun
67
- const cwd = deps.cwd ?? processCwd()
68
- const log = deps.log ?? console.error
69
-
70
- const branch = rest[0]
71
- const desc = rest.slice(1).join(' ').trim()
72
- if (!branch || !desc) {
73
- log('Usage: n-cursor flow <init|run> <branch> "<опис>"')
74
- return { code: 1 }
75
- }
76
-
77
- let worktreeDir
78
- if (inLinkedWorktree(run, cwd)) {
79
- worktreeDir = cwd
80
- log(`flow: уже в worktree (${cwd}) — не вкладаю новий`)
81
- } else {
82
- const add = run('npx', ['@nitra/cursor', 'worktree', 'add', branch, desc], { cwd })
83
- if ((add.status ?? 1) !== 0) {
84
- const detail = add.stderr ? `: ${add.stderr.trim()}` : ''
85
- log(`flow: worktree add не вдався${detail}`)
86
- return { code: 1 }
87
- }
88
- worktreeDir = worktreePaths(cwd, branch).checkout
89
- }
90
-
91
- const head = run('git', ['rev-parse', 'HEAD'], { cwd: worktreeDir })
92
- const baseCommit = (head.status ?? 1) === 0 ? (head.stdout ?? '').trim() : null
93
- return { code: 0, worktreeDir, branch, desc, baseCommit }
94
- }
95
-
96
- /**
97
- * `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1).
98
- * @param {string[]} rest аргументи `<branch> <опис...>`
99
- * @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
100
- * @returns {Promise<number>} exit code
101
- */
102
- export async function init(rest, deps = {}) {
103
- const ew = ensureWorktree(rest, deps)
104
- if (ew.code !== 0) return ew.code
105
- const now = deps.now ?? Date.now
106
- const log = deps.log ?? console.error
107
- const statePath = flowStatePath(ew.worktreeDir)
108
- const level = detectLevel(ew.desc)
109
- const risk = detectRisk(ew.desc)
110
- writeState(statePath, {
111
- branch: ew.branch,
112
- status: 'in_progress',
113
- started_at: new Date(now()).toISOString(),
114
- metadata: { base_commit: ew.baseCommit },
115
- level,
116
- risk,
117
- plan: []
118
- })
119
- log(`init: ${ew.branch} (level ${level}, risk ${risk}) → ${statePath}`)
120
- return 0
121
- }
122
-
123
- /**
124
- * `flow verify` — проганяє Quality Gates у поточному worktree (Турнікет, §8.1).
125
- * На фейл друкує вивід проваленого gate. Якщо поряд є стан — записує
126
- * gate-результати + fingerprint. Read-only щодо коду.
127
- * @param {string[]} _rest аргументи (не використовуються)
128
- * @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, fingerprint?: () => string | null }} [deps] ін'єкції
129
- * @returns {Promise<number>} exit code (0 — pass, 1 — fail)
130
- */
131
- export async function verify(_rest, deps = {}) {
132
- const run = deps.run ?? realRun
133
- const cwd0 = deps.cwd ?? processCwd()
134
- const log = deps.log ?? console.error
135
-
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
- }
161
- // М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
162
- if (state && !state.plan?.length) {
163
- log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
164
- }
165
-
166
- const verdict = runReview({ run, cwd, fingerprint })
167
-
168
- for (const g of verdict.gates) {
169
- log(`${g.ok ? '✅' : '❌'} gate: ${g.name}`)
170
- }
171
- if (!verdict.pass && verdict.failedOutput) log(verdict.failedOutput)
172
- log(verdict.pass ? '✅ verify: усі gate-и пройдено' : '❌ verify: провалено')
173
-
174
- if (state) {
175
- recordTransition({ statePath, eventsPath: flowEventsPath(cwd) }, { type: 'verify', pass: verdict.pass }, state => ({
176
- ...state,
177
- gates: verdict.gates,
178
- fingerprint: verdict.fingerprint,
179
- status: verdict.pass ? state.status : 'failed'
180
- }))
181
- }
182
-
183
- return verdict.pass ? 0 : 1
184
- }
185
-
186
- /**
187
- * Які з subworkspace-тек мають змінені файли — для авто-`--ws` у `release`.
188
- * Кожен файл відноситься до НАЙГЛИБШОГО воркспейсу-збігу, тож вкладені воркспейси
189
- * (`apps` + `apps/web`) не дають хибного «кілька воркспейсів» для `apps/web/x`.
190
- * @param {string[]} subWorkspaces теки воркспейсів без кореня (`.`)
191
- * @param {string[]} changedFiles змінені шляхи відносно кореня репо (posix)
192
- * @returns {string[]} підмножина `subWorkspaces`, під якими є зміни (у вхідному порядку)
193
- */
194
- export function matchChangedWorkspaces(subWorkspaces, changedFiles) {
195
- const byDepthDesc = subWorkspaces.toSorted((a, b) => b.length - a.length)
196
- const hit = new Set()
197
- for (const f of changedFiles) {
198
- const ws = byDepthDesc.find(w => f === w || f.startsWith(`${w}/`))
199
- if (ws) hit.add(ws)
200
- }
201
- return subWorkspaces.filter(w => hit.has(w))
202
- }
203
-
204
- /**
205
- * Додає `--ws <шлях>` до аргументів `change`, інферячи воркспейс зі змін від
206
- * `base_commit`, якщо `--ws` не задано явно. Один змінений subworkspace → авто-`--ws`;
207
- * кілька → `{ error: true }` (fail-hard, exit 1 у release); нуль / без subworkspace →
208
- * лишаємо як є (change дефолтиться на `.`). Помилку самого інференсу (недосяжний base,
209
- * збій `listWorkspaces`) трактуємо fail-soft — не блокуємо, лишаємо дефолт.
210
- * @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 ін'єкції
211
- * @returns {Promise<{ args: string[], error?: boolean }>} аргументи для `change` або `{ error: true }`
212
- */
213
- async function resolveChangeWsArgs({ rest, baseCommit, cwd, listWorkspaces, changedFilesSince, log }) {
214
- // Поважаємо явно заданий воркспейс в обох формах (`--ws x` і `--ws=x`).
215
- if (rest.includes('--ws') || rest.some(a => a.startsWith('--ws='))) return { args: rest }
216
- try {
217
- const workspaces = await listWorkspaces(cwd)
218
- const subWs = workspaces.filter(w => w !== '.')
219
- if (subWs.length === 0) return { args: rest }
220
- const hits = matchChangedWorkspaces(subWs, changedFilesSince(baseCommit, cwd))
221
- if (hits.length > 1) {
222
- log(`release: зміни у кількох воркспейсах (${hits.join(', ')}) — вкажи --ws явно`)
223
- return { args: rest, error: true }
224
- }
225
- if (hits.length === 1) {
226
- log(`release: change → воркспейс «${hits[0]}» (інферено з diff від base)`)
227
- return { args: [...rest, '--ws', hits[0]] }
228
- }
229
- return { args: rest }
230
- } catch (error) {
231
- log(`⚠️ release: інференс воркспейсу пропущено (${error instanceof Error ? error.message : String(error)})`)
232
- return { args: rest }
233
- }
234
- }
235
-
236
- /**
237
- * `flow release [--bump … --section … --message …]` — генерує `.changes` і пише
238
- * completion snapshot (§3 Ф5, §7). Потребує наявного стану (`init`).
239
- * @param {string[]} rest аргументи, що прокидаються у `n-cursor change`
240
- * @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
241
- * @returns {Promise<number>} exit code
242
- */
243
- export async function release(rest, deps = {}) {
244
- const run = deps.run ?? realRun
245
- const cwd = deps.cwd ?? processCwd()
246
- const log = deps.log ?? console.error
247
- const now = deps.now ?? Date.now
248
-
249
- const resolved = resolveActiveFlowState({ cwd, branch: deps.branch }, deps)
250
- if (!resolved.statePath) {
251
- log(`release: ${resolved.error}`)
252
- return 1
253
- }
254
- if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
255
- const effectiveCwd = resolved.worktreeDir ?? cwd
256
- const statePath = resolved.statePath
257
- const state = readState(statePath)
258
- if (!state) {
259
- log('release: стану нема — спершу `flow init`')
260
- return 1
261
- }
262
- // М'які ворота: FAIL-гейт — лише попередження, рішення за людиною.
263
- if (state.gate?.verdict === 'FAIL') {
264
- log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
265
- }
266
-
267
- const wsResolved = await resolveChangeWsArgs({
268
- rest,
269
- baseCommit: state.metadata?.base_commit ?? null,
270
- cwd: effectiveCwd,
271
- listWorkspaces: deps.listWorkspaces ?? getMonorepoProjectRootDirs,
272
- changedFilesSince: deps.changedFilesSince ?? collectChangedFilesSince,
273
- log
274
- })
275
- if (wsResolved.error) return 1
276
-
277
- const ch = run('npx', ['@nitra/cursor', 'change', ...wsResolved.args], { cwd: effectiveCwd })
278
- if ((ch.status ?? 1) !== 0) {
279
- const detail = ch.stderr ? `: ${ch.stderr.trim()}` : ''
280
- log(`release: change не вдався${detail}`)
281
- return 1
282
- }
283
-
284
- const snapshot = buildCompletionSnapshot({ ...state, status: 'done' }, now)
285
- recordTransition(
286
- { statePath, eventsPath: flowEventsPath(effectiveCwd) },
287
- { type: 'release' },
288
- state_ => ({ ...state_, status: 'done', completion: snapshot }),
289
- now
290
- )
291
- if (state.task) {
292
- writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(effectiveCwd, state.task), snapshot)
293
- }
294
- log('release: done')
295
- return 0
296
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * Серіалізація мутацій стану `flow` через **reuse** спільного `withLock`
3
- * (spec §4.1.3). `withLock` уже коректно чистить stale-локи (TTL +
4
- * `process.kill(pid,0)`) і релізить на SIGINT/SIGTERM — не дублюємо це.
5
- *
6
- * **Override для flow:** `onWaitTimeout: 'fail'` — на відміну від lint (де
7
- * прийнятно «після таймауту запустити без локу»), мутацію стану двома writer-ами
8
- * не допускаємо → fail-closed. Dedup за fingerprint вимкнено (`getFingerprint:
9
- * () => null`): flow завжди має виконатись, а не пропуститись за «тим самим
10
- * деревом».
11
- *
12
- * Лок-кеш — sibling `.worktrees/.flow-lock-<branch>/` (поряд зі станом), щоб не
13
- * залежати від глобального кеш-каталогу.
14
- */
15
- import { basename, dirname, isAbsolute, join } from 'node:path'
16
-
17
- import { withLock } from '../../utils/with-lock.mjs'
18
-
19
- /**
20
- * Виконує `runFn` під per-branch локом flow. Кидає (fail-closed), якщо лок не
21
- * вдалося взяти за `waitTimeout`.
22
- * @param {string} worktreeDir абсолютний шлях checkout (`…/.worktrees/feat-x`)
23
- * @param {() => unknown | Promise<unknown>} runFn критична секція
24
- * @param {object} [opts] прокидається у `withLock` (напр. `waitTimeout`, `pollInterval`)
25
- * @returns {Promise<unknown>} результат `runFn`
26
- */
27
- export function withFlowLock(worktreeDir, runFn, opts = {}) {
28
- if (!isAbsolute(worktreeDir)) {
29
- throw new Error(`withFlowLock: очікується абсолютний шлях (отримано: ${worktreeDir})`)
30
- }
31
- const base = basename(worktreeDir)
32
- const cacheDir = join(dirname(worktreeDir), `.flow-lock-${base}`)
33
- return withLock(`flow-${base}`, runFn, {
34
- onWaitTimeout: 'fail',
35
- cacheDir,
36
- getFingerprint: () => null,
37
- ...opts
38
- })
39
- }
@@ -1,91 +0,0 @@
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 { readState, recordTransition } from './state-store.mjs'
14
- import { resolveActiveFlowState } from './flow-resolve.mjs'
15
-
16
- /** Штрафи score за кожен тип проблеми. */
17
- const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
18
-
19
- /**
20
- * Чистий синтез вердикту з наявного стану.
21
- * @param {{ gates?: { name: string, ok: boolean }[], review?: { findings?: { severity?: string }[] } }} state стан flow
22
- * @returns {{ verdict: 'PASS' | 'CONCERNS' | 'FAIL', score: number, reasons: string[] }} вердикт
23
- */
24
- export function computeGate(state) {
25
- const gates = state.gates ?? []
26
- const findings = state.review?.findings ?? []
27
- const failedGates = gates.filter(g => !g.ok)
28
- const high = findings.filter(f => f.severity === 'high')
29
- const med = findings.filter(f => f.severity === 'med')
30
- const noVerify = gates.length === 0
31
-
32
- const reasons = []
33
- for (const g of failedGates) reasons.push(`gate «${g.name}» провалено`)
34
- if (high.length > 0) reasons.push(`${high.length} high-severity review finding(s)`)
35
- if (med.length > 0) reasons.push(`${med.length} med-severity review finding(s)`)
36
- if (noVerify) reasons.push('verify ще не запускався')
37
-
38
- let verdict = 'PASS'
39
- if (failedGates.length > 0 || high.length > 0) {
40
- verdict = 'FAIL'
41
- } else if (med.length > 0 || noVerify) {
42
- verdict = 'CONCERNS'
43
- }
44
-
45
- const penalty =
46
- PENALTY.failedGate * failedGates.length +
47
- PENALTY.high * high.length +
48
- PENALTY.med * med.length +
49
- (noVerify ? PENALTY.noVerify : 0)
50
- const score = Math.max(0, Math.min(100, 100 - penalty))
51
-
52
- return { verdict, score, reasons }
53
- }
54
-
55
- /**
56
- * `flow gate` — обчислює й фіксує вердикт у `.flow.json`.
57
- * @param {string[]} _rest аргументи (не використовуються)
58
- * @param {{ cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
59
- * @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
60
- */
61
- export async function gate(_rest, deps = {}) {
62
- const cwd0 = deps.cwd ?? processCwd()
63
- const log = deps.log ?? console.error
64
- const now = deps.now ?? Date.now
65
-
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
74
- const state = readState(statePath)
75
- if (!state) {
76
- log('gate: стану нема — спершу `flow init`')
77
- return 1
78
- }
79
-
80
- const result = computeGate(state)
81
- recordTransition(
82
- { statePath, eventsPath: flowEventsPath(cwd) },
83
- { type: 'gate', verdict: result.verdict },
84
- s => ({ ...s, gate: { ...result, at: new Date(now()).toISOString() } }),
85
- now
86
- )
87
-
88
- log(`gate: ${result.verdict} (score ${result.score})`)
89
- for (const r of result.reasons) log(` · ${r}`)
90
- return result.verdict === 'FAIL' ? 1 : 0
91
- }