@nitra/cursor 1.41.0 → 2.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.
@@ -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
+ }