@nitra/cursor 3.29.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Handler `flow verify` — Stage 2 structural check (думка.MD § "flow verify").
3
+ *
4
+ * Перевіряє що `outputs_NNN.md` існує і непорожній у директорії поточного вузла
5
+ * (CWD). Якщо так — виводить `## Done when` секцію з `task.md` та вміст
6
+ * `outputs_NNN.md` на stdout для агентської self-evaluation.
7
+ *
8
+ * exit 0 = структурно OK
9
+ * exit 1 = структурна помилка (outputs відсутній або порожній)
10
+ *
11
+ * НІЯКОГО артефакту не пишеться. FS ін'єктується для тестування без диска.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { cwd as processCwd } from 'node:process'
16
+
17
+ const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
18
+ const SECTION_RE = /^## (.+)$/m
19
+
20
+ /**
21
+ * Читає секцію за заголовком із markdown-файлу.
22
+ * Повертає вміст від заголовка до наступного `## ` або кінця файлу.
23
+ * @param {string} text вміст файлу
24
+ * @param {string} heading заголовок без `## `
25
+ * @returns {string | null} вміст секції (включно з рядком заголовка) або null
26
+ */
27
+ function extractSection(text, heading) {
28
+ const lines = text.split(/\r?\n/)
29
+ const start = lines.findIndex(l => l === `## ${heading}`)
30
+ if (start === -1) return null
31
+ const end = lines.findIndex((l, i) => i > start && SECTION_RE.test(l))
32
+ const section = end === -1 ? lines.slice(start) : lines.slice(start, end)
33
+ return section.join('\n').trimEnd()
34
+ }
35
+
36
+ /**
37
+ * Знаходить outputs-файл з найбільшим NNN у директорії вузла.
38
+ * @param {string[]} files список файлів директорії
39
+ * @returns {string | null} ім'я файлу (напр. `outputs_001.md`) або null
40
+ */
41
+ export function findLatestOutputs(files) {
42
+ let max = -1
43
+ let best = null
44
+ for (const f of files) {
45
+ const m = f.match(/^outputs_(\d+)\.md$/)
46
+ if (m) {
47
+ const n = parseInt(m[1], 10)
48
+ if (n > max) {
49
+ max = n
50
+ best = f
51
+ }
52
+ }
53
+ }
54
+ return best
55
+ }
56
+
57
+ /**
58
+ * `flow verify` handler.
59
+ *
60
+ * @param {string[]} _rest аргументи після `verify` (не використовуються)
61
+ * @param {{
62
+ * cwd?: string,
63
+ * log?: (m: string) => void,
64
+ * readFile?: (path: string, enc: string) => string,
65
+ * readdir?: (dir: string) => string[],
66
+ * exists?: (path: string) => boolean
67
+ * }} [deps] ін'єкції
68
+ * @returns {Promise<number>} exit code (0=OK, 1=структурна помилка)
69
+ */
70
+ export async function verify(_rest, deps = {}) {
71
+ const cwd = deps.cwd ?? processCwd()
72
+ const log = deps.log ?? console.error
73
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
74
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
75
+ const exists = deps.exists ?? existsSync
76
+
77
+ // Перевіряємо outputs_NNN.md
78
+ const files = readdir(cwd)
79
+ const outputsName = findLatestOutputs(files)
80
+ if (!outputsName) {
81
+ log('flow verify: outputs_NNN.md не знайдено — структурна помилка')
82
+ return 1
83
+ }
84
+
85
+ const outputsPath = join(cwd, outputsName)
86
+ if (!exists(outputsPath)) {
87
+ log(`flow verify: ${outputsName} не існує — структурна помилка`)
88
+ return 1
89
+ }
90
+
91
+ let outputsContent
92
+ try {
93
+ outputsContent = readFile(outputsPath, 'utf8')
94
+ } catch (err) {
95
+ log(`flow verify: не вдалося прочитати ${outputsName} — ${err instanceof Error ? err.message : String(err)}`)
96
+ return 1
97
+ }
98
+
99
+ // Перевіряємо що файл не порожній (без front-matter — суто тіло)
100
+ const withoutFm = outputsContent.replace(FRONT_MATTER_RE, '').trim()
101
+ if (withoutFm.length === 0) {
102
+ log(`flow verify: ${outputsName} порожній — структурна помилка`)
103
+ return 1
104
+ }
105
+
106
+ // Виводимо Done when + outputs на stdout для агентської self-evaluation
107
+ const outLines = [`## verify context`, ``]
108
+
109
+ const taskPath = join(cwd, 'task.md')
110
+ if (exists(taskPath)) {
111
+ try {
112
+ const taskContent = readFile(taskPath, 'utf8')
113
+ const doneWhen = extractSection(taskContent, 'Done when')
114
+ if (doneWhen) {
115
+ outLines.push(doneWhen, '')
116
+ }
117
+ } catch {
118
+ // якщо task.md недоступний — не блокуємо verify
119
+ }
120
+ }
121
+
122
+ outLines.push(`### ${outputsName}`, ``, outputsContent.trimEnd())
123
+
124
+ console.log(outLines.join('\n'))
125
+
126
+ return 0
127
+ }
@@ -15,7 +15,6 @@ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'no
15
15
  import { join } from 'node:path'
