@nitra/cursor 3.2.3 → 3.4.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 +12 -0
- package/package.json +1 -1
- package/rules/flow/flow.mdc +51 -6
- package/scripts/dispatcher/index.mjs +10 -4
- package/scripts/dispatcher/lib/artifact.mjs +67 -0
- package/scripts/dispatcher/lib/commands.mjs +12 -3
- package/scripts/dispatcher/lib/level.mjs +41 -0
- package/scripts/dispatcher/lib/plan-panel.mjs +76 -0
- package/scripts/dispatcher/lib/plan.mjs +80 -0
- package/scripts/dispatcher/lib/planner.mjs +7 -0
- package/scripts/dispatcher/lib/review.mjs +150 -0
- package/scripts/dispatcher/lib/spec.mjs +68 -0
- package/scripts/dispatcher/trace.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.0] - 2026-06-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- flow: команда review (adversarial diff-review за рівнем) + scale-adaptive level в init (L0–L3, керує глибиною review)
|
|
8
|
+
|
|
9
|
+
## [3.3.0] - 2026-06-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- flow: фази spec і plan (brainstorm human↔agent + agent↔agent --panel), trace простежує лінк plan→flow, verify м'яко попереджає про відсутній план
|
|
14
|
+
|
|
3
15
|
## [3.2.3] - 2026-06-01
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/package.json
CHANGED
package/rules/flow/flow.mdc
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Контракт Пасивного Турнікета n-cursor flow — IDE-агент сам пише код, але
|
|
2
|
+
description: Контракт Пасивного Турнікета n-cursor flow — IDE-агент сам пише код, але ізолює/планує/перевіряє/релізить через flow init/spec/plan/verify/release.
|
|
3
3
|
globs:
|
|
4
4
|
alwaysApply: true
|
|
5
5
|
---
|
|
@@ -20,11 +20,43 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Створює ізольований worktree (`.worktrees/<branch>/`) і стан задачі. Якщо ти
|
|
23
|
-
вже в worktree — новий не вкладається.
|
|
23
|
+
вже в worktree — новий не вкладається. `init` визначає **рівень** задачі
|
|
24
|
+
(L0 тривіальне … L3 архітектурне) за описом: для L0 (fix/typo/bump) фази
|
|
25
|
+
Spec/План можна пропустити; для L≥1 вони рекомендовані. Рівень також керує
|
|
26
|
+
глибиною `review`.
|
|
24
27
|
|
|
25
|
-
2.
|
|
28
|
+
2. **Spec (дизайн)** — рекомендовано, не блокує. Brainstorm нашими термінами
|
|
29
|
+
(НЕ викликаючи superpowers):
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
- **human↔agent (дефолт):** питання по одному (перевага multiple-choice) →
|
|
32
|
+
2-3 підходи з рекомендацією → дизайн секціями з апрувом людини;
|
|
33
|
+
- **agent↔agent:** `npx @nitra/cursor flow spec --panel` — панель персон
|
|
34
|
+
(architect/skeptic/tester) → суддя-синтез; презентуй синтез людині.
|
|
35
|
+
|
|
36
|
+
Збережи дизайн → `docs/specs/<date>-<slug>.md` (`kind: nitra-spec`,
|
|
37
|
+
`plan: null`), тоді зафіксуй:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
npx @nitra/cursor flow spec
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
3. **План** — декомпозиція дизайну в кроки:
|
|
44
|
+
|
|
45
|
+
- збережи `docs/plans/<date>-<slug>.md` (`kind: nitra-plan`, `spec:` → лінк на
|
|
46
|
+
spec, `flow:` → шлях `.flow.json`; секція `## Кроки` — нумерований список
|
|
47
|
+
`N. <task> — acceptance: <критерій>`);
|
|
48
|
+
- зафіксуй (`--panel` — для agent↔agent синтезу кроків):
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
npx @nitra/cursor flow plan
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Команда дзеркалить кроки у `.flow.json` (`status: planned`) і запускає `trace`
|
|
55
|
+
для перевірки ланцюга spec↔plan↔flow. `verify` без плану лише попередить.
|
|
56
|
+
|
|
57
|
+
4. **Пиши код** сам, кроками. TDD: спершу падаючі тести, тоді реалізація.
|
|
58
|
+
|
|
59
|
+
5. **Перевіряй** після кожного логічного кроку:
|
|
28
60
|
|
|
29
61
|
```
|
|
30
62
|
npx @nitra/cursor flow verify
|
|
@@ -33,10 +65,21 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
|
|
|
33
65
|
Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
|
|
34
66
|
виводом проваленого gate.
|
|
35
67
|
|
|
36
|
-
|
|
68
|
+
6. **Review (adversarial)** — рекомендовано перед release:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
npx @nitra/cursor flow review
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Незалежний субагент читає ЛИШЕ `git diff` від `base_commit` і шукає логічні
|
|
75
|
+
баги/ризики, яких не ловлять механічні гейти. Кількість рецензентів — за
|
|
76
|
+
рівнем (L0→1 … L3→3). Findings пишуться у `.flow.json` (не блокує); high-
|
|
77
|
+
severity варто виправити перед фінішем.
|
|
78
|
+
|
|
79
|
+
7. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
|
|
37
80
|
**3 спроби**; якщо не вдається — зупинись і поклич людину.
|
|
38
81
|
|
|
39
|
-
|
|
82
|
+
8. **Фініш** — лише після зеленого `verify`:
|
|
40
83
|
|
|
41
84
|
```
|
|
42
85
|
npx @nitra/cursor flow release --bump <patch|minor|major> --section <Added|Changed|Fixed> --message "<що зроблено>"
|
|
@@ -49,3 +92,5 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
|
|
|
49
92
|
- Не обходь `verify` — це єдиний критерій «готово».
|
|
50
93
|
- Не редагуй `version` чи `CHANGELOG.md` вручну (це робить CI з `.changes/`).
|
|
51
94
|
- Не коміть стан `.worktrees/<branch>.flow.json` у гілку — він поза git.
|
|
95
|
+
- Не лінкуй spec↔plan неконсистентно — тримай `spec.plan`/`plan.spec`/`plan.flow`
|
|
96
|
+
у front-matter; `flow spec`/`flow plan` перевіряють їх через `trace`.
|
|
@@ -9,11 +9,17 @@
|
|
|
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 { plan } from './lib/plan.mjs'
|
|
13
|
+
import { review } from './lib/review.mjs'
|
|
14
|
+
import { spec } from './lib/spec.mjs'
|
|
12
15
|
|
|
13
16
|
const USAGE = [
|
|
14
17
|
'Usage:',
|
|
15
|
-
' npx @nitra/cursor flow init "<опис>" # Фасад A: worktree + .flow.json',
|
|
18
|
+
' npx @nitra/cursor flow init "<опис>" # Фасад A: worktree + .flow.json (+ level)',
|
|
19
|
+
' npx @nitra/cursor flow spec [--panel] # Фасад A: фаза дизайну → docs/specs/<…>',
|
|
20
|
+
' npx @nitra/cursor flow plan [--panel] # Фасад A: фаза плану → docs/plans/<…> + state',
|
|
16
21
|
' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
|
|
22
|
+
' npx @nitra/cursor flow review # Фасад A: adversarial diff-review (за level)',
|
|
17
23
|
' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
|
|
18
24
|
' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
|
|
19
25
|
' npx @nitra/cursor flow resume # продовжити з чекпойнта',
|
|
@@ -22,13 +28,13 @@ const USAGE = [
|
|
|
22
28
|
].join('\n')
|
|
23
29
|
|
|
24
30
|
/** Підкоманди flow. */
|
|
25
|
-
export const SUBCOMMANDS = ['init', 'verify', 'release', 'run', 'resume', 'cancel', 'repair']
|
|
31
|
+
export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'release', 'run', 'resume', 'cancel', 'repair']
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
|
-
* Усі handler-и реальні (Ф2 Турнікет + Ф4 Активний Раннер).
|
|
34
|
+
* Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
|
|
29
35
|
* @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
|
|
30
36
|
*/
|
|
31
|
-
export const DEFAULT_HANDLERS = { init, verify, release, run, resume, cancel, repair }
|
|
37
|
+
export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, release, run, resume, cancel, repair }
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
|
|
@@ -0,0 +1,67 @@
|
|
|
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 } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { runTraceCli } from '../trace.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Найсвіжіший `docs/<kind>/*.md` (лексикографічно — дата у префіксі назви).
|
|
16
|
+
* @param {string} cwd корінь worktree
|
|
17
|
+
* @param {'specs' | 'plans'} kind підкаталог `docs`
|
|
18
|
+
* @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
|
|
19
|
+
*/
|
|
20
|
+
export function resolveArtifact(cwd, kind) {
|
|
21
|
+
const dir = join(cwd, 'docs', kind)
|
|
22
|
+
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
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
|
|
30
|
+
const ACCEPTANCE_MARK = '— acceptance:'
|
|
31
|
+
/** Лише цифри — перевірка нумерації кроку (лінійний, без backtracking). */
|
|
32
|
+
const DIGITS_RE = /^\d+$/u
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Кроки зі секції плану — нумерований список `N. <task> — acceptance: <crit>`.
|
|
36
|
+
* Best-effort парсинг через `indexOf` (без regex-backtracking): рядки поза
|
|
37
|
+
* форматом ігноруються.
|
|
38
|
+
* @param {string} text вміст plan-doc
|
|
39
|
+
* @returns {{ task: string, acceptance?: string }[]} кроки у порядку появи
|
|
40
|
+
*/
|
|
41
|
+
export function extractSteps(text) {
|
|
42
|
+
const steps = []
|
|
43
|
+
for (const raw of String(text).split('\n')) {
|
|
44
|
+
const line = raw.trim()
|
|
45
|
+
const dot = line.indexOf('. ')
|
|
46
|
+
if (dot <= 0 || !DIGITS_RE.test(line.slice(0, dot))) continue
|
|
47
|
+
const body = line.slice(dot + 2).trim()
|
|
48
|
+
const sep = body.toLowerCase().indexOf(ACCEPTANCE_MARK)
|
|
49
|
+
if (sep === -1) {
|
|
50
|
+
steps.push({ task: body })
|
|
51
|
+
} else {
|
|
52
|
+
steps.push({ task: body.slice(0, sep).trim(), acceptance: body.slice(sep + ACCEPTANCE_MARK.length).trim() })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return steps
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read-only перевірка цілісності ланцюга артефактів (не мутує — лише сигнал).
|
|
60
|
+
* @param {string} cwd корінь worktree
|
|
61
|
+
* @param {(cwd: string) => number} [runTrace] runner trace (0 — цілісно, 1 — розрив); ін'єкція для тестів
|
|
62
|
+
* @returns {boolean} true, якщо ланцюг цілісний
|
|
63
|
+
*/
|
|
64
|
+
export function verifyTrace(cwd, runTrace) {
|
|
65
|
+
const run = runTrace ?? (c => runTraceCli([], { cwd: c, log: () => {} }))
|
|
66
|
+
return run(cwd) === 0
|
|
67
|
+
}
|
|
@@ -12,6 +12,7 @@ import { cwd as processCwd } from 'node:process'
|
|
|
12
12
|
import { worktreePaths } from '../../lib/worktree.mjs'
|
|
13
13
|
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
14
14
|
import { flowEventsPath } from './events.mjs'
|
|
15
|
+
import { detectLevel } from './level.mjs'
|
|
15
16
|
import { runReview } from './reviewer.mjs'
|
|
16
17
|
import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
|
|
17
18
|
import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
|
|
@@ -101,14 +102,16 @@ export async function init(rest, deps = {}) {
|
|
|
101
102
|
const now = deps.now ?? Date.now
|
|
102
103
|
const log = deps.log ?? console.error
|
|
103
104
|
const statePath = flowStatePath(ew.worktreeDir)
|
|
105
|
+
const level = detectLevel(ew.desc)
|
|
104
106
|
writeState(statePath, {
|
|
105
107
|
branch: ew.branch,
|
|
106
108
|
status: 'in_progress',
|
|
107
109
|
started_at: new Date(now()).toISOString(),
|
|
108
110
|
metadata: { base_commit: ew.baseCommit },
|
|
111
|
+
level,
|
|
109
112
|
plan: []
|
|
110
113
|
})
|
|
111
|
-
log(`init: ${ew.branch} → ${statePath}`)
|
|
114
|
+
log(`init: ${ew.branch} (level ${level}) → ${statePath}`)
|
|
112
115
|
return 0
|
|
113
116
|
}
|
|
114
117
|
|
|
@@ -126,6 +129,13 @@ export async function verify(_rest, deps = {}) {
|
|
|
126
129
|
const log = deps.log ?? console.error
|
|
127
130
|
const fingerprint = deps.fingerprint ?? (() => worktreeFingerprint())
|
|
128
131
|
|
|
132
|
+
const statePath = flowStatePath(cwd)
|
|
133
|
+
const state = readState(statePath)
|
|
134
|
+
// М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
|
|
135
|
+
if (state && !(state.plan?.length)) {
|
|
136
|
+
log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
const verdict = runReview({ run, cwd, fingerprint })
|
|
130
140
|
|
|
131
141
|
for (const g of verdict.gates) {
|
|
@@ -134,8 +144,7 @@ export async function verify(_rest, deps = {}) {
|
|
|
134
144
|
if (!verdict.pass && verdict.failedOutput) log(verdict.failedOutput)
|
|
135
145
|
log(verdict.pass ? '✅ verify: усі gate-и пройдено' : '❌ verify: провалено')
|
|
136
146
|
|
|
137
|
-
|
|
138
|
-
if (readState(statePath)) {
|
|
147
|
+
if (state) {
|
|
139
148
|
recordTransition(
|
|
140
149
|
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
141
150
|
{ type: 'verify', pass: verdict.pass },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scale-adaptive рівень задачі (ідея з BMAD project-levels, у наших термінах).
|
|
3
|
+
* `init` визначає рівень за описом; рівень right-size'ить, скільки adversarial-
|
|
4
|
+
* рецензентів спавнить `flow review`, і які фази рекомендовані (контракт).
|
|
5
|
+
*
|
|
6
|
+
* Детекція — підрядками (case-insensitive), без regex (уникаємо slow-regex і
|
|
7
|
+
* проблем зі словомежами для кирилиці).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** L3 — велике/архітектурне. */
|
|
11
|
+
const L3_KEYS = ['platform', 'migration', 'rewrite', 'architecture', 'enterprise', 'редизайн', 'міграц', 'переписат']
|
|
12
|
+
/** L0 — тривіальне. */
|
|
13
|
+
const L0_KEYS = ['fix', 'typo', 'bump', 'rename', 'hotfix', 'опечат', 'перейменув']
|
|
14
|
+
/** L2 — багатофайлова фіча/рефактор. */
|
|
15
|
+
const L2_KEYS = ['feature', 'epic', 'refactor', 'рефактор', 'фіча']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Рівень складності задачі за описом: 0 (тривіальне) … 3 (архітектурне).
|
|
19
|
+
* Пріоритет: L3 > L0 > L2 > дефолт L1.
|
|
20
|
+
* @param {string} desc опис задачі
|
|
21
|
+
* @returns {0 | 1 | 2 | 3} рівень
|
|
22
|
+
*/
|
|
23
|
+
export function detectLevel(desc) {
|
|
24
|
+
const d = String(desc ?? '').toLowerCase()
|
|
25
|
+
const has = keys => keys.some(k => d.includes(k))
|
|
26
|
+
if (has(L3_KEYS)) return 3
|
|
27
|
+
if (has(L0_KEYS)) return 0
|
|
28
|
+
if (has(L2_KEYS)) return 2
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Скільки adversarial-рецензентів спавнити для рівня (глибина review за ризиком).
|
|
34
|
+
* @param {number} level рівень 0..3
|
|
35
|
+
* @returns {number} кількість рецензентів (1..3)
|
|
36
|
+
*/
|
|
37
|
+
export function reviewersForLevel(level) {
|
|
38
|
+
if (level >= 3) return 3
|
|
39
|
+
if (level === 2) return 2
|
|
40
|
+
return 1
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent↔agent brainstorm (bmad party-mode + superpowers dispatching у наших
|
|
3
|
+
* термінах): персони-субагенти пропонують погляди, суддя-субагент синтезує одну
|
|
4
|
+
* відповідь. Спільний для фази `spec` (mode: 'spec' — підходи) і `plan`
|
|
5
|
+
* (mode: 'plan' — JSON-кроки). Перевикористовує runner-інтерфейс Фасада B
|
|
6
|
+
* (`runStep(prompt, opts) => { ok, output }`, як `planner.mjs`/`active.mjs`).
|
|
7
|
+
*
|
|
8
|
+
* HITL: panel лише ПОВЕРТАЄ синтез — апрув людини й збереження артефакту робить
|
|
9
|
+
* агент за контрактом `flow.mdc` (фіксація — окрема команда `flow spec`/`flow plan`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Персони панелі: [ім'я, системний промпт]. */
|
|
13
|
+
const PERSONAS = [
|
|
14
|
+
['architect', 'Ти — architect. Запропонуй найчистішу архітектуру розв’язання. Стисло, по суті.'],
|
|
15
|
+
['skeptic', 'Ти — skeptic. Назви ризики, граничні випадки і що може піти не так. Стисло.'],
|
|
16
|
+
['tester', 'Ти — tester. Опиши, які тести доведуть коректність. Стисло.']
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Промпт судді за режимом.
|
|
21
|
+
* @param {'spec' | 'plan'} mode режим синтезу
|
|
22
|
+
* @param {string} proposals склеєні думки персон
|
|
23
|
+
* @param {string} task опис задачі
|
|
24
|
+
* @returns {string} промпт судді
|
|
25
|
+
*/
|
|
26
|
+
function judgePrompt(mode, proposals, task) {
|
|
27
|
+
const head =
|
|
28
|
+
mode === 'plan'
|
|
29
|
+
? [
|
|
30
|
+
'Синтезуй із думок персон ОДИН покроковий план реалізації.',
|
|
31
|
+
'Кожен крок — ≤ 5 хв розробки, з критерієм приймання.',
|
|
32
|
+
'Поверни ЛИШЕ JSON-масив без коментарів: [{ "task": "...", "acceptance": "..." }, ...].'
|
|
33
|
+
]
|
|
34
|
+
: [
|
|
35
|
+
'Синтезуй із думок персон 2-3 підходи до розв’язання з рекомендацією й коротким дизайном.',
|
|
36
|
+
'Поверни людино-читабельний текст (Markdown).'
|
|
37
|
+
]
|
|
38
|
+
return [...head, '', proposals, '', `Задача: ${task}`].join('\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Проводить панель і повертає синтез.
|
|
43
|
+
* @param {{ task: string, cwd: string, runner: { runStep: (p: string, o?: object) => { ok: boolean, output: string } | Promise<{ ok: boolean, output: string }> }, log?: (m: string) => void, mode?: 'spec' | 'plan' }} input ін'єкції
|
|
44
|
+
* @returns {Promise<{ task: string, acceptance?: string }[] | string | null>} кроки (plan), текст (spec) або null (фейл)
|
|
45
|
+
*/
|
|
46
|
+
export async function runPanel({ task, cwd, runner, log = console.error, mode = 'plan' }) {
|
|
47
|
+
if (!runner) {
|
|
48
|
+
log('panel: нема runner — режим --panel недоступний')
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
const proposals = await Promise.all(
|
|
52
|
+
PERSONAS.map(async ([name, sys]) => {
|
|
53
|
+
const r = await runner.runStep(`${sys}\n\nЗадача: ${task}`, { cwd })
|
|
54
|
+
return `### ${name}\n${r.ok ? r.output : '(порожньо)'}`
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
const judge = await runner.runStep(judgePrompt(mode, proposals.join('\n\n'), task), { cwd })
|
|
58
|
+
if (!judge.ok) {
|
|
59
|
+
log('panel: суддя-синтез завершився помилкою')
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
if (mode === 'spec') return judge.output
|
|
63
|
+
|
|
64
|
+
const start = judge.output.indexOf('[')
|
|
65
|
+
const end = judge.output.lastIndexOf(']')
|
|
66
|
+
if (start === -1 || end === -1 || end < start) {
|
|
67
|
+
log('panel: суддя не повернув JSON-план')
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(judge.output.slice(start, end + 1))
|
|
72
|
+
} catch {
|
|
73
|
+
log('panel: невалідний JSON синтезу')
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { flowStatePath, readState, recordTransition } from './state-store.mjs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[]} rest аргументи (`--panel`, опц. `<plan.md>`)
|
|
22
|
+
* @param {{ cwd?: string, log?: (m: string) => void, runner?: object, trace?: (cwd: string) => number, now?: () => number }} [deps] ін'єкції
|
|
23
|
+
* @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку/невалідний план)
|
|
24
|
+
*/
|
|
25
|
+
export async function plan(rest, deps = {}) {
|
|
26
|
+
const cwd = deps.cwd ?? processCwd()
|
|
27
|
+
const log = deps.log ?? console.error
|
|
28
|
+
const statePath = flowStatePath(cwd)
|
|
29
|
+
const state = readState(statePath)
|
|
30
|
+
if (!state) {
|
|
31
|
+
log('plan: стану нема — спершу `flow init`')
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
if (state.status !== 'spec' && !state.spec_doc) {
|
|
35
|
+
log('plan: дизайн ще не зафіксовано — рекомендовано спершу `flow spec` (не блокує)')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'plans')
|
|
39
|
+
let steps
|
|
40
|
+
if (rest.includes('--panel')) {
|
|
41
|
+
let runner = deps.runner
|
|
42
|
+
if (!runner) {
|
|
43
|
+
try {
|
|
44
|
+
runner = await createRunner(deps)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
log(`plan: ${error.message}`)
|
|
47
|
+
return 1
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
steps = await runPanel({ task: state.branch, cwd, runner, log, mode: 'plan' })
|
|
51
|
+
if (!steps) return 1
|
|
52
|
+
} else {
|
|
53
|
+
if (!doc || !existsSync(doc)) {
|
|
54
|
+
log('plan: нема docs/plans/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
steps = extractSteps(readFileSync(doc, 'utf8'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let normalized
|
|
61
|
+
try {
|
|
62
|
+
normalized = parsePlan(JSON.stringify(steps))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log(`plan: ${error.message}`)
|
|
65
|
+
return 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!verifyTrace(cwd, deps.trace)) {
|
|
69
|
+
log('⚠️ plan: trace виявив розрив ланцюга — перевір лінки spec/plan/flow')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
recordTransition(
|
|
73
|
+
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
74
|
+
{ type: 'plan', steps: normalized.length },
|
|
75
|
+
s => ({ ...s, plan: normalized, plan_doc: doc ?? null, status: 'planned' }),
|
|
76
|
+
deps.now ?? Date.now
|
|
77
|
+
)
|
|
78
|
+
log(`plan: зафіксовано ${normalized.length} кроків → status: planned`)
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Нормалізує кроки до `{ step, task, status: 'pending', retry_count: 0 }`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/** Заборонені плейсхолдер-значення `task` (план із ними — не план, fail-closed). */
|
|
8
|
+
const PLACEHOLDER = /^(tbd|todo|fixme|\.\.\.|placeholder)$/i
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* Системно-користувацький промпт планувальника.
|
|
9
12
|
* @param {string} task опис фічі
|
|
@@ -45,6 +48,10 @@ export function parsePlan(text) {
|
|
|
45
48
|
if (!task || typeof task !== 'string') {
|
|
46
49
|
throw new Error(`planner: крок ${i} без текстового поля task — fail-closed`)
|
|
47
50
|
}
|
|
51
|
+
const trimmed = task.trim()
|
|
52
|
+
if (!trimmed || PLACEHOLDER.test(trimmed)) {
|
|
53
|
+
throw new Error(`planner: крок ${i} — placeholder/порожній task (${task}) — fail-closed`)
|
|
54
|
+
}
|
|
48
55
|
const step = { step: i, task, status: 'pending', retry_count: 0 }
|
|
49
56
|
if (s?.acceptance) step.acceptance = String(s.acceptance)
|
|
50
57
|
return step
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flow review` — adversarial-перевірка коду ПІСЛЯ написання (ідея з BMAD
|
|
3
|
+
* quick-dev: self-check → adversarial-review). Незалежний субагент читає ЛИШЕ
|
|
4
|
+
* `git diff base_commit` і шукає логічні баги/ризики, яких не ловлять механічні
|
|
5
|
+
* гейти `verify` (lint+coverage). Findings пишуться у `.flow.json`; команда
|
|
6
|
+
* інформативна (м'які ворота — завжди код 0). Кількість рецензентів — за `level`.
|
|
7
|
+
*
|
|
8
|
+
* Уся IO (`run`/`runner`/`now`) ін'єктується — тестується без git/LLM.
|
|
9
|
+
*/
|
|
10
|
+
import { cwd as processCwd } from 'node:process'
|
|
11
|
+
|
|
12
|
+
import { realRun } from './commands.mjs'
|
|
13
|
+
import { flowEventsPath } from './events.mjs'
|
|
14
|
+
import { reviewersForLevel } from './level.mjs'
|
|
15
|
+
import { flowStatePath, readState, recordTransition } from './state-store.mjs'
|
|
16
|
+
import { createRunner } from './subagent-runner.mjs'
|
|
17
|
+
|
|
18
|
+
/** Ліміт diff у промпті (символів) — щоб не роздувати контекст рецензента. */
|
|
19
|
+
const DIFF_LIMIT = 12_000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Текст diff від base: `base...HEAD` (закомічене) + `git diff` (робоче дерево).
|
|
23
|
+
* @param {string} base базовий комміт
|
|
24
|
+
* @param {(cmd: string, args: string[], opts: object) => { stdout: string }} run git-runner
|
|
25
|
+
* @param {string} cwd worktree
|
|
26
|
+
* @returns {string} склеєний diff (trim)
|
|
27
|
+
*/
|
|
28
|
+
export function diffFromBase(base, run, cwd) {
|
|
29
|
+
const committed = run('git', ['diff', `${base}...HEAD`], { cwd })
|
|
30
|
+
const working = run('git', ['diff'], { cwd })
|
|
31
|
+
return `${committed.stdout ?? ''}\n${working.stdout ?? ''}`.trim()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Промпт adversarial-рецензента (читає ЛИШЕ diff).
|
|
36
|
+
* @param {string} diff текст diff
|
|
37
|
+
* @returns {string} промпт
|
|
38
|
+
*/
|
|
39
|
+
export function reviewerPrompt(diff) {
|
|
40
|
+
return [
|
|
41
|
+
'Ти — прискіпливий adversarial-рецензент. Знайди баги, ризики й smells ЛИШЕ в цьому diff.',
|
|
42
|
+
'Поверни ЛИШЕ JSON-масив: [{ "severity": "high|med|low", "file": "...", "issue": "...", "suggestion": "..." }].',
|
|
43
|
+
'Якщо проблем нема — поверни [].',
|
|
44
|
+
'',
|
|
45
|
+
diff.slice(0, DIFF_LIMIT)
|
|
46
|
+
].join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Парсить findings із відповіді рецензента. Fail-soft: сміття/невалідний JSON → [].
|
|
51
|
+
* @param {string} text відповідь субагента
|
|
52
|
+
* @returns {{ severity?: string, file?: string, issue?: string, suggestion?: string }[]} findings
|
|
53
|
+
*/
|
|
54
|
+
export function parseFindings(text) {
|
|
55
|
+
const s = String(text)
|
|
56
|
+
const start = s.indexOf('[')
|
|
57
|
+
const end = s.lastIndexOf(']')
|
|
58
|
+
if (start === -1 || end === -1 || end < start) return []
|
|
59
|
+
try {
|
|
60
|
+
const arr = JSON.parse(s.slice(start, end + 1))
|
|
61
|
+
return Array.isArray(arr) ? arr : []
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Дедуплікує findings за (file, issue).
|
|
69
|
+
* @param {object[]} findings вхідні
|
|
70
|
+
* @returns {object[]} без дублікатів
|
|
71
|
+
*/
|
|
72
|
+
export function dedupeFindings(findings) {
|
|
73
|
+
const seen = new Set()
|
|
74
|
+
const out = []
|
|
75
|
+
for (const f of findings) {
|
|
76
|
+
const key = `${f?.file ?? ''}::${f?.issue ?? ''}`
|
|
77
|
+
if (seen.has(key)) continue
|
|
78
|
+
seen.add(key)
|
|
79
|
+
out.push(f)
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Іконка за severity finding-а.
|
|
86
|
+
* @param {string} severity рівень
|
|
87
|
+
* @returns {string} емодзі
|
|
88
|
+
*/
|
|
89
|
+
function severityIcon(severity) {
|
|
90
|
+
if (severity === 'high') return '🔴'
|
|
91
|
+
if (severity === 'med') return '🟡'
|
|
92
|
+
return '⚪'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `flow review` — спавнить adversarial-рецензента(ів) на diff від base.
|
|
97
|
+
* @param {string[]} _rest аргументи (не використовуються)
|
|
98
|
+
* @param {{ cwd?: string, log?: (m: string) => void, run?: (cmd: string, args: string[], opts: object) => { stdout: string }, runner?: object, now?: () => number }} [deps] ін'єкції
|
|
99
|
+
* @returns {Promise<number>} exit code (0 завжди — інформативна; 1 лише якщо нема стану/runner)
|
|
100
|
+
*/
|
|
101
|
+
export async function review(_rest, deps = {}) {
|
|
102
|
+
const cwd = deps.cwd ?? processCwd()
|
|
103
|
+
const log = deps.log ?? console.error
|
|
104
|
+
const run = deps.run ?? realRun
|
|
105
|
+
const now = deps.now ?? Date.now
|
|
106
|
+
|
|
107
|
+
const statePath = flowStatePath(cwd)
|
|
108
|
+
const state = readState(statePath)
|
|
109
|
+
if (!state) {
|
|
110
|
+
log('review: стану нема — спершу `flow init`')
|
|
111
|
+
return 1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const base = state.metadata?.base_commit ?? 'HEAD~1'
|
|
115
|
+
const diff = diffFromBase(base, run, cwd)
|
|
116
|
+
if (!diff) {
|
|
117
|
+
log('review: нема змін від base — нічого ревʼювити')
|
|
118
|
+
return 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let runner = deps.runner
|
|
122
|
+
if (!runner) {
|
|
123
|
+
try {
|
|
124
|
+
runner = await createRunner(deps)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
log(`review: ${error.message}`)
|
|
127
|
+
return 1
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const reviewers = reviewersForLevel(state.level ?? 1)
|
|
132
|
+
const prompt = reviewerPrompt(diff)
|
|
133
|
+
const results = await Promise.all(Array.from({ length: reviewers }, () => runner.runStep(prompt, { cwd })))
|
|
134
|
+
const findings = dedupeFindings(results.flatMap(r => (r.ok ? parseFindings(r.output) : [])))
|
|
135
|
+
|
|
136
|
+
recordTransition(
|
|
137
|
+
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
138
|
+
{ type: 'review', findings: findings.length },
|
|
139
|
+
s => ({ ...s, review: { at: new Date(now()).toISOString(), reviewers, findings } }),
|
|
140
|
+
now
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for (const f of findings) {
|
|
144
|
+
log(`${severityIcon(f.severity)} ${f.file ?? '?'}: ${f.issue ?? ''}`)
|
|
145
|
+
}
|
|
146
|
+
const high = findings.filter(f => f.severity === 'high').length
|
|
147
|
+
if (high > 0) log(`⚠️ review: ${high} high-severity — рекомендовано виправити перед release`)
|
|
148
|
+
log(`review: ${findings.length} findings (рецензентів: ${reviewers})`)
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flow spec [--panel] [<spec.md>]` — фаза дизайну (Пасивний Турнікет, lifecycle
|
|
3
|
+
* §3). Фіксує `docs/specs/<date>-<slug>.md` (дизайн із brainstorm) у стані й
|
|
4
|
+
* верифікує ланцюг через read-only `trace`. Код не пише; лінки front-matter
|
|
5
|
+
* пише агент за контрактом `flow.mdc`.
|
|
6
|
+
*
|
|
7
|
+
* Brainstorm: human↔agent — у діалозі IDE-агента (контракт); agent↔agent —
|
|
8
|
+
* `--panel` (панель персон → суддя, синтез презентується людині).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { cwd as processCwd } from 'node:process'
|
|
12
|
+
|
|
13
|
+
import { resolveArtifact, verifyTrace } from './artifact.mjs'
|
|
14
|
+
import { flowEventsPath } from './events.mjs'
|
|
15
|
+
import { runPanel } from './plan-panel.mjs'
|
|
16
|
+
import { createRunner } from './subagent-runner.mjs'
|
|
17
|
+
import { flowStatePath, readState, recordTransition } from './state-store.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string[]} rest аргументи (`--panel`, опц. `<spec.md>`)
|
|
21
|
+
* @param {{ cwd?: string, log?: (m: string) => void, runner?: object, trace?: (cwd: string) => number, now?: () => number }} [deps] ін'єкції
|
|
22
|
+
* @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку)
|
|
23
|
+
*/
|
|
24
|
+
export async function spec(rest, deps = {}) {
|
|
25
|
+
const cwd = deps.cwd ?? processCwd()
|
|
26
|
+
const log = deps.log ?? console.error
|
|
27
|
+
const statePath = flowStatePath(cwd)
|
|
28
|
+
const state = readState(statePath)
|
|
29
|
+
if (!state) {
|
|
30
|
+
log('spec: стану нема — спершу `flow init`')
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (rest.includes('--panel')) {
|
|
35
|
+
let runner = deps.runner
|
|
36
|
+
if (!runner) {
|
|
37
|
+
try {
|
|
38
|
+
runner = await createRunner(deps)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log(`spec: ${error.message}`)
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const synth = await runPanel({ task: state.branch, cwd, runner, log, mode: 'spec' })
|
|
45
|
+
if (synth) {
|
|
46
|
+
log('spec: панель синтезувала підходи (нижче) — збережи дизайн у docs/specs/ і повтори `flow spec`:')
|
|
47
|
+
log(typeof synth === 'string' ? synth : JSON.stringify(synth))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'specs')
|
|
52
|
+
if (!doc || !existsSync(doc)) {
|
|
53
|
+
log('spec: нема docs/specs/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
|
|
54
|
+
return 1
|
|
55
|
+
}
|
|
56
|
+
if (!verifyTrace(cwd, deps.trace)) {
|
|
57
|
+
log('⚠️ spec: trace виявив розрив ланцюга — перевір лінки front-matter (adr/spec/plan)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
recordTransition(
|
|
61
|
+
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
62
|
+
{ type: 'spec' },
|
|
63
|
+
s => ({ ...s, spec_doc: doc, status: 'spec' }),
|
|
64
|
+
deps.now ?? Date.now
|
|
65
|
+
)
|
|
66
|
+
log(`spec: зафіксовано ${doc} → status: spec`)
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
@@ -11,7 +11,7 @@ import { join } from 'node:path'
|
|
|
11
11
|
import { cwd as processCwd } from 'node:process'
|
|
12
12
|
|
|
13
13
|
/** Поля-лінки у front-matter, що утворюють ланцюг. */
|
|
14
|
-
const LINK_FIELDS = ['adr', 'spec', 'plan', 'change', 'task']
|
|
14
|
+
const LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']
|
|
15
15
|
|
|
16
16
|
/** Каталоги з traceable-артефактами. */
|
|
17
17
|
const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
|