@nitra/cursor 3.28.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,62 +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'
4
+ import { spawnSync } from 'node:child_process'
5
5
  import { env } from 'node:process'
6
- import Anthropic from '@anthropic-ai/sdk'
6
+ import { resolveModel } from '../../../lib/models.mjs'
7
7
  import { extractFacts } from './docgen-extract.mjs'
8
- import { sectionMessages, oneShotMessages, STYLE, oneShotPromptText } from './docgen-prompts.mjs'
8
+ import { STYLE, oneShotPromptText, sectionMessages } from './docgen-prompts.mjs'
9
9
 
10
10
  const QUALITY_THRESHOLD = 70
11
11
 
12
- /** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
13
- async function ollamaChat(messages, { model, numPredict = 600 }) {
14
- const body = JSON.stringify({
15
- model,
16
- messages,
17
- stream: true,
18
- think: false,
19
- options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
20
- keep_alive: '15m'
21
- })
22
- return new Promise((resolve, reject) => {
23
- const req = request(
24
- {
25
- hostname: 'localhost',
26
- port: 11434,
27
- path: '/api/chat',
28
- method: 'POST',
29
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
30
- },
31
- res => {
32
- let text = '',
33
- genTok = 0,
34
- buf = ''
35
- res.on('data', chunk => {
36
- buf += chunk.toString()
37
- const lines = buf.split('\n')
38
- buf = lines.pop()
39
- for (const line of lines) {
40
- if (!line.trim()) continue
41
- try {
42
- const j = JSON.parse(line)
43
- text += j.message?.content ?? ''
44
- if (j.done) genTok = j.eval_count ?? 0
45
- } catch {
46
- /* partial line */
47
- }
48
- }
49
- })
50
- res.on('end', () => resolve({ text, genTok }))
51
- res.on('error', reject)
52
- }
53
- )
54
- req.on('error', reject)
55
- req.write(body)
56
- req.end()
57
- })
58
- }
59
-
60
12
  /** Прибирає ```-обгортку й випадковий провідний `##`-заголовок із секції. */