16
16
  import { cwd as processCwd } from 'node:process'
17
17
 
18
- import { cleanupFlowSiblings } from './dispatcher/lib/state-store.mjs'
19
18
  import {
20
19
  buildDescription,
21
20
  buildDirtyNotice,
@@ -161,7 +160,8 @@ function cmdRemove(rest, ctx) {
161
160
  return 1
162
161
  }
163
162
  if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
164
- cleanupFlowSiblings(paths.checkout) // flow-sibling-и (.flow.json/.events.jsonl/lock) інакше осиротіють (§1.4)
163
+ // В новій архітектурі (думка.MD) state зберігається у файлах вузлів (task.md, outputs, run),
164
+ // а не у .flow.json/.events.jsonl/lock sibling-ах. Cleanup sibling-ів більше не потрібен.
165
165
  ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
166
166
  return 0
167
167
  }
@@ -1,70 +1,14 @@
1
1
  /** @see ./docs/docgen-gen.md */
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { basename } from 'node:path'
4
- import { request } from 'node:http'
5
4
  import { spawnSync } from 'node:child_process'
6
5
  import { env } from 'node:process'
7
- import { LOCAL_MIN, resolveModel } from '../../../lib/models.mjs'
6
+ import { resolveModel } from '../../../lib/models.mjs'
8
7
  import { extractFacts } from './docgen-extract.mjs'
9
- import { sectionMessages, oneShotMessages, STYLE, oneShotPromptText } from './docgen-prompts.mjs'
10
-
11
- /** Strips provider prefix from tier string for direct ollama HTTP (ollama/gemma3:4b → gemma3:4b). */
12
- function localModelId(tier) {
13
- if (!tier) return 'gemma3:4b'
14
- const i = tier.indexOf('/')
15
- return i === -1 ? tier : tier.slice(i + 1)
16
- }
8
+ import { STYLE, oneShotPromptText, sectionMessages } from './docgen-prompts.mjs'
17
9
 
18
10
  const QUALITY_THRESHOLD = 70
19
11
 
20
- /** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
21
- async function ollamaChat(messages, { model, numPredict = 600 }) {
22
- const body = JSON.stringify({
23
- model,
24
- messages,
25
- stream: true,
26
- think: false,
27
- options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
28
- keep_alive: '15m'
29
- })
30
- return new Promise((resolve, reject) => {
31
- const req = request(
32
- {
33
- hostname: 'localhost',
34
- port: 11434,
35
- path: '/api/chat',
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
38
- },
39
- res => {
40
- let text = '',
41
- genTok = 0,
42
- buf = ''
43
- res.on('data', chunk => {
44
- buf += chunk.toString()
45
- const lines = buf.split('\n')
46
- buf = lines.pop()
47
- for (const line of lines) {
48
- if (!line.trim()) continue
49
- try {
50
- const j = JSON.parse(line)
51
- text += j.message?.content ?? ''
52
- if (j.done) genTok = j.eval_count ?? 0
53
- } catch {
54
- /* partial line */
55
- }
56
- }
57
- })
58
- res.on('end', () => resolve({ text, genTok }))
59
- res.on('error', reject)
60
- }
61
- )
62
- req.on('error', reject)
63
- req.write(body)
64
- req.end()
65
- })
66
- }
67
-
68
12
  /** Прибирає ```-обгортку й випадковий провідний `##`-заголовок із секції. */
