@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.
- package/CHANGELOG.md +13 -0
- package/lib/models.mjs +68 -0
- package/package.json +2 -1
- package/scripts/dispatcher/index.mjs +20 -61
- package/scripts/dispatcher/lib/flow-plan.mjs +153 -0
- package/scripts/dispatcher/lib/flow-signals.mjs +235 -0
- package/scripts/dispatcher/lib/flow-verify.mjs +127 -0
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +42 -125
- package/scripts/dispatcher/lib/active.mjs +0 -222
- package/scripts/dispatcher/lib/artifact.mjs +0 -79
- package/scripts/dispatcher/lib/budget.mjs +0 -36
- package/scripts/dispatcher/lib/capability.mjs +0 -59
- package/scripts/dispatcher/lib/commands.mjs +0 -296
- package/scripts/dispatcher/lib/flow-lock.mjs +0 -39
- package/scripts/dispatcher/lib/gate.mjs +0 -91
- package/scripts/dispatcher/lib/level.mjs +0 -135
- package/scripts/dispatcher/lib/plan.mjs +0 -88
- package/scripts/dispatcher/lib/planner.mjs +0 -73
- package/scripts/dispatcher/lib/review.mjs +0 -176
- package/scripts/dispatcher/lib/reviewer.mjs +0 -44
- package/scripts/dispatcher/lib/snapshot.mjs +0 -58
- package/scripts/dispatcher/lib/spec.mjs +0 -97
|
@@ -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
|
-
}
|