@nitra/cursor 1.41.1 → 3.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.
- package/CHANGELOG.md +38 -0
- package/bin/n-cursor.js +17 -1
- package/package.json +1 -1
- package/rules/flow/fix.mjs +18 -0
- package/rules/flow/flow.mdc +51 -0
- package/rules/flow/meta.json +1 -0
- package/rules/js-lint/js/data/tooling/oxlint-canonical.json +1 -1
- package/rules/js-lint-ci/meta.json +1 -1
- package/scripts/dispatcher/index.mjs +48 -0
- package/scripts/dispatcher/lib/active.mjs +226 -0
- package/scripts/dispatcher/lib/budget.mjs +36 -0
- package/scripts/dispatcher/lib/capability.mjs +81 -0
- package/scripts/dispatcher/lib/commands.mjs +193 -0
- package/scripts/dispatcher/lib/events.mjs +67 -0
- package/scripts/dispatcher/lib/executor.mjs +102 -0
- package/scripts/dispatcher/lib/flow-lock.mjs +39 -0
- package/scripts/dispatcher/lib/planner.mjs +66 -0
- package/scripts/dispatcher/lib/reviewer.mjs +38 -0
- package/scripts/dispatcher/lib/snapshot.mjs +58 -0
- package/scripts/dispatcher/lib/state-store.mjs +173 -0
- package/scripts/dispatcher/lib/subagent-runner.mjs +120 -0
- package/scripts/dispatcher/trace.mjs +114 -0
- package/scripts/utils/with-lock.mjs +7 -3
- package/scripts/worktree-cli.mjs +12 -5
|
@@ -0,0 +1,193 @@
|
|
|
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 { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
14
|
+
import { flowEventsPath } from './events.mjs'
|
|
15
|
+
import { runReview } from './reviewer.mjs'
|
|
16
|
+
import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
|
|
17
|
+
import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Реальний sync-runner із захопленням виводу.
|
|
21
|
+
* @param {string} cmd виконуваний
|
|
22
|
+
* @param {string[]} args аргументи
|
|
23
|
+
* @param {object} [opts] додаткові опції spawnSync (напр. `cwd`)
|
|
24
|
+
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
25
|
+
*/
|
|
26
|
+
export function realRun(cmd, args, opts = {}) {
|
|
27
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
28
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Чи `cwd` — уже linked worktree (не основний checkout і не submodule).
|
|
33
|
+
* @param {(cmd: string, args: string[], opts: object) => { status: number, stdout: string }} run runner
|
|
34
|
+
* @param {string} cwd робочий каталог
|
|
35
|
+
* @returns {boolean} true, якщо вже в worktree
|
|
36
|
+
*/
|
|
37
|
+
function inLinkedWorktree(run, cwd) {
|
|
38
|
+
const gitDir = run('git', ['rev-parse', '--git-dir'], { cwd })
|
|
39
|
+
const gitCommon = run('git', ['rev-parse', '--git-common-dir'], { cwd })
|
|
40
|
+
if ((gitDir.status ?? 1) !== 0 || (gitCommon.status ?? 1) !== 0) return false
|
|
41
|
+
const superproject = run('git', ['rev-parse', '--show-superproject-working-tree'], { cwd })
|
|
42
|
+
const isSubmodule = (superproject.stdout ?? '').trim() !== ''
|
|
43
|
+
return !isSubmodule && (gitDir.stdout ?? '').trim() !== (gitCommon.stdout ?? '').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1). Якщо вже
|
|
48
|
+
* в worktree — не вкладає новий (detect existing isolation).
|
|
49
|
+
* @param {string[]} rest аргументи: `<branch> <опис...>`
|
|
50
|
+
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
51
|
+
* @returns {Promise<number>} exit code
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* Гарантує worktree для задачі: парсить `<branch> <опис>`, детектить існуючу
|
|
55
|
+
* ізоляцію (§8.1) або створює новий worktree, читає `base_commit`. Спільне для
|
|
56
|
+
* `init` (Фасад A) і `run` (Фасад B).
|
|
57
|
+
* @param {string[]} rest аргументи `<branch> <опис...>`
|
|
58
|
+
* @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
|
|
59
|
+
* @returns {{ code: number, worktreeDir?: string, branch?: string, desc?: string, baseCommit?: string | null }} результат
|
|
60
|
+
*/
|
|
61
|
+
export function ensureWorktree(rest, deps = {}) {
|
|
62
|
+
const run = deps.run ?? realRun
|
|
63
|
+
const cwd = deps.cwd ?? processCwd()
|
|
64
|
+
const log = deps.log ?? console.error
|
|
65
|
+
|
|
66
|
+
const branch = rest[0]
|
|
67
|
+
const desc = rest.slice(1).join(' ').trim()
|
|
68
|
+
if (!branch || !desc) {
|
|
69
|
+
log('Usage: n-cursor flow <init|run> <branch> "<опис>"')
|
|
70
|
+
return { code: 1 }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let worktreeDir
|
|
74
|
+
if (inLinkedWorktree(run, cwd)) {
|
|
75
|
+
worktreeDir = cwd
|
|
76
|
+
log(`flow: уже в worktree (${cwd}) — не вкладаю новий`)
|
|
77
|
+
} else {
|
|
78
|
+
const add = run('npx', ['@nitra/cursor', 'worktree', 'add', branch, desc], { cwd })
|
|
79
|
+
if ((add.status ?? 1) !== 0) {
|
|
80
|
+
const detail = add.stderr ? `: ${add.stderr.trim()}` : ''
|
|
81
|
+
log(`flow: worktree add не вдався${detail}`)
|
|
82
|
+
return { code: 1 }
|
|
83
|
+
}
|
|
84
|
+
worktreeDir = worktreePaths(cwd, branch).checkout
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const head = run('git', ['rev-parse', 'HEAD'], { cwd: worktreeDir })
|
|
88
|
+
const baseCommit = (head.status ?? 1) === 0 ? (head.stdout ?? '').trim() : null
|
|
89
|
+
return { code: 0, worktreeDir, branch, desc, baseCommit }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1).
|
|
94
|
+
* @param {string[]} rest аргументи `<branch> <опис...>`
|
|
95
|
+
* @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
96
|
+
* @returns {Promise<number>} exit code
|
|
97
|
+
*/
|
|
98
|
+
export async function init(rest, deps = {}) {
|
|
99
|
+
const ew = ensureWorktree(rest, deps)
|
|
100
|
+
if (ew.code !== 0) return ew.code
|
|
101
|
+
const now = deps.now ?? Date.now
|
|
102
|
+
const log = deps.log ?? console.error
|
|
103
|
+
const statePath = flowStatePath(ew.worktreeDir)
|
|
104
|
+
writeState(statePath, {
|
|
105
|
+
branch: ew.branch,
|
|
106
|
+
status: 'in_progress',
|
|
107
|
+
started_at: new Date(now()).toISOString(),
|
|
108
|
+
metadata: { base_commit: ew.baseCommit },
|
|
109
|
+
plan: []
|
|
110
|
+
})
|
|
111
|
+
log(`init: ${ew.branch} → ${statePath}`)
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `flow verify` — проганяє Quality Gates у поточному worktree (Турнікет, §8.1).
|
|
117
|
+
* На фейл друкує вивід проваленого gate. Якщо поряд є стан — записує
|
|
118
|
+
* gate-результати + fingerprint. Read-only щодо коду.
|
|
119
|
+
* @param {string[]} _rest аргументи (не використовуються)
|
|
120
|
+
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, fingerprint?: () => string | null }} [deps] ін'єкції
|
|
121
|
+
* @returns {Promise<number>} exit code (0 — pass, 1 — fail)
|
|
122
|
+
*/
|
|
123
|
+
export async function verify(_rest, deps = {}) {
|
|
124
|
+
const run = deps.run ?? realRun
|
|
125
|
+
const cwd = deps.cwd ?? processCwd()
|
|
126
|
+
const log = deps.log ?? console.error
|
|
127
|
+
const fingerprint = deps.fingerprint ?? (() => worktreeFingerprint())
|
|
128
|
+
|
|
129
|
+
const verdict = runReview({ run, cwd, fingerprint })
|
|
130
|
+
|
|
131
|
+
for (const g of verdict.gates) {
|
|
132
|
+
log(`${g.ok ? '✅' : '❌'} gate: ${g.name}`)
|
|
133
|
+
}
|
|
134
|
+
if (!verdict.pass && verdict.failedOutput) log(verdict.failedOutput)
|
|
135
|
+
log(verdict.pass ? '✅ verify: усі gate-и пройдено' : '❌ verify: провалено')
|
|
136
|
+
|
|
137
|
+
const statePath = flowStatePath(cwd)
|
|
138
|
+
if (readState(statePath)) {
|
|
139
|
+
recordTransition(
|
|
140
|
+
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
141
|
+
{ type: 'verify', pass: verdict.pass },
|
|
142
|
+
state => ({
|
|
143
|
+
...state,
|
|
144
|
+
gates: verdict.gates,
|
|
145
|
+
fingerprint: verdict.fingerprint,
|
|
146
|
+
status: verdict.pass ? state.status : 'failed'
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return verdict.pass ? 0 : 1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `flow release [--bump … --section … --message …]` — генерує `.changes` і пише
|
|
156
|
+
* completion snapshot (§3 Ф5, §7). Потребує наявного стану (`init`).
|
|
157
|
+
* @param {string[]} rest аргументи, що прокидаються у `n-cursor change`
|
|
158
|
+
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
159
|
+
* @returns {Promise<number>} exit code
|
|
160
|
+
*/
|
|
161
|
+
export async function release(rest, deps = {}) {
|
|
162
|
+
const run = deps.run ?? realRun
|
|
163
|
+
const cwd = deps.cwd ?? processCwd()
|
|
164
|
+
const log = deps.log ?? console.error
|
|
165
|
+
const now = deps.now ?? Date.now
|
|
166
|
+
|
|
167
|
+
const statePath = flowStatePath(cwd)
|
|
168
|
+
const state = readState(statePath)
|
|
169
|
+
if (!state) {
|
|
170
|
+
log('release: стану нема — спершу `flow init`')
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ch = run('npx', ['@nitra/cursor', 'change', ...rest], { cwd })
|
|
175
|
+
if ((ch.status ?? 1) !== 0) {
|
|
176
|
+
const detail = ch.stderr ? `: ${ch.stderr.trim()}` : ''
|
|
177
|
+
log(`release: change не вдався${detail}`)
|
|
178
|
+
return 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const snapshot = buildCompletionSnapshot({ ...state, status: 'done' }, now)
|
|
182
|
+
recordTransition(
|
|
183
|
+
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
184
|
+
{ type: 'release' },
|
|
185
|
+
state_ => ({ ...state_, status: 'done', completion: snapshot }),
|
|
186
|
+
now
|
|
187
|
+
)
|
|
188
|
+
if (state.task) {
|
|
189
|
+
writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(cwd, state.task), snapshot)
|
|
190
|
+
}
|
|
191
|
+
log('release: done')
|
|
192
|
+
return 0
|
|
193
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAL — append-only журнал подій `flow` (spec §4.1.2, §9).
|
|
3
|
+
*
|
|
4
|
+
* Sibling-файл `.worktrees/<sanitized-branch>.events.jsonl` (JSON Lines). Єдиний
|
|
5
|
+
* журнал: субсумує і переходи стану (`step_*`, `blocked`…), і api-облік
|
|
6
|
+
* (`api_call`). Append-only → краш-безпечніший за перезапис: торваний останній
|
|
7
|
+
* рядок (краш посеред append) при читанні **толеруємо** (пропускаємо), а не
|
|
8
|
+
* валимо весь журнал.
|
|
9
|
+
*
|
|
10
|
+
* **WAL-інваріант** (забезпечує `state-store.recordTransition`): подію
|
|
11
|
+
* дописуємо ДО зміни високорівневого статусу у snapshot `.flow.json`.
|
|
12
|
+
*
|
|
13
|
+
* Усі шляхи — абсолютні (`no-relative-fs-path`).
|
|
14
|
+
*/
|
|
15
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Шлях sibling-журналу подій для checkout-каталогу worktree.
|
|
20
|
+
* @param {string} worktreeDir абсолютний шлях checkout (`…/.worktrees/feat-x`)
|
|
21
|
+
* @returns {string} `…/.worktrees/feat-x.events.jsonl`
|
|
22
|
+
*/
|
|
23
|
+
export function flowEventsPath(worktreeDir) {
|
|
24
|
+
if (!isAbsolute(worktreeDir)) {
|
|
25
|
+
throw new Error(`flowEventsPath: очікується абсолютний шлях (отримано: ${worktreeDir})`)
|
|
26
|
+
}
|
|
27
|
+
return join(dirname(worktreeDir), `${basename(worktreeDir)}.events.jsonl`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Дописує одну подію (з міткою часу `at`) у журнал. Створює файл за потреби.
|
|
32
|
+
* @param {string} eventsPath абсолютний шлях `.events.jsonl`
|
|
33
|
+
* @param {object} event подія (напр. `{ type: 'step_started', step: 2 }`)
|
|
34
|
+
* @param {() => number} [now] фабрика часу (ms) — ін'єкція для тестів
|
|
35
|
+
* @returns {object} фактично записаний запис (зі `at`)
|
|
36
|
+
*/
|
|
37
|
+
export function appendEvent(eventsPath, event, now = Date.now) {
|
|
38
|
+
if (!isAbsolute(eventsPath)) {
|
|
39
|
+
throw new Error(`appendEvent: очікується абсолютний шлях (отримано: ${eventsPath})`)
|
|
40
|
+
}
|
|
41
|
+
const record = { at: new Date(now()).toISOString(), ...event }
|
|
42
|
+
appendFileSync(eventsPath, `${JSON.stringify(record)}\n`, 'utf8')
|
|
43
|
+
return record
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Читає всі події. Відсутній файл → `[]`. Непарсабельні рядки (порожні або
|
|
48
|
+
* торваний останній) **пропускаються** (append-only толерантність).
|
|
49
|
+
* @param {string} eventsPath абсолютний шлях `.events.jsonl`
|
|
50
|
+
* @returns {object[]} розпарсені події у порядку запису
|
|
51
|
+
*/
|
|
52
|
+
export function readEvents(eventsPath) {
|
|
53
|
+
if (!isAbsolute(eventsPath)) {
|
|
54
|
+
throw new Error(`readEvents: очікується абсолютний шлях (отримано: ${eventsPath})`)
|
|
55
|
+
}
|
|
56
|
+
if (!existsSync(eventsPath)) return []
|
|
57
|
+
return readFileSync(eventsPath, 'utf8')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter(line => line.trim() !== '')
|
|
60
|
+
.flatMap(line => {
|
|
61
|
+
try {
|
|
62
|
+
return [JSON.parse(line)]
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor (spec §3 Ф3) — виконує план покроково через SubagentRunner + verify.
|
|
3
|
+
*
|
|
4
|
+
* Інваріанти:
|
|
5
|
+
* - **мікропромпт зі стану** (§3 Ф3): субагент отримує лише поточний крок +
|
|
6
|
+
* критерії + останню помилку, не історію переписки;
|
|
7
|
+
* - **commit лише після зеленого verify** (§4.1.7): repair-спроби не комітять,
|
|
8
|
+
* тож HEAD завжди = останній зелений крок;
|
|
9
|
+
* - **repair ≤ maxRepairAttempts**, далі — HITL (`blocked-on-human`, §4.2).
|
|
10
|
+
*
|
|
11
|
+
* Усі побічні дії (`runner`/`verify`/`commit`) ін'єктуються — тестується без
|
|
12
|
+
* реальних LLM/git/gates.
|
|
13
|
+
*/
|
|
14
|
+
import { readState, recordTransition } from './state-store.mjs'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Мікропромпт для кроку (§3 Ф3): лише поточний крок + критерії + остання помилка.
|
|
18
|
+
* @param {{ step: number, task: string, acceptance?: string, last_error?: string }} step крок плану
|
|
19
|
+
* @param {{ branch?: string }} state стан (для контексту гілки)
|
|
20
|
+
* @returns {string} промпт субагента
|
|
21
|
+
*/
|
|
22
|
+
export function microprompt(step, state) {
|
|
23
|
+
const lines = [
|
|
24
|
+
'Реалізуй РІВНО цей крок плану (не більше). Iron Law of TDD: спершу падаючі тести, тоді код.',
|
|
25
|
+
`Гілка: ${state.branch ?? '—'}`,
|
|
26
|
+
`Крок ${step.step}: ${step.task}`
|
|
27
|
+
]
|
|
28
|
+
if (step.acceptance) lines.push(`Критерії приймання: ${step.acceptance}`)
|
|
29
|
+
if (step.hint) lines.push(`Підказка людини (HITL): ${step.hint}`)
|
|
30
|
+
if (step.last_error) lines.push(`Попередня спроба впала на перевірці:\n${step.last_error}\nВиправ це.`)
|
|
31
|
+
return lines.join('\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Оновлює крок плану за індексом (pure).
|
|
36
|
+
* @param {{ plan: object[] }} state стан
|
|
37
|
+
* @param {number} index індекс кроку
|
|
38
|
+
* @param {object} patch часткове оновлення кроку
|
|
39
|
+
* @returns {object} новий стан
|
|
40
|
+
*/
|
|
41
|
+
export function patchStep(state, index, patch) {
|
|
42
|
+
return { ...state, plan: state.plan.map((s, i) => (i === index ? { ...s, ...patch } : s)) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Виконує план зі стану.
|
|
47
|
+
* @param {{ statePath: string, eventsPath: string }} paths шляхи стану й журналу
|
|
48
|
+
* @param {{ runner: { runStep: (prompt: string, opts?: object) => object }, verify: (cwd: string) => Promise<{ pass: boolean, failedOutput?: string }> | { pass: boolean, failedOutput?: string }, commit: (cwd: string, msg: string) => void, cwd?: string, maxRepairAttempts?: number, log?: (m: string) => void, now?: () => number }} deps ін'єкції
|
|
49
|
+
* @returns {Promise<{ status: 'done' | 'blocked-on-human', step?: number }>} результат
|
|
50
|
+
*/
|
|
51
|
+
export async function executePlan(paths, deps) {
|
|
52
|
+
const { runner, verify, commit, cwd, maxRepairAttempts = 3, log = () => {}, now = Date.now } = deps
|
|
53
|
+
let state = readState(paths.statePath)
|
|
54
|
+
if (!state?.plan?.length) {
|
|
55
|
+
throw new Error('executor: у стані немає плану — спершу planner')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < state.plan.length; i++) {
|
|
59
|
+
if (state.plan[i].status === 'done') continue
|
|
60
|
+
|
|
61
|
+
let done = false
|
|
62
|
+
while (state.plan[i].retry_count < maxRepairAttempts && !done) {
|
|
63
|
+
const step = state.plan[i]
|
|
64
|
+
log(`executor: крок ${step.step} (спроба ${step.retry_count + 1})`)
|
|
65
|
+
await runner.runStep(microprompt(step, state), { cwd })
|
|
66
|
+
const verdict = await verify(cwd)
|
|
67
|
+
if (verdict.pass) {
|
|
68
|
+
commit(cwd, `flow: step ${step.step} — ${step.task}`) // commit ЛИШЕ після зеленого
|
|
69
|
+
state = recordTransition(paths, { type: 'step_done', step: step.step }, s => patchStep(s, i, { status: 'done' }), now)
|
|
70
|
+
done = true
|
|
71
|
+
} else {
|
|
72
|
+
state = recordTransition(
|
|
73
|
+
paths,
|
|
74
|
+
{ type: 'step_retry', step: step.step },
|
|
75
|
+
s => patchStep(s, i, { retry_count: s.plan[i].retry_count + 1, last_error: verdict.failedOutput ?? null }),
|
|
76
|
+
now
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!done) {
|
|
82
|
+
const failed = state.plan[i]
|
|
83
|
+
const question = {
|
|
84
|
+
id: `q-${i}`,
|
|
85
|
+
step: failed.step,
|
|
86
|
+
question: `Крок ${failed.step} «${failed.task}» не проходить verify після ${maxRepairAttempts} спроб. Що робити?`,
|
|
87
|
+
status: 'open',
|
|
88
|
+
answer: ''
|
|
89
|
+
}
|
|
90
|
+
recordTransition(
|
|
91
|
+
paths,
|
|
92
|
+
{ type: 'blocked', step: failed.step },
|
|
93
|
+
s => ({ ...s, status: 'blocked-on-human', hitl: [...(s.hitl ?? []), question] }),
|
|
94
|
+
now
|
|
95
|
+
)
|
|
96
|
+
return { status: 'blocked-on-human', step: failed.step }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
recordTransition(paths, { type: 'plan_done' }, s => ({ ...s, status: 'built' }), now)
|
|
101
|
+
return { status: 'done' }
|
|
102
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Декларативний планувальник (spec §3 Ф1). Просить субагента видати суворий
|
|
3
|
+
* покроковий JSON-план, парсить і **валідує** його (fail-closed на невалідному).
|
|
4
|
+
* Нормалізує кроки до `{ step, task, status: 'pending', retry_count: 0 }`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Системно-користувацький промпт планувальника.
|
|
9
|
+
* @param {string} task опис фічі
|
|
10
|
+
* @returns {string} промпт
|
|
11
|
+
*/
|
|
12
|
+
export function plannerPrompt(task) {
|
|
13
|
+
return [
|
|
14
|
+
'Ти — архітектор. Розбий задачу на суворий покроковий план реалізації.',
|
|
15
|
+
'Кожен крок — ≤ 5 хв розробки, з чіткими критеріями приймання коду.',
|
|
16
|
+
'Поверни ЛИШЕ JSON-масив без коментарів: [{ "task": "...", "acceptance": "..." }, ...].',
|
|
17
|
+
'',
|
|
18
|
+
`Задача: ${task}`
|
|
19
|
+
].join('\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Парсить і валідує план із тексту відповіді (толерує markdown-огорожу).
|
|
24
|
+
* @param {string} text відповідь субагента
|
|
25
|
+
* @returns {{ step: number, task: string, status: string, retry_count: number, acceptance?: string }[]} нормалізований план
|
|
26
|
+
*/
|
|
27
|
+
export function parsePlan(text) {
|
|
28
|
+
const str = String(text)
|
|
29
|
+
const start = str.indexOf('[')
|
|
30
|
+
const end = str.lastIndexOf(']')
|
|
31
|
+
if (start === -1 || end === -1 || end < start) {
|
|
32
|
+
throw new Error('planner: не знайдено JSON-масив плану — fail-closed')
|
|
33
|
+
}
|
|
34
|
+
let arr
|
|
35
|
+
try {
|
|
36
|
+
arr = JSON.parse(str.slice(start, end + 1))
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('planner: невалідний JSON плану — fail-closed')
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
41
|
+
throw new Error('planner: план має бути непорожнім масивом — fail-closed')
|
|
42
|
+
}
|
|
43
|
+
return arr.map((s, i) => {
|
|
44
|
+
const task = typeof s === 'string' ? s : s?.task
|
|
45
|
+
if (!task || typeof task !== 'string') {
|
|
46
|
+
throw new Error(`planner: крок ${i} без текстового поля task — fail-closed`)
|
|
47
|
+
}
|
|
48
|
+
const step = { step: i, task, status: 'pending', retry_count: 0 }
|
|
49
|
+
if (s?.acceptance) step.acceptance = String(s.acceptance)
|
|
50
|
+
return step
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Генерує план через субагента-планувальника.
|
|
56
|
+
* @param {{ runner: { runStep: (prompt: string, opts?: object) => { ok: boolean, output: string } | Promise<{ ok: boolean, output: string }> }, task: string, cwd?: string }} input ін'єкції
|
|
57
|
+
* @returns {Promise<object[]>} нормалізований план
|
|
58
|
+
*/
|
|
59
|
+
export async function generatePlan({ runner, task, cwd }) {
|
|
60
|
+
const res = await runner.runStep(plannerPrompt(task), { cwd })
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const detail = res.output ? `:\n${res.output}` : ''
|
|
63
|
+
throw new Error(`planner: субагент-планувальник завершився помилкою${detail}`)
|
|
64
|
+
}
|
|
65
|
+
return parsePlan(res.output)
|
|
66
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level-1 «Суддя» (spec §8.4): проганяє Quality Gates (§5) через **ін'єктований**
|
|
3
|
+
* `run`-runner і повертає структурований verdict. Не знає про LLM/API-ключі —
|
|
4
|
+
* чистий FS/Git/процеси. Один і той самий `runReview` обслуговує і Пасивний
|
|
5
|
+
* Турнікет (`flow verify`), і Активний Раннер (per-step Ф4).
|
|
6
|
+
*
|
|
7
|
+
* Fail-fast: на першому проваленому gate зупиняємось. Fingerprint дерева
|
|
8
|
+
* (`worktree-fingerprint`) фіксуємо лише на повному pass — щоб зберегти у стан і
|
|
9
|
+
* пізніше ловити stale-результат (§5).
|
|
10
|
+
*/
|
|
11
|
+
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
12
|
+
|
|
13
|
+
/** Канонічні gate-и verify (lint + coverage; coverage включає тести+мутації). */
|
|
14
|
+
export const DEFAULT_GATES = [
|
|
15
|
+
{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
|
|
16
|
+
{ name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage'] }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Проганяє gate-и й повертає verdict.
|
|
21
|
+
* @param {{ run: (cmd: string, args: string[], opts: object) => { status: number, stdout?: string, stderr?: string }, cwd: string, gates?: { name: string, cmd: string[] }[], fingerprint?: () => string | null }} input ін'єкції
|
|
22
|
+
* @returns {{ pass: boolean, gates: { name: string, ok: boolean }[], failedOutput: string | null, fingerprint: string | null }} verdict
|
|
23
|
+
*/
|
|
24
|
+
export function runReview({ run, cwd, gates = DEFAULT_GATES, fingerprint = () => worktreeFingerprint() }) {
|
|
25
|
+
const results = []
|
|
26
|
+
let failedOutput = null
|
|
27
|
+
for (const g of gates) {
|
|
28
|
+
const r = run(g.cmd[0], g.cmd.slice(1), { cwd })
|
|
29
|
+
const ok = (r?.status ?? 1) === 0
|
|
30
|
+
results.push({ name: g.name, ok })
|
|
31
|
+
if (!ok) {
|
|
32
|
+
failedOutput = `${r?.stdout ?? ''}\n${r?.stderr ?? ''}`.trim() || null
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const pass = results.length === gates.length && results.every(x => x.ok)
|
|
37
|
+
return { pass, gates: results, failedOutput, fingerprint: pass ? fingerprint() : null }
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion snapshot (spec §3 Ф5, §7): перед cleanup transient `.flow.json`
|
|
3
|
+
* durable-слід задачі має пережити. Будуємо стислий summary і вписуємо його в
|
|
4
|
+
* task record (`docs/tasks/<id>.md`) між HTML-маркерами (idempotent upsert).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { isAbsolute } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const SUMMARY_START = '<!-- flow:summary:start -->'
|
|
10
|
+
const SUMMARY_END = '<!-- flow:summary:end -->'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Будує completion snapshot зі стану.
|
|
14
|
+
* @param {object} state стан `.flow.json`
|
|
15
|
+
* @param {() => number} [now] фабрика часу (ms)
|
|
16
|
+
* @returns {object} snapshot (status, branch, base_commit, gates, change, notified, finished_at)
|
|
17
|
+
*/
|
|
18
|
+
export function buildCompletionSnapshot(state, now = Date.now) {
|
|
19
|
+
return {
|
|
20
|
+
status: state.status ?? 'done',
|
|
21
|
+
branch: state.branch ?? null,
|
|
22
|
+
base_commit: state.metadata?.base_commit ?? state.base_commit ?? null,
|
|
23
|
+
gates: Object.fromEntries((state.gates ?? []).map(g => [g.name, g.ok ? 'ok' : 'fail'])),
|
|
24
|
+
change: state.change ?? null,
|
|
25
|
+
notified: state.notified ?? null,
|
|
26
|
+
finished_at: new Date(now()).toISOString()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Вставляє/оновлює блок Summary в markdown-контенті (між маркерами).
|
|
32
|
+
* @param {string} content вихідний markdown
|
|
33
|
+
* @param {object} snapshot completion snapshot
|
|
34
|
+
* @returns {string} оновлений markdown
|
|
35
|
+
*/
|
|
36
|
+
export function upsertSummaryBlock(content, snapshot) {
|
|
37
|
+
const block = `${SUMMARY_START}\n## Summary\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\`\n${SUMMARY_END}`
|
|
38
|
+
const i = content.indexOf(SUMMARY_START)
|
|
39
|
+
const j = content.indexOf(SUMMARY_END)
|
|
40
|
+
if (i !== -1 && j !== -1 && j > i) {
|
|
41
|
+
return content.slice(0, i) + block + content.slice(j + SUMMARY_END.length)
|
|
42
|
+
}
|
|
43
|
+
return `${content.trimEnd()}\n\n${block}\n`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Вписує snapshot у task record (створює файл, якщо його нема).
|
|
48
|
+
* @param {string} taskPath абсолютний шлях `docs/tasks/<id>.md`
|
|
49
|
+
* @param {object} snapshot completion snapshot
|
|
50
|
+
* @returns {void}
|
|
51
|
+
*/
|
|
52
|
+
export function writeSummaryToTaskRecord(taskPath, snapshot) {
|
|
53
|
+
if (!isAbsolute(taskPath)) {
|
|
54
|
+
throw new Error(`writeSummaryToTaskRecord: очікується абсолютний шлях (отримано: ${taskPath})`)
|
|
55
|
+
}
|
|
56
|
+
const content = existsSync(taskPath) ? readFileSync(taskPath, 'utf8') : ''
|
|
57
|
+
writeFileSync(taskPath, upsertSummaryBlock(content, snapshot), 'utf8')
|
|
58
|
+
}
|