69
13
  function stripSection(text) {
70
14
  let t = text.trim()
@@ -145,17 +89,21 @@ function scoreDoc(md, facts) {
145
89
  return { score: Math.max(0, score), issues }
146
90
  }
147
91
 
148
- /** Tier 2: виклик через pi (провайдер-нейтрально). model рядок `provider/model-id`. */
149
- function piOneShot(facts, src, model) {
150
- const fullPrompt = `${STYLE}\n\n${oneShotPromptText(facts, src)}`
92
+ /** Викликає pi і повертає stdout. Кидає якщо pi повертає ненульовий код. */
93
+ function callPi(prompt, model, timeoutMs) {
151
94
  const modelArgs = model ? ['--model', model] : []
152
- const r = spawnSync('pi', ['-p', fullPrompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
95
+ const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
153
96
  encoding: 'utf8',
154
- timeout: 120_000
97
+ timeout: timeoutMs
155
98
  })
156
- if (r.error) throw new Error(`pi Tier 2 error: ${r.error.message}`)
157
- if (r.status !== 0) throw new Error(`pi Tier 2 exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
158
- const text = r.stdout?.trim() ?? ''
99
+ if (r.error) throw new Error(`pi error: ${r.error.message}`)
100
+ if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
101
+ return r.stdout?.trim() ?? ''
102
+ }
103
+
104
+ /** One-shot: один pi-виклик на весь документ. */
105
+ function piOneShot(facts, src, model, timeoutMs = 120_000) {
106
+ const text = callPi(`${STYLE}\n\n${oneShotPromptText(facts, src)}`, model, timeoutMs)
159
107
  let md = stripSignatures(stripSection(text))
160
108
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
161
109
  return { md: md + '\n', genTok: 0 }
@@ -177,58 +125,43 @@ function assemble(stem, sections) {
177
125
  return parts.join('\n\n') + '\n'
178
126
  }
179
127
 
180
- /** Оркестрований режим: секційно-мінімальний контекст — код інгестується лише в `behavior`. */
181
- async function generateOrchestrated(facts, src, model) {
128
+ /**
129
+ * Orchestrated: N окремих pi-викликів, по одному на секцію.
130
+ * Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
131
+ */
132
+ function piOrchestrated(facts, src, model, timeoutMs) {
182
133
  const sections = {}
183
- let genTok = 0
184
134
  for (const s of sectionMessages(facts, src)) {
185
- const { text, genTok: g } = await ollamaChat(s.messages, { model, numPredict: s.numPredict })
186
- sections[s.key] = stripSignatures(stripSection(text))
187
- genTok += g
135
+ // messages = [{role:'system',content}, {role:'user',content}] plain text prompt для pi
136
+ const prompt = s.messages.map(m => m.content).join('\n\n')
137
+ sections[s.key] = stripSignatures(stripSection(callPi(prompt, model, timeoutMs)))
188
138
  }
189
- return { md: assemble(basename(facts.relPath), sections), genTok }
139
+ return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
190
140
  }
191
141
 
192
- /** One-shot режим: один промпт на весь документ. */
193
- async function generateOneShot(facts, src, model) {
194
- const { text, genTok } = await ollamaChat(oneShotMessages(facts, src), { model, numPredict: 1500 })
195
- let md = stripSignatures(stripSection(text)) // Stage-2 лінт і для one-shot
196
- if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
197
- return { md: md + '\n', genTok }
198
- }
199
142
 
200
- /** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без локального проходу). */
143
+
144
+ /** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без Tier 1 проходу). */
201
145
  const DEFAULT_SYM_THRESHOLD = 4
202
- /** Максимальний час локальної генерації на один файл перед ескалацією у Tier 2. */
146
+ /** Максимальний час Tier 1 генерації на один файл перед ескалацією у Tier 2. */
203
147
  const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
204
- /** Дефолтна Tier 1 модель: N_CURSOR_DOCGEN_MODEL → LOCAL_MIN → ollama gemma3:4b. */
205
- const DEFAULT_LOCAL_MODEL = localModelId(env.N_CURSOR_DOCGEN_MODEL ?? LOCAL_MIN)
206
- /** Дефолтна Tier 2 модель (provider/model-id для pi): N_CURSOR_DOCGEN_CLOUD_MODEL → resolveModel('avg'). */
148
+ /** Дефолтна Tier 1 модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min'). */
149
+ const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? resolveModel('min')
150
+ /** Дефолтна Tier 2 модель: N_CURSOR_DOCGEN_CLOUD_MODEL → resolveModel('avg'). */
207
151
  const DEFAULT_CLOUD_MODEL = env.N_CURSOR_DOCGEN_CLOUD_MODEL ?? resolveModel('avg')
208
152
 
209
- /** Повертає promise, що відхиляється через `ms` мс з повідомленням про timeout. */
210
- function withTimeout(promise, ms) {
211
- return Promise.race([
212
- promise,
213
- new Promise((_, reject) => setTimeout(() => reject(new Error(`local timeout after ${ms / 1000}s`)), ms))
214
- ])
215
- }
216
-
217
153
  /**
218
154
  * Головний API: файл → { md, genTok, ms, score, issues, tier }.
219
155
  *
220
156
  * Routing (sym-threshold):
221
- * sym < symThreshold → Tier 1 local (timeout: LOCAL_TIMEOUT_MS) + det-scorer
157
+ * sym < symThreshold → Tier 1 pi(resolveModel('min'), timeout=5хв) + det-scorer
222
158
  * → timeout або det-score < threshold → Tier 2
223
159
  * sym >= symThreshold → Pre-routing одразу Tier 2
224
- *
225
- * @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
226
160
  */
227
161
  export async function generateDoc(
228
162
  file,
229
163
  {
230
164
  model = DEFAULT_LOCAL_MODEL,
231
- mode = 'orchestrated',
232
165
  cloudModel = DEFAULT_CLOUD_MODEL,
233
166
  threshold = QUALITY_THRESHOLD,
234
167
  symThreshold = DEFAULT_SYM_THRESHOLD
@@ -238,39 +171,24 @@ export async function generateDoc(
238
171
  const facts = extractFacts(src, file)
239
172
  const t0 = Date.now()
240
173
 
241
- // Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2, не витрачаємо local-час
174
+ // Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
242
175
  const complexity = facts.internalSymbols?.length ?? 0
243
176
  if (complexity >= symThreshold && cloudModel) {
244
177
  const r2 = piOneShot(facts, src, cloudModel)
245
- return {
246
- ...r2,
247
- ms: Date.now() - t0,
248
- score: null,
249
- issues: [`pre-routed:sym=${complexity}`],
250
- tier: 2,
251
- model: cloudModel
252
- }
178
+ return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2, model: cloudModel }
253
179
  }
254
180
 
255
- // Tier 1: локальна генерація з timeout 5 хв при перевищенні одразу Tier 2
181
+ // Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
182
+ // facts.unsupported → one-shot (структура файлу нестандартна)
256
183
  let r
257
184
  try {
258
- const localPromise =
259
- facts.unsupported || mode === 'oneshot'
260
- ? generateOneShot(facts, src, model)
261
- : generateOrchestrated(facts, src, model)
262
- r = await withTimeout(localPromise, LOCAL_TIMEOUT_MS)
185
+ r = facts.unsupported
186
+ ? piOneShot(facts, src, model, LOCAL_TIMEOUT_MS)
187
+ : piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS)
263
188
  } catch (e) {
264
189
  if (cloudModel) {
265
190
  const r2 = piOneShot(facts, src, cloudModel)
266
- return {
267
- ...r2,
268
- ms: Date.now() - t0,
269
- score: null,
270
- issues: [`local-timeout: ${e.message}`],
271
- tier: 2,
272
- model: cloudModel
273
- }
191
+ return { ...r2, ms: Date.now() - t0, score: null, issues: [`tier1-error: ${e.message}`], tier: 2, model: cloudModel }
274
192
  }
275
193
  throw e
276
194
  }
@@ -286,19 +204,18 @@ export async function generateDoc(
286
204
  return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1, model }
287
205
  }
288
206
 
289
- // CLI: node docgen-gen.mjs <file> [--oneshot] [--model <m>] [--sym-threshold N] [--tier-only]
207
+ // CLI: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]
290
208
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
291
209
  if (isRunAsCli(import.meta.url)) {
292
210
  const args = process.argv.slice(2)
293
211
  const file = args.find(a => !a.startsWith('--'))
294
- const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
295
212
  const tierOnly = args.includes('--tier-only')
296
213
  const mi = args.indexOf('--model')
297
214
  const model = mi >= 0 ? args[mi + 1] : DEFAULT_LOCAL_MODEL
298
215
  const si = args.indexOf('--sym-threshold')
299
216
  const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
300
217
  if (!file) {
301
- console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--model <m>] [--sym-threshold N] [--tier-only]')
218
+ console.error('Usage: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]')
302
219
  process.exit(1)
303
220
  }
304
221
  if (tierOnly) {
@@ -313,8 +230,8 @@ if (isRunAsCli(import.meta.url)) {
313
230
  process.stdout.write(`${icon} ${label} | ${file}\n`)
314
231
  process.exit(0)
315
232
  }
316
- const r = await generateDoc(file, { model, mode, symThreshold })
233
+ const r = await generateDoc(file, { model, symThreshold })
317
234
  const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
318
- process.stderr.write(`[tier${r.tier} ${mode}] ${r.ms}ms / ${r.genTok} tok / score=${r.score}${issuesTxt}\n`)
235
+ process.stderr.write(`[tier${r.tier} pi-orchestrated] ${r.ms}ms / score=${r.score}${issuesTxt}\n`)
319
236
  process.stdout.write(r.md)
320
237
  }
@@ -1,222 +0,0 @@
1
- /**
2
- * Активний Раннер (spec §8.1 Фасад B): `run`/`resume`/`cancel`/`repair`. Зшиває
3
- * ensureWorktree + planner + executor + verify у повний 5-фазний цикл. Уся IO
4
- * ін'єктується (`runner`/`verify`/`commit`/`run`/`now`) — тестується без
5
- * реальних LLM/git/gates.
6
- */
7
- import { spawnSync } from 'node:child_process'
8
- import { readFileSync } from 'node:fs'
9
- import { join } from 'node:path'
10
- import { cwd as processCwd } from 'node:process'
11
-
12
- import { BudgetExceeded, withBudget } from './budget.mjs'
13
- import { ensureWorktree, realRun } from './commands.mjs'
14
- import { flowEventsPath } from './events.mjs'
15
- import { executePlan } from './executor.mjs'
16
- import { generatePlan } from './planner.mjs'
17
- import { runReview } from './reviewer.mjs'
18
- import { cleanupFlowSiblings, flowStatePath, readState, updateState, writeState } from './state-store.mjs'
19
- import { createRunner } from './subagent-runner.mjs'
20
-
21
- /**
22
- * Дефолтний commit: `git add -A && git commit -m` у worktree.
23
- * @param {string} cwd worktree
24
- * @param {string} msg повідомлення
25
- * @returns {void}
26
- */
27
- function defaultCommit(cwd, msg) {
28
- spawnSync('git', ['add', '-A'], { cwd })
29
- spawnSync('git', ['commit', '-m', msg], { cwd })
30
- }
31
-
32
- /**
33
- * Дефолтний verify для executor-а: проганяє gates і повертає verdict.
34
- * @param {string} cwd worktree
35
- * @returns {{ pass: boolean, failedOutput: string | null }} verdict
36
- */
37
- function defaultVerify(cwd) {
38
- return runReview({ run: realRun, cwd, fingerprint: () => null })
39
- }
40
-
41
- /**
42
- * Читає `flow.autonomous` із `.n-cursor.json` (бюджет автономного режиму).
43
- * @param {string} cwd корінь
44
- * @returns {{ maxApiCalls?: number, maxCostUsd?: number, onBudgetExceeded?: string }} конфіг бюджету
45
- */
46
- function readFlowAutonomous(cwd) {
47
- try {
48
- const cfg = JSON.parse(readFileSync(join(cwd, '.n-cursor.json'), 'utf8'))
49
- return cfg?.flow?.autonomous ?? {}
50
- } catch {
51
- return {}
52
- }
53
- }
54
-
55
- /**
56
- * `flow run [--autonomous] <branch> "<task>"` — повний цикл: ensureWorktree →
57
- * план → executor. У `--autonomous` runner обгортається budget guard-ом (§9.4).
58
- * @param {string[]} rest аргументи (`--autonomous` + `<branch> <task...>`)
59
- * @param {{ runner?: object, verify?: (cwd: string) => object, commit?: (cwd: string, msg: string) => void, run?: (cmd: string, args: string[], opts: object) => object, autonomous?: boolean, budget?: object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
60
- * @returns {Promise<number>} exit code: 0 done, 1 fail, 2 blocked-on-human
61
- */
62
- export async function run(rest, deps = {}) {
63
- const log = deps.log ?? console.error
64
- const now = deps.now ?? Date.now
65
- const autonomous = deps.autonomous ?? rest.includes('--autonomous')
66
- const positional = rest.filter(a => !a.startsWith('--'))
67
-
68
- const ew = ensureWorktree(positional, deps)
69
- if (ew.code !== 0) return ew.code
70
- const { worktreeDir, branch, desc, baseCommit } = ew
71
- const statePath = flowStatePath(worktreeDir)
72
- writeState(statePath, {
73
- branch,
74
- status: 'in_progress',
75
- started_at: new Date(now()).toISOString(),
76
- metadata: { base_commit: baseCommit },
77
- plan: []
78
- })
79
-
80
- let runner
81
- try {
82
- runner = deps.runner ?? (await createRunner(deps))
83
- } catch (error) {
84
- log(`run: ${error.message}`)
85
- return 1
86
- }
87
- if (autonomous) {
88
- const budget = deps.budget ?? readFlowAutonomous(deps.cwd ?? processCwd())
89
- runner = withBudget(runner, { maxApiCalls: budget.maxApiCalls, log })
90
- }
91
-
92
- try {
93
- const plan = await generatePlan({ runner, task: desc, cwd: worktreeDir })
94
- updateState(statePath, s => ({ ...s, plan }))
95
- const result = await executePlan(
96
- { statePath, eventsPath: flowEventsPath(worktreeDir) },
97
- { runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd: worktreeDir, log, now }
98
- )
99
- if (result.status === 'done') {
100
- log('run: build done — далі `flow release`')
101
- return 0
102
- }
103
- if (result.status === 'blocked-on-human') {
104
- log(`run: blocked-on-human на кроці ${result.step}`)
105
- return 2
106
- }
107
- return 1
108
- } catch (error) {
109
- if (error instanceof BudgetExceeded) {
110
- log(`run: ${error.message} — abort`)
111
- updateState(statePath, s => ({ ...s, status: 'failed' }))
112
- return 1
113
- }
114
- log(`run: ${error.message}`)
115
- return 1
116
- }
117
- }
118
-
119
- /**
120
- * `flow resume` — продовжує з чекпойнта. Safe-resume (§4.1.7): скидає частковий
121
- * доробок до останнього коміту; застосовує HITL-відповіді як підказки й дає
122
- * крокам свіжі спроби.
123
- * @param {string[]} _rest аргументи (не використовуються)
124
- * @param {object} [deps] ін'єкції (як у `run`)
125
- * @returns {Promise<number>} exit code
126
- */
127
- export async function resume(_rest, deps = {}) {
128
- const cwd = deps.cwd ?? processCwd()
129
- const log = deps.log ?? console.error
130
- const now = deps.now ?? Date.now
131
- const run_ = deps.run ?? realRun
132
-
133
- const statePath = flowStatePath(cwd)
134
- const state = readState(statePath)
135
- if (!state) {
136
- log('resume: стану нема')
137
- return 1
138
- }
139
-
140
- const openHitl = (state.hitl ?? []).filter(q => !q.answer)
141
- if (state.status === 'blocked-on-human' && openHitl.length > 0) {
142
- log(`resume: ще blocked — ${openHitl.length} відкритих HITL-питань (заповни answer і повтори)`)
143
- return 2
144
- }
145
- if (!state.plan?.length) {
146
- log('resume: нема плану')
147
- return 1
148
- }
149
-
150
- // safe-resume: скинути частковий доробок невдалого кроку до останнього коміту
151
- run_('git', ['reset', '--hard', 'HEAD'], { cwd })
152
-
153
- // застосувати HITL-відповіді як hint + дати незавершеним крокам свіжі спроби
154
- const answers = new Map((state.hitl ?? []).filter(q => q.answer).map(q => [q.step, q.answer]))
155
- updateState(statePath, s => ({
156
- ...s,
157
- status: 'in_progress',
158
- plan: s.plan.map(st =>
159
- st.status === 'done'
160
- ? st
161
- : { ...st, retry_count: 0, ...(answers.has(st.step) ? { hint: answers.get(st.step) } : {}) }
162
- ),
163
- hitl: (s.hitl ?? []).map(q => (q.answer ? { ...q, status: 'answered' } : q))
164
- }))
165
-
166
- let runner
167
- try {
168
- runner = deps.runner ?? (await createRunner(deps))
169
- } catch (error) {
170
- log(`resume: ${error.message}`)
171
- return 1
172
- }
173
-
174
- const result = await executePlan(
175
- { statePath, eventsPath: flowEventsPath(cwd) },
176
- { runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd, log, now }
177
- )
178
- if (result.status === 'done') return 0
179
- if (result.status === 'blocked-on-human') return 2
180
- return 1
181
- }
182
-
183
- /**
184
- * `flow cancel` — скасування: прибирає transient sibling-и (стан/журнал/lock).
185
- * @param {string[]} _rest аргументи
186
- * @param {{ cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
187
- * @returns {Promise<number>} 0
188
- */
189
- export async function cancel(_rest, deps = {}) {
190
- const cwd = deps.cwd ?? processCwd()
191
- const log = deps.log ?? console.error
192
- cleanupFlowSiblings(cwd)
193
- log('cancel: стан і sibling-и прибрано')
194
- return 0
195
- }
196
-
197
- /**
198
- * `flow repair [--discard-step-work]` — fail-closed escape: діагностика стану або
199
- * жорстке скидання робочого дерева до HEAD (свідоме викидання доробку).
200
- * @param {string[]} rest аргументи
201
- * @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
202
- * @returns {Promise<number>} exit code
203
- */
204
- export async function repair(rest, deps = {}) {
205
- const cwd = deps.cwd ?? processCwd()
206
- const log = deps.log ?? console.error
207
- const run_ = deps.run ?? realRun
208
-
209
- if (rest.includes('--discard-step-work')) {
210
- run_('git', ['reset', '--hard', 'HEAD'], { cwd })
211
- log('repair: робоче дерево скинуто до HEAD (--discard-step-work)')
212
- return 0
213
- }
214
- try {
215
- const state = readState(flowStatePath(cwd))
216
- log(state ? `repair: стан валідний (status: ${state.status})` : 'repair: стану нема')
217
- return 0
218
- } catch (error) {
219
- log(`repair: стан пошкоджено — ${error.message}. Спробуй \`flow repair --discard-step-work\` або \`flow cancel\`.`)
220
- return 1
221
- }
222
- }