61
13
  function stripSection(text) {
62
14
  let t = text.trim()
@@ -137,73 +89,24 @@ function scoreDoc(md, facts) {
137
89
  return { score: Math.max(0, score), issues }
138
90
  }
139
91
 
140
- const SCORE_RUBRIC = `Оціни якість документації для JavaScript-модуля за 4 критеріями (1-3 кожен):
141
-
142
- - огляд: 3=описує роль модуля в системі (ЩО і НАВІЩО); 2=частково розмитий; 1=відсутній або перераховує функції
143
- - поведінка: 3=бізнес-терміни, без деталей реалізації; 2=деякі impl-деталі; 1=переважно реалізація або відсутня
144
- - гарантії: 3=лише реальні інваріанти підтверджені кодом, без галюцинацій; 2=частково правильні; 1=вигадані або відсутні
145
- - стиль: 3=без сигнатур/internal-імен, правильна markdown-структура; 2=дрібні порушення; 1=сигнатури/internal-імена/відсутні заголовки
146
-
147
- Відповідай ТІЛЬКИ JSON без пояснень:
148
- {"огляд":N,"поведінка":N,"гарантії":N,"стиль":N,"issues":["коротко про кожен мінус 1-5 слів"]}`
149
-
150
- /**
151
- * Stage 2.5 cloud: Claude Haiku оцінює якість доку проти коду + фактів.
152
- * Використовує найдешевшу хмарну модель — haiku — для мінімальної вартості судді.
153
- * @returns {{ score: number, scores: object, issues: string[], tok: number }}
154
- */
155
- async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001') {
156
- const client = new Anthropic()
157
- const factsTxt = [
158
- facts.exports?.length ? `Публічні функції: ${facts.exports.map(e => e.name).join(', ')}` : '',
159
- facts.internalSymbols?.length ? `Внутрішні (не публічні): ${facts.internalSymbols.join(', ')}` : '',
160
- facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
161
- facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
162
- facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
163
- ]
164
- .filter(Boolean)
165
- .join('\n')
166
-
167
- const msg = await client.messages.create({
168
- model,
169
- max_tokens: 256,
170
- system: SCORE_RUBRIC,
171
- messages: [
172
- {
173
- role: 'user',
174
- content: [
175
- { type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
176
- { type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
177
- { type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
178
- ]
179
- }
180
- ]
92
+ /** Викликає pi і повертає stdout. Кидає якщо pi повертає ненульовий код. */
93
+ function callPi(prompt, model, timeoutMs) {
94
+ const modelArgs = model ? ['--model', model] : []
95
+ const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
96
+ encoding: 'utf8',
97
+ timeout: timeoutMs
181
98
  })
182
- const tok = (msg.usage?.input_tokens ?? 0) + (msg.usage?.output_tokens ?? 0)
183
- try {
184
- const j = JSON.parse(msg.content[0]?.text ?? '{}')
185
- const total = (((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12) * 100
186
- return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
187
- } catch {
188
- return { score: 50, scores: {}, issues: ['parse-error'], tok }
189
- }
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() ?? ''
190
102
  }
191
103
 
192
- /** Tier 2: хмарний fallback через Claude коли local-score < QUALITY_THRESHOLD. */
193
- async function claudeOneShot(facts, src, model = 'claude-sonnet-4-6') {
194
- const client = new Anthropic()
195
- const prompt = oneShotPromptText(facts, src)
196
- const msg = await client.messages.create({
197
- model,
198
- max_tokens: 1500,
199
- system: STYLE,
200
- messages: [{ role: 'user', content: prompt }]
201
- })
202
- const text = msg.content[0]?.text ?? ''
203
- const genTok = msg.usage?.output_tokens ?? 0
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)
204
107
  let md = stripSignatures(stripSection(text))
205
108
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
206
- return { md: md + '\n', genTok }
109
+ return { md: md + '\n', genTok: 0 }
207
110
  }
208
111
 
209
112
  /** Stage 3: фіксовані заголовки у фіксованому порядку. */
@@ -222,55 +125,44 @@ function assemble(stem, sections) {
222
125
  return parts.join('\n\n') + '\n'
223
126
  }
224
127
 
225
- /** Оркестрований режим: секційно-мінімальний контекст — код інгестується лише в `behavior`. */
226
- async function generateOrchestrated(facts, src, model) {
128
+ /**
129
+ * Orchestrated: N окремих pi-викликів, по одному на секцію.
130
+ * Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
131
+ */
132
+ function piOrchestrated(facts, src, model, timeoutMs) {
227
133
  const sections = {}
228
- let genTok = 0
229
134
  for (const s of sectionMessages(facts, src)) {
230
- const { text, genTok: g } = await ollamaChat(s.messages, { model, numPredict: s.numPredict })
231
- sections[s.key] = stripSignatures(stripSection(text))
232
- 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)))
233
138
  }
234
- return { md: assemble(basename(facts.relPath), sections), genTok }
139
+ return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
235
140
  }
236
141
 
237
- /** One-shot режим: один промпт на весь документ. */
238
- async function generateOneShot(facts, src, model) {
239
- const { text, genTok } = await ollamaChat(oneShotMessages(facts, src), { model, numPredict: 1500 })
240
- let md = stripSignatures(stripSection(text)) // Stage-2 лінт і для one-shot
241
- if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
242
- return { md: md + '\n', genTok }
243
- }
244
142
 
245
- /** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без локального проходу). */
143
+
144
+ /** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без Tier 1 проходу). */
246
145
  const DEFAULT_SYM_THRESHOLD = 4
247
- /** Максимальний час локальної генерації на один файл перед ескалацією у Tier 2. */
146
+ /** Максимальний час Tier 1 генерації на один файл перед ескалацією у Tier 2. */
248
147
  const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
249
-
250
- /** Повертає promise, що відхиляється через `ms` мс з повідомленням про timeout. */
251
- function withTimeout(promise, ms) {
252
- return Promise.race([
253
- promise,
254
- new Promise((_, reject) => setTimeout(() => reject(new Error(`local timeout after ${ms / 1000}s`)), ms))
255
- ])
256
- }
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'). */
151
+ const DEFAULT_CLOUD_MODEL = env.N_CURSOR_DOCGEN_CLOUD_MODEL ?? resolveModel('avg')
257
152
 
258
153
  /**
259
154
  * Головний API: файл → { md, genTok, ms, score, issues, tier }.
260
155
  *
261
156
  * Routing (sym-threshold):
262
- * sym < symThreshold → Tier 1 local (timeout: LOCAL_TIMEOUT_MS) + det-scorer
157
+ * sym < symThreshold → Tier 1 pi(resolveModel('min'), timeout=5хв) + det-scorer
263
158
  * → timeout або det-score < threshold → Tier 2
264
159
  * sym >= symThreshold → Pre-routing одразу Tier 2
265
- *
266
- * @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
267
160
  */
268
161
  export async function generateDoc(
269
162
  file,
270
163
  {
271
- model = 'gemma3:4b',
272
- mode = 'orchestrated',
273
- cloudModel = 'claude-sonnet-4-6',
164
+ model = DEFAULT_LOCAL_MODEL,
165
+ cloudModel = DEFAULT_CLOUD_MODEL,
274
166
  threshold = QUALITY_THRESHOLD,
275
167
  symThreshold = DEFAULT_SYM_THRESHOLD
276
168
  } = {}
@@ -279,39 +171,24 @@ export async function generateDoc(
279
171
  const facts = extractFacts(src, file)
280
172
  const t0 = Date.now()
281
173
 
282
- // Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2, не витрачаємо local-час
174
+ // Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
283
175
  const complexity = facts.internalSymbols?.length ?? 0
284
- if (complexity >= symThreshold && env.ANTHROPIC_API_KEY) {
285
- const r2 = await claudeOneShot(facts, src, cloudModel)
286
- return {
287
- ...r2,
288
- ms: Date.now() - t0,
289
- score: null,
290
- issues: [`pre-routed:sym=${complexity}`],
291
- tier: 2,
292
- model: cloudModel
293
- }
176
+ if (complexity >= symThreshold && cloudModel) {
177
+ const r2 = piOneShot(facts, src, cloudModel)
178
+ return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2, model: cloudModel }
294
179
  }
295
180
 
296
- // Tier 1: локальна генерація з timeout 5 хв при перевищенні одразу Tier 2
181
+ // Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
182
+ // facts.unsupported → one-shot (структура файлу нестандартна)
297
183
  let r
298
184
  try {
299
- const localPromise =
300
- facts.unsupported || mode === 'oneshot'
301
- ? generateOneShot(facts, src, model)
302
- : generateOrchestrated(facts, src, model)
303
- 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)
304
188
  } catch (e) {
305
- if (env.ANTHROPIC_API_KEY) {
306
- const r2 = await claudeOneShot(facts, src, cloudModel)
307
- return {
308
- ...r2,
309
- ms: Date.now() - t0,
310
- score: null,
311
- issues: [`local-timeout: ${e.message}`],
312
- tier: 2,
313
- model: cloudModel
314
- }
189
+ if (cloudModel) {
190
+ const r2 = piOneShot(facts, src, cloudModel)
191
+ return { ...r2, ms: Date.now() - t0, score: null, issues: [`tier1-error: ${e.message}`], tier: 2, model: cloudModel }
315
192
  }
316
193
  throw e
317
194
  }
@@ -319,27 +196,26 @@ export async function generateDoc(
319
196
  // Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
320
197
  const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
321
198
 
322
- if (detScore < threshold && env.ANTHROPIC_API_KEY) {
323
- const r2 = await claudeOneShot(facts, src, cloudModel)
199
+ if (detScore < threshold && cloudModel) {
200
+ const r2 = piOneShot(facts, src, cloudModel)
324
201
  return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2, model: cloudModel }
325
202
  }
326
203
 
327
204
  return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1, model }
328
205
  }
329
206
 
330
- // 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]
331
208
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
332
209
  if (isRunAsCli(import.meta.url)) {
333
210
  const args = process.argv.slice(2)
334
211
  const file = args.find(a => !a.startsWith('--'))
335
- const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
336
212
  const tierOnly = args.includes('--tier-only')
337
213
  const mi = args.indexOf('--model')
338
- const model = mi >= 0 ? args[mi + 1] : 'gemma3:4b'
214
+ const model = mi >= 0 ? args[mi + 1] : DEFAULT_LOCAL_MODEL
339
215
  const si = args.indexOf('--sym-threshold')
340
216
  const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
341
217
  if (!file) {
342
- 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]')
343
219
  process.exit(1)
344
220
  }
345
221
  if (tierOnly) {
@@ -354,8 +230,8 @@ if (isRunAsCli(import.meta.url)) {
354
230
  process.stdout.write(`${icon} ${label} | ${file}\n`)
355
231
  process.exit(0)
356
232
  }
357
- const r = await generateDoc(file, { model, mode, symThreshold })
233
+ const r = await generateDoc(file, { model, symThreshold })
358
234
  const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
359
- 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`)
360
236
  process.stdout.write(r.md)
361
237
  }
@@ -4,12 +4,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { env } from 'node:process'
7
- import { CLOUD_MIN, CLOUD_AVG } from '../../../lib/models.mjs'
7
+ import { resolveModel } from '../../../lib/models.mjs'
8
8
 
9
- // Тир за замовчуванням: CLOUD_MINCLOUD_AVG при ескалації.
9
+ // Тир за замовчуванням: minavg при ескалації (каскад local→cloud).
10
10
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
11
- export const MODEL = env.N_CURSOR_FIX_MODEL ?? CLOUD_MIN
12
- export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? CLOUD_AVG
11
+ export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
12
+ export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
13
13
 
14
14
  /**
15
15
  * Витягує відносні шляхи файлів із violation output.
@@ -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
- }
@@ -1,79 +0,0 @@
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, statSync } from 'node:fs'
10
- import { join } from 'node:path'
11
-
12
- import { runTraceCli } from '../trace.mjs'
13
-
14
- /**
15
- * Резолвить артефакт у `docs/<kind>/`. Пріоритет: файли, чия назва містить
16
- * хвіст гілки (slug, напр. `flow-gate` з `claude/flow-gate`); серед них (або
17
- * серед усіх, якщо збігу нема) — **найсвіжіший за mtime**. Лексикографічний
18
- * вибір був хибним при кількох артефактах на одну дату (виявлено dogfood'ом).
19
- * @param {string} cwd корінь worktree
20
- * @param {'specs' | 'plans'} kind підкаталог `docs`
21
- * @param {string} [branch] гілка задачі — для пріоритету за slug
22
- * @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
23
- */
24
- export function resolveArtifact(cwd, kind, branch) {
25
- const dir = join(cwd, 'docs', kind)
26
- if (!existsSync(dir)) return null
27
- const md = readdirSync(dir).filter(f => f.endsWith('.md'))
28
- if (md.length === 0) return null
29
-
30
- const slug = branch ? branch.split('/').pop() : null
31
- const matched = slug ? md.filter(f => f.includes(slug)) : []
32
- const pool = matched.length > 0 ? matched : md
33
-
34
- const best = pool
35
- .map(f => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
36
- .toSorted((a, b) => a.mtime - b.mtime || (a.f < b.f ? -1 : 1))
37
- .at(-1)
38
- return join(dir, best.f)
39
- }
40
-
41
- /** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
42
- const ACCEPTANCE_MARK = '— acceptance:'
43
- /** Лише цифри — перевірка нумерації кроку (лінійний, без backtracking). */
44
- const DIGITS_RE = /^\d+$/u
45
-
46
- /**
47
- * Кроки зі секції плану — нумерований список `N. <task> — acceptance: <crit>`.
48
- * Best-effort парсинг через `indexOf` (без regex-backtracking): рядки поза
49
- * форматом ігноруються.
50
- * @param {string} text вміст plan-doc
51
- * @returns {{ task: string, acceptance?: string }[]} кроки у порядку появи
52
- */
53
- export function extractSteps(text) {
54
- const steps = []
55
- for (const raw of String(text).split('\n')) {
56
- const line = raw.trim()
57
- const dot = line.indexOf('. ')
58
- if (dot <= 0 || !DIGITS_RE.test(line.slice(0, dot))) continue
59
- const body = line.slice(dot + 2).trim()
60
- const sep = body.toLowerCase().indexOf(ACCEPTANCE_MARK)
61
- if (sep === -1) {
62
- steps.push({ task: body })
63
- } else {
64
- steps.push({ task: body.slice(0, sep).trim(), acceptance: body.slice(sep + ACCEPTANCE_MARK.length).trim() })
65
- }
66
- }
67
- return steps
68
- }
69
-
70
- /**
71
- * Read-only перевірка цілісності ланцюга артефактів (не мутує — лише сигнал).
72
- * @param {string} cwd корінь worktree
73
- * @param {(cwd: string) => number} [runTrace] runner trace (0 — цілісно, 1 — розрив); ін'єкція для тестів
74
- * @returns {boolean} true, якщо ланцюг цілісний
75
- */
76
- export function verifyTrace(cwd, runTrace) {
77
- const run = runTrace ?? (c => runTraceCli([], { cwd: c, log: () => {} }))
78
- return run(cwd) === 0
79
- }