@nitra/cursor 3.29.0 → 4.0.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,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
- }
@@ -1,135 +0,0 @@
1
- /**
2
- * Scale-adaptive рівень + ризик задачі (ідея з BMAD project-levels/risk-profile,
3
- * у наших термінах). `init` визначає рівень і ризик за описом; разом вони
4
- * right-size'ять, скільки adversarial-рецензентів спавнить `flow review` (і його
5
- * фокус), і які фази рекомендовані (контракт).
6
- *
7
- * Детекція — підрядками (case-insensitive), без regex (уникаємо slow-regex і
8
- * проблем зі словомежами для кирилиці).
9
- */
10
-
11
- /** L3 — велике/архітектурне. */
12
- const L3_KEYS = ['platform', 'migration', 'rewrite', 'architecture', 'enterprise', 'редизайн', 'міграц', 'переписат']
13
- /** L0 — тривіальне. ASCII-дієслова: матч цілим словом (щоб `fix` не ловило `prefix`/`fixture`). */
14
- const L0_WORD_KEYS = ['fix', 'typo', 'bump', 'rename', 'hotfix']
15
- /** L0 — кириличні ключі: підрядком (стемінг: `перейменув` ловить `перейменування`). */
16
- const L0_SUBSTR_KEYS = ['опечат', 'перейменув']
17
- /** L2 — багатофайлова фіча/рефактор. */
18
- const L2_KEYS = ['feature', 'epic', 'refactor', 'рефактор', 'фіча']
19
- /**
20
- * Сигнали складності (cross-cutting rules/checks/correctness): разом із L0-дієсловом
21
- * задача НЕ тривіальна. Перекривають L0 (мінімум L2) — щоб rules/checks-роботу не
22
- * класифікувати як trivial і не пропускати spec (беклог #2). Підрядком (case-insensitive).
23
- */
24
- const COMPLEXITY_KEYS = [
25
- 'mdc',
26
- 'policy',
27
- 'політик',
28
- 'rego',
29
- 'checker',
30
- 'чекер',
31
- 'правило',
32
- 'правила',
33
- 'rules',
34
- 'суперечн',
35
- 'інваріант',
36
- 'invariant',
37
- 'порушен',
38
- 'violation',
39
- 'кілька файл',
40
- 'декілька',
41
- 'meta-'
42
- ]
43
-
44
- /**
45
- * Чи символ — ASCII-літера/цифра (межа слова). `undefined` (край рядка) — не alnum.
46
- * @param {string | undefined} ch символ
47
- * @returns {boolean} результат
48
- */
49
- function isAsciiAlnum(ch) {
50
- return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))
51
- }
52
-
53
- /**
54
- * Чи містить `text` слово `word` із межами, що не є ASCII-alnum (без regex —
55
- * конвенція файлу). Для ASCII L0-дієслів: `fix` у `prefix`/`fixture` не рахується.
56
- * @param {string} text текст (lowercase)
57
- * @param {string} word шукане ASCII-слово (lowercase)
58
- * @returns {boolean} результат
59
- */
60
- function hasWord(text, word) {
61
- let i = text.indexOf(word)
62
- while (i !== -1) {
63
- if (!isAsciiAlnum(text[i - 1]) && !isAsciiAlnum(text[i + word.length])) return true
64
- i = text.indexOf(word, i + 1)
65
- }
66
- return false
67
- }
68
-
69
- /**
70
- * Рівень складності задачі за описом: 0 (тривіальне) … 3 (архітектурне).
71
- * Пріоритет: L3 > L0 (якщо без сигналів складності) > L2/складність > дефолт L1.
72
- * Лише сигнал складності перекриває L0-дієслово (`fix mdc checker` → L2, не L0);
73
- * L2-ключі порядок L0 не змінюють (`rename feature` лишається L0, як і раніше).
74
- * @param {string} desc опис задачі
75
- * @returns {0 | 1 | 2 | 3} рівень
76
- */
77
- export function detectLevel(desc) {
78
- const d = String(desc ?? '').toLowerCase()
79
- const has = keys => keys.some(k => d.includes(k))
80
- const isL0 = L0_WORD_KEYS.some(k => hasWord(d, k)) || L0_SUBSTR_KEYS.some(k => d.includes(k))
81
- if (has(L3_KEYS)) return 3
82
- if (isL0 && !has(COMPLEXITY_KEYS)) return 0
83
- if (has(L2_KEYS) || has(COMPLEXITY_KEYS)) return 2
84
- return 1
85
- }
86
-
87
- /**
88
- * Скільки adversarial-рецензентів спавнити для рівня (глибина review за розміром).
89
- * @param {number} level рівень 0..3
90
- * @returns {number} кількість рецензентів (1..3)
91
- */
92
- export function reviewersForLevel(level) {
93
- if (level >= 3) return 3
94
- if (level === 2) return 2
95
- return 1
96
- }
97
-
98
- /** Ключові слова високого ризику (безпека/гроші/доступи). */
99
- const HIGH_RISK_KEYS = ['security', 'auth', 'crypto', 'payment', 'secret', 'token', 'permission', 'password', 'безпек']
100
- /** Ключові слова середнього ризику (дані/незворотність). */
101
- const MED_RISK_KEYS = ['data', ' db', 'database', 'migration', 'delete', 'gateway', 'міграц', 'видален']
102
-
103
- /**
104
- * Рівень ризику задачі за описом: low | med | high.
105
- * @param {string} desc опис задачі
106
- * @returns {'low' | 'med' | 'high'} ризик
107
- */
108
- export function detectRisk(desc) {
109
- const d = String(desc ?? '').toLowerCase()
110
- const has = keys => keys.some(k => d.includes(k))
111
- if (has(HIGH_RISK_KEYS)) return 'high'
112
- if (has(MED_RISK_KEYS)) return 'med'
113
- return 'low'
114
- }
115
-
116
- /**
117
- * Скільки рецензентів диктує сам ризик.
118
- * @param {string} risk low|med|high
119
- * @returns {number} 1..3
120
- */
121
- export function reviewersForRisk(risk) {
122
- if (risk === 'high') return 3
123
- if (risk === 'med') return 2
124
- return 1
125
- }
126
-
127
- /**
128
- * Підсумкова глибина review: максимум вимог за рівнем і за ризиком (кап 3).
129
- * @param {number} level рівень 0..3
130
- * @param {string} [risk] low|med|high
131
- * @returns {number} кількість рецензентів (1..3)
132
- */
133
- export function reviewersFor(level, risk) {
134
- return Math.min(3, Math.max(reviewersForLevel(level), reviewersForRisk(risk)))
135
- }
@@ -1,88 +0,0 @@
1
- /**
2
- * `flow plan [--panel] [<plan.md>]` — фаза плану (Пасивний Турнікет, lifecycle
3
- * §4). Фіксує `docs/plans/<date>-<slug>.md`: дзеркалить кроки (`## Кроки`) у
4
- * `.flow.json plan[]`, виставляє `status: planned`, верифікує ланцюг через
5
- * read-only `trace`. Код не пише; лінки front-matter (`spec`/`flow`) пише агент.
6
- *
7
- * Brainstorm: human↔agent (агент пише plan-doc у діалозі) або agent↔agent
8
- * (`--panel`: панель персон → суддя синтезує кроки).
9
- */
10
- import { existsSync, readFileSync } from 'node:fs'
11
- import { cwd as processCwd } from 'node:process'
12
-
13
- import { extractSteps, resolveArtifact, verifyTrace } from './artifact.mjs'
14
- import { flowEventsPath } from './events.mjs'
15
- import { parsePlan } from './planner.mjs'
16
- import { runPanel } from './plan-panel.mjs'
17
- import { createRunner } from './subagent-runner.mjs'
18
- import { readState, recordTransition } from './state-store.mjs'
19
- import { resolveActiveFlowState } from './flow-resolve.mjs'
20
-
21
- /**
22
- * @param {string[]} rest аргументи (`--panel`, опц. `<plan.md>`)
23
- * @param {{ cwd?: string, log?: (m: string) => void, runner?: object, trace?: (cwd: string) => number, now?: () => number }} [deps] ін'єкції
24
- * @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку/невалідний план)
25
- */
26
- export async function plan(rest, deps = {}) {
27
- const cwd0 = deps.cwd ?? processCwd()
28
- const log = deps.log ?? console.error
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
37
- const state = readState(statePath)
38
- if (!state) {
39
- log('plan: стану нема — спершу `flow init`')
40
- return 1
41
- }
42
- if (state.status !== 'spec' && !state.spec_doc) {
43
- log('plan: дизайн ще не зафіксовано — рекомендовано спершу `flow spec` (не блокує)')
44
- }
45
-
46
- const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'plans', state.branch)
47
- let steps
48
- if (rest.includes('--panel')) {
49
- let runner = deps.runner
50
- if (!runner) {
51
- try {
52
- runner = await createRunner(deps)
53
- } catch (error) {
54
- log(`plan: ${error.message}`)
55
- return 1
56
- }
57
- }
58
- steps = await runPanel({ task: state.branch, cwd, runner, log, mode: 'plan' })
59
- if (!steps) return 1
60
- } else {
61
- if (!doc || !existsSync(doc)) {
62
- log('plan: нема docs/plans/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
63
- return 1
64
- }
65
- steps = extractSteps(readFileSync(doc, 'utf8'))
66
- }
67
-
68
- let normalized
69
- try {
70
- normalized = parsePlan(JSON.stringify(steps))
71
- } catch (error) {
72
- log(`plan: ${error.message}`)
73
- return 1
74
- }
75
-
76
- if (!verifyTrace(cwd, deps.trace)) {
77
- log('⚠️ plan: trace виявив розрив ланцюга — перевір лінки spec/plan/flow')
78
- }
79
-
80
- recordTransition(
81
- { statePath, eventsPath: flowEventsPath(cwd) },
82
- { type: 'plan', steps: normalized.length },
83
- s => ({ ...s, plan: normalized, plan_doc: doc ?? null, status: 'planned' }),
84
- deps.now ?? Date.now
85
- )
86
- log(`plan: зафіксовано ${normalized.length} кроків → status: planned`)
87
- return 0
88
- }