@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.0.0] - 2026-06-07
4
+
5
+ ### Changed
6
+
7
+ - major version bump to 4.0.0
8
+ - docgen Tier 1: пряму ollama HTTP замінено на pi+resolveModel('min') — universally через каскад
9
+
10
+ ## [3.29.0] - 2026-06-07
11
+
12
+ ### Added
13
+
14
+ - resolveModel(tier) — прозорий каскадний fallback local→cloud для всіх 3 тирів (min/avg/max)
15
+
3
16
  ## [3.28.0] - 2026-06-06
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.28.0",
3
+ "version": "4.0.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -51,8 +51,6 @@
51
51
  "rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
52
52
  },
53
53
  "dependencies": {
54
- "@anthropic-ai/claude-agent-sdk": "^0.3.0",
55
- "@anthropic-ai/sdk": "^0.100.1",
56
54
  "oxc-parser": "^0.128.0",
57
55
  "picomatch": "^4.0.4",
58
56
  "smol-toml": "^1.6.1",
@@ -1,26 +1,20 @@
1
1
  /**
2
2
  * Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
3
3
  *
4
- * Orchestration:
5
- * 1. Перевірка ANTHROPIC_API_KEY + dynamic import SDK (graceful skip).
6
- * 2. Для кожного мутанта: cache lookup класифікаціяcache write.
7
- * 3. На неуспішну класифікацію після retries conservative fallback worth-testing/confidence=0.
8
- *
9
- * Prompt caching: system-prompt передається з cache_control: ephemeral —
10
- * усі мутанти одного прогону reuse кешований префікс на стороні API.
4
+ * Routing:
5
+ * 1. Cache lookup hit використати збережений verdict.
6
+ * 2. Cache miss Tier 1 (LOCAL_MIN через pi) parseVerdict.
7
+ * 3. Tier 1 fail (pi error / bad JSON / Zod) → Tier 2 (CLOUD_MIN через pi).
8
+ * 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
11
9
  */
10
+ import { spawnSync } from 'node:child_process'
12
11
  import { join } from 'node:path'
13
- import { env } from 'node:process'
14
- import { setTimeout } from 'node:timers/promises'
15
12
 
13
+ import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
16
14
  import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
17
15
  import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
18
16
  import { parseVerdict } from './verdict-schema.mjs'
19
17
 
20
- const MODEL = 'claude-sonnet-4-6'
21
- const MAX_RETRIES = 2
22
- const DEFAULT_RETRY_DELAY_MS = 1000
23
-
24
18
  const FALLBACK_VERDICT = {
25
19
  verdict: 'worth-testing',
26
20
  confidence: 0,
@@ -28,36 +22,67 @@ const FALLBACK_VERDICT = {
28
22
  }
29
23
 
30
24
  /**
31
- * Класифікує survived мутантів через Claude API.
32
- * Без API key / без SDK / при критичних помилках — повертає [] (graceful skip).
33
- * @param {Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>} survived список survived груп (як у COVERAGE.md)
34
- * @param {string} cwd корінь проєкту
35
- * @param {{cachePath?: string, client?: object, retryDelayMs?: number}} [opts] ін'єкції для тестів
36
- * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
25
+ * Викликає pi і повертає raw stdout.
26
+ * @param {string} prompt
27
+ * @param {string} model provider/model-id або '' для pi-дефолту
28
+ * @returns {string}
29
+ * @throws якщо pi не знайдено або повертає ненульовий exit code
37
30
  */
38
- export async function classify(survived, cwd, opts = {}) {
39
- const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
40
- const retryDelayMs = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
31
+ function callPi(prompt, model) {
32
+ const modelArgs = model ? ['--model', model] : []
33
+ const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
34
+ encoding: 'utf8',
35
+ timeout: 60_000
36
+ })
37
+ if (r.error) throw new Error(`pi error: ${r.error.message}`)
38
+ if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
39
+ return r.stdout?.trim() ?? ''
40
+ }
41
41
 
42
- if (!env.ANTHROPIC_API_KEY) {
43
- console.warn('⚠ coverage classify: ANTHROPIC_API_KEY not set, classification skipped')
44
- return []
45
- }
42
+ /**
43
+ * Два тири: LOCAL_MIN Tier 2 CLOUD_MIN → FALLBACK_VERDICT.
44
+ * @param {{file: string, mutants: object[]}} group
45
+ * @param {object} mutant
46
+ * @param {string} cwd
47
+ * @param {(prompt: string, model: string) => string} callPiFn ін'єкція для тестів
48
+ * @returns {object} verdict
49
+ */
50
+ function classifyOne(group, mutant, cwd, callPiFn) {
51
+ const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
52
+ const loc = `${group.file}:${mutant.line}:${mutant.col}`
46
53
 
47
- let SDK
54
+ // Tier 1: resolveModel('min') — каскад local→cloud якщо локалі нема
48
55
  try {
49
- SDK = await import('@anthropic-ai/sdk')
56
+ const text = callPiFn(prompt, resolveModel('min'))
57
+ return parseVerdict(text)
50
58
  } catch {
51
- console.warn('⚠ coverage classify: @anthropic-ai/sdk not installed, classification skipped')
52
- return []
59
+ // Tier 2: CLOUD_MIN
60
+ try {
61
+ const text = callPiFn(prompt, CLOUD_MIN)
62
+ return parseVerdict(text)
63
+ } catch (e) {
64
+ console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${e.message}`)
65
+ return { ...FALLBACK_VERDICT }
66
+ }
53
67
  }
54
- const Anthropic = SDK.default
55
- const client = opts.client ?? new Anthropic()
68
+ }
69
+
70
+ /**
71
+ * Класифікує survived мутантів через pi (LOCAL_MIN → CLOUD_MIN → fallback).
72
+ * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
73
+ * @param {string} cwd корінь проєкту
74
+ * @param {{cachePath?: string, callPi?: Function}} [opts] ін'єкції для тестів
75
+ * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
76
+ */
77
+ export async function classify(survived, cwd, opts = {}) {
78
+ const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
79
+ const callPiFn = opts.callPi ?? callPi
80
+ const cacheModel = `${resolveModel('min') || 'default'}+${CLOUD_MIN || 'cloud'}`
56
81
 
57
82
  const cache = readCache(cachePath)
58
- if (cache.model !== MODEL) {
83
+ if (cache.model !== cacheModel) {
59
84
  cache.entries = {}
60
- cache.model = MODEL
85
+ cache.model = cacheModel
61
86
  }
62
87
 
63
88
  const verdicts = []
@@ -77,7 +102,7 @@ export async function classify(survived, cwd, opts = {}) {
77
102
  }
78
103
  }
79
104
  if (!verdict) {
80
- verdict = await classifyOne(client, group, mutant, cwd, retryDelayMs)
105
+ verdict = classifyOne(group, mutant, cwd, callPiFn)
81
106
  if (cacheKey) {
82
107
  cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
83
108
  }
@@ -90,40 +115,3 @@ export async function classify(survived, cwd, opts = {}) {
90
115
  writeCache(cachePath, cache)
91
116
  return verdicts
92
117
  }
93
-
94
- /**
95
- * Один виклик API з retry. На фейл після MAX_RETRIES — повертає FALLBACK_VERDICT.
96
- * @param {{messages: {create: Function}}} client SDK client
97
- * @param {{file: string}} group group для контексту
98
- * @param {object} mutant mutant data
99
- * @param {string} cwd корінь
100
- * @param {number} retryDelayMs base delay для exp-backoff (0 у тестах)
101
- * @returns {Promise<object>} verdict (parsed або fallback)
102
- */
103
- async function classifyOne(client, group, mutant, cwd, retryDelayMs) {
104
- const userPrompt = buildUserPrompt({ ...mutant, file: group.file }, cwd)
105
- let lastError = null
106
-
107
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
108
- try {
109
- const response = await client.messages.create({
110
- model: MODEL,
111
- max_tokens: 1024,
112
- system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
113
- messages: [{ role: 'user', content: userPrompt }]
114
- })
115
- const text = response?.content?.[0]?.text ?? ''
116
- return parseVerdict(text)
117
- } catch (error) {
118
- lastError = error
119
- if (attempt < MAX_RETRIES && retryDelayMs > 0) {
120
- await setTimeout(retryDelayMs * 2 ** attempt)
121
- }
122
- }
123
- }
124
-
125
- console.warn(
126
- `⚠ coverage classify: ${group.file}:${mutant.line}:${mutant.col} failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message ?? 'unknown'}`
127
- )
128
- return { ...FALLBACK_VERDICT }
129
- }
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `n-cursor coverage --fix`: запускає Claude Code агента для написання тестів
2
+ * `n-cursor coverage --fix`: запускає pi-агента для написання тестів
3
3
  * по вцілілих мутантах Stryker. Агент отримує список мутантів з контекстом
4
4
  * (file, line, оригінальний код, вцілілий варіант, тип мутації) і самостійно
5
5
  * знаходить або створює відповідні test-файли.
6
6
  *
7
- * Залежить від `@anthropic-ai/claude-agent-sdk` (dependencies у npm/package.json).
7
+ * Модель: CLOUD_MAX (складна агентна задача) або N_CURSOR_COVERAGE_FIX_MODEL.
8
8
  */
9
9
  import { readFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
+ import { spawnSync } from 'node:child_process'
12
+
13
+ import { resolveModel } from '../lib/models.mjs'
14
+
15
+ const MODEL = process.env.N_CURSOR_COVERAGE_FIX_MODEL ?? resolveModel('max')
11
16
 
12
17
  /**
13
18
  * @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
@@ -15,12 +20,13 @@ import { join } from 'node:path'
15
20
  */
16
21
 
17
22
  /**
18
- * Запускає Claude Code агента для написання тестів по вцілілих мутантах.
23
+ * Запускає pi-агента для написання тестів по вцілілих мутантах.
19
24
  * @param {SurvivedFileGroup[]} survived вцілілі мутанти, згруповані по файлах
20
25
  * @param {string} projectRoot абсолютний шлях до кореня проєкту
26
+ * @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => void }} [opts] ін'єкції для тестів
21
27
  * @returns {Promise<void>}
22
28
  */
23
- export async function fixSurvivedMutants(survived, projectRoot) {
29
+ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
24
30
  const totalMutants = survived.reduce((s, g) => s + g.mutants.length, 0)
25
31
  if (totalMutants === 0) {
26
32
  console.log('✓ Всі мутанти вбиті — доповнення тестів не потрібне')
@@ -30,26 +36,23 @@ export async function fixSurvivedMutants(survived, projectRoot) {
30
36
  const prompt = await buildFixPrompt(survived, projectRoot)
31
37
  console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вцілілих мутантів...\n`)
32
38
 
33
- // Dynamic import: @anthropic-ai/claude-agent-sdk завантажується лише при --fix,
34
- // щоб не гальмувати звичайний coverage-прогін за відсутності пакету.
35
- const { query } = await import('@anthropic-ai/claude-agent-sdk')
39
+ const callPiFn = opts.callPi ?? callPi
40
+ callPiFn(prompt, MODEL, { cwd: projectRoot })
41
+ }
36
42
 
37
- for await (const msg of query({
38
- prompt,
39
- options: {
40
- cwd: projectRoot,
41
- maxTurns: 20,
42
- allowedTools: ['Read', 'Edit', 'Bash'],
43
- // Без permissionMode headless-агент SDK не отримує дозволу на запис/Bash —
44
- // він крутиться, палить токени, але НЕ редагує файли (підтверджено пробом).
45
- // `bypassPermissions` потрібен, бо агент і пише тести (Edit), і ганяє `bun test` (Bash);
46
- // `acceptEdits` авто-підтверджує лише edits, а Bash лишився б заблокованим.
47
- permissionMode: 'bypassPermissions'
48
- }
49
- })) {
50
- if (msg.type === 'text') process.stdout.write(msg.text)
51
- }
52
- process.stdout.write('\n')
43
+ /**
44
+ * Викликає pi в агентному режимі з live-output до stdout.
45
+ * @param {string} prompt
46
+ * @param {string} model provider/model-id або '' для pi-дефолту
47
+ * @param {{ cwd?: string }} [piOpts]
48
+ */
49
+ function callPi(prompt, model, { cwd } = {}) {
50
+ const modelArgs = model ? ['--model', model] : []
51
+ spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
52
+ cwd,
53
+ stdio: 'inherit',
54
+ timeout: 900_000
55
+ })
53
56
  }
54
57
 
55
58
  /**
@@ -1,86 +1,45 @@
1
1
  /**
2
- * CLI-диспетчер `n-cursor flow` (spec §8 Dual-Mode Dispatcher).
2
+ * CLI-диспетчер `n-cursor flow` (думка.MD протокол всередині вузла графу).
3
3
  *
4
- * Два фасади навколо єдиного джерела істини `.flow.json`:
5
- * - **Пасивний Турнікет** (Фасад A): `init`, `verify`, `release` для IDE-
6
- * агентів (Cursor/Claude Code), що самі пишуть код; `n-cursor` лише судить.
7
- * - **Активний Раннер** (Фасад B): `run`, `resume`, `cancel`, `repair`
8
- * повний 5-фазний polyfill-цикл для headless/CI.
4
+ * flow plan — Stage 1: читає task.md, створює plan_NNN.md, виводить контекст
5
+ * flow verify — Stage 2: структурний check + ## Done when + outputs на stdout
6
+ * flow done CWD node path `graph done <path>`
7
+ * flow audit — CWD node path pending-audit_NNN.md → `graph audit <path>`
8
+ * flow failed — CWD → node path → `graph failed <path>`
9
+ * flow spawn — CWD → node path → `graph spawn <path>`
9
10
  */
10
- import { cancel, repair, resume, run } from './lib/active.mjs'
11
- import { init, release, verify } from './lib/commands.mjs'
12
- import { gate } from './lib/gate.mjs'
13
- import { plan } from './lib/plan.mjs'
14
- import { review } from './lib/review.mjs'
15
- import { spec } from './lib/spec.mjs'
11
+ import { plan } from './lib/flow-plan.mjs'
12
+ import { verify } from './lib/flow-verify.mjs'
13
+ import { audit, done, failed, spawn } from './lib/flow-signals.mjs'
16
14
 
17
15
  const USAGE = [
18
16
  'Usage:',
19
- ' npx @nitra/cursor flow init "<опис>" # Фасад A: worktree + .flow.json (+ level)',
20
- ' npx @nitra/cursor flow spec [--panel] # Фасад A: фаза дизайну docs/specs/<…>',
21
- ' npx @nitra/cursor flow plan [--panel] # Фасад A: фаза плану docs/plans/<…> + state',
22
- ' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
23
- ' npx @nitra/cursor flow review # Фасад A: adversarial diff-review (за level)',
24
- ' npx @nitra/cursor flow gate # Фасад A: вердикт PASS/CONCERNS/FAIL (verify+review)',
25
- ' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
26
- ' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
27
- ' npx @nitra/cursor flow resume # продовжити з чекпойнта',
28
- ' npx @nitra/cursor flow cancel # скасувати, прибрати стан',
29
- ' npx @nitra/cursor flow repair [--discard-step-work] # відновлення пошкодженого стану'
17
+ ' npx @nitra/cursor flow plan # Stage 1: читає task.md, створює plan_NNN.md',
18
+ ' npx @nitra/cursor flow verify # Stage 2: структурна перевірка + stdout-контекст для агента',
19
+ ' npx @nitra/cursor flow done # успіхgraph done <node-path>',
20
+ ' npx @nitra/cursor flow audit # аудит pending-audit_NNN.md graph audit <node-path>',
21
+ ' npx @nitra/cursor flow failed # провал graph failed <node-path>',
22
+ ' npx @nitra/cursor flow spawn # розклад graph spawn <node-path>'
30
23
  ].join('\n')
31
24
 
32
25
  /**
33
- * Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
34
26
  * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
35
27
  */
36
- export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, gate, release, run, resume, cancel, repair }
37
-
38
- /**
39
- * Витягує опційний `--branch <гілка>` з аргументів (для cwd-незалежного резолву
40
- * стану — беклог #1). Повертає очищені аргументи й значення гілки.
41
- * @param {string[]} args аргументи після підкоманди
42
- * @returns {{ rest: string[], branch: string | undefined }} очищені аргументи + гілка
43
- */
44
- export function extractBranchFlag(args) {
45
- const rest = []
46
- let branch
47
- for (let i = 0; i < args.length; i++) {
48
- if (args[i] === '--branch') {
49
- const val = args[i + 1]
50
- // Поглинаємо наступний аргумент як значення лише якщо це справді значення,
51
- // а не інший прапорець / кінець аргументів (інакше `--branch` був би no-op,
52
- // що тихо ковтав би сусідній прапорець).
53
- if (val !== undefined && !val.startsWith('-')) {
54
- branch = val
55
- i++
56
- }
57
- continue
58
- }
59
- const inline = args[i].startsWith('--branch=') ? args[i].slice('--branch='.length) : null
60
- if (inline !== null) {
61
- if (inline !== '') branch = inline
62
- continue
63
- }
64
- rest.push(args[i])
65
- }
66
- return { rest, branch }
67
- }
28
+ export const DEFAULT_HANDLERS = { plan, verify, done, audit, failed, spawn }
68
29
 
69
30
  /**
70
31
  * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
71
32
  * маршрутизує до handler-а. Невідома/відсутня підкоманда → usage + код 1.
72
- * Опційний `--branch <гілка>` прокидається в `deps.branch` (резолв стану поза worktree).
73
33
  * @param {string[]} args аргументи після `flow`
74
- * @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>>, branch?: string }} [deps] ін'єкція handler-ів (для тестів)
34
+ * @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>> }} [deps] ін'єкція handler-ів (для тестів)
75
35
  * @returns {Promise<number>} exit code
76
36
  */
77
37
  export async function runFlowCli(args, deps = {}) {
78
- const [sub, ...raw] = args
38
+ const [sub, ...rest] = args
79
39
  const handlers = deps.handlers ?? DEFAULT_HANDLERS
80
40
  if (!sub || !Object.hasOwn(handlers, sub)) {
81
41
  console.error(USAGE)
82
42
  return 1
83
43
  }
84
- const { rest, branch } = extractBranchFlag(raw)
85
- return await handlers[sub](rest, { ...deps, branch: deps.branch ?? branch })
44
+ return await handlers[sub](rest, deps)
86
45
  }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Handler `flow plan` — Stage 1 (думка.MD § "flow plan").
3
+ *
4
+ * Читає `task.md` у поточному вузлі, розбирає `mode` і `hint` з front-matter,
5
+ * знаходить наступний номер `plan_NNN.md`, пише шаблон і виводить контекст для
6
+ * агента (task + mode + hint) на stdout.
7
+ *
8
+ * FS та path-резолвінг ін'єктуються — тестується без реального диска.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { cwd as processCwd } from 'node:process'
13
+
14
+ const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
15
+
16
+ /**
17
+ * Парсить YAML front-matter (мінімально: лише прості `key: value` рядки).
18
+ * @param {string} text вміст файлу
19
+ * @returns {Record<string, string>} ключ-значення з front-matter (рядки)
20
+ */
21
+ function parseFrontMatter(text) {
22
+ const m = text.match(FRONT_MATTER_RE)
23
+ if (!m) return {}
24
+ const result = {}
25
+ for (const line of m[1].split(/\r?\n/)) {
26
+ const idx = line.indexOf(':')
27
+ if (idx === -1) continue
28
+ const key = line.slice(0, idx).trim()
29
+ const val = line.slice(idx + 1).trim()
30
+ if (key) result[key] = val
31
+ }
32
+ return result
33
+ }
34
+
35
+ /**
36
+ * Знаходить наступний номер `plan_NNN.md` у директорії вузла.
37
+ * @param {string} dir абсолютний шлях до директорії вузла
38
+ * @param {(dir: string) => string[]} readdir інжектована readdir
39
+ * @returns {string} рядок типу `001`, `002`, …
40
+ */
41
+ function nextPlanNumber(dir, readdir) {
42
+ const files = readdir(dir)
43
+ let max = 0
44
+ for (const f of files) {
45
+ const m = f.match(/^plan_(\d+)\.md$/)
46
+ if (m) {
47
+ const n = parseInt(m[1], 10)
48
+ if (n > max) max = n
49
+ }
50
+ }
51
+ return String(max + 1).padStart(3, '0')
52
+ }
53
+
54
+ /**
55
+ * Будує вміст шаблону `plan_NNN.md`.
56
+ * @param {{ mode: string, hint: string, now: string }} params параметри
57
+ * @returns {string} вміст файлу
58
+ */
59
+ export function buildPlanTemplate({ mode, hint, now }) {
60
+ return [
61
+ '---',
62
+ `created_at: ${now}`,
63
+ `mode: ${mode}`,
64
+ `decision: ${hint || 'atomic | composite'}`,
65
+ '---',
66
+ '',
67
+ '## Context',
68
+ "<!-- Чому саме такий підхід — що агент/людина з'ясували -->",
69
+ '',
70
+ '## Approach',
71
+ '<!-- atomic: покроковий план виконання -->',
72
+ '<!-- composite: список дочірніх вузлів з описами -->',
73
+ '',
74
+ '## Risks',
75
+ '<!-- Що може піти не так -->',
76
+ ''
77
+ ].join('\n')
78
+ }
79
+
80
+ /**
81
+ * `flow plan` handler.
82
+ *
83
+ * @param {string[]} _rest аргументи після `plan` (не використовуються)
84
+ * @param {{
85
+ * cwd?: string,
86
+ * log?: (m: string) => void,
87
+ * readFile?: (path: string, enc: string) => string,
88
+ * writeFile?: (path: string, content: string, enc: string) => void,
89
+ * readdir?: (dir: string) => string[],
90
+ * exists?: (path: string) => boolean,
91
+ * now?: () => string
92
+ * }} [deps] ін'єкції
93
+ * @returns {Promise<number>} exit code
94
+ */
95
+ export async function plan(_rest, deps = {}) {
96
+ const cwd = deps.cwd ?? processCwd()
97
+ const log = deps.log ?? console.error
98
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
99
+ const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
100
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
101
+ const exists = deps.exists ?? existsSync
102
+ const nowFn = deps.now ?? (() => new Date().toISOString())
103
+
104
+ const taskPath = join(cwd, 'task.md')
105
+ if (!exists(taskPath)) {
106
+ log('flow plan: task.md не знайдено в CWD')
107
+ return 1
108
+ }
109
+
110
+ let taskContent
111
+ try {
112
+ taskContent = readFile(taskPath, 'utf8')
113
+ } catch (err) {
114
+ log(`flow plan: не вдалося прочитати task.md — ${err instanceof Error ? err.message : String(err)}`)
115
+ return 1
116
+ }
117
+
118
+ const fm = parseFrontMatter(taskContent)
119
+ const mode = fm.mode || 'human'
120
+ const hint = fm.hint || ''
121
+
122
+ const num = nextPlanNumber(cwd, readdir)
123
+ const planPath = join(cwd, `plan_${num}.md`)
124
+
125
+ const content = buildPlanTemplate({ mode, hint, now: nowFn() })
126
+ try {
127
+ writeFile(planPath, content, 'utf8')
128
+ } catch (err) {
129
+ log(`flow plan: не вдалося записати ${planPath} — ${err instanceof Error ? err.message : String(err)}`)
130
+ return 1
131
+ }
132
+
133
+ log(`flow plan: створено ${planPath}`)
134
+
135
+ // Виводимо контекст для агента на stdout
136
+ const outLines = [
137
+ `## flow plan context`,
138
+ ``,
139
+ `mode: ${mode}`,
140
+ hint ? `hint: ${hint}` : `hint: (не задано — агент вирішує сам)`,
141
+ `plan: plan_${num}.md`,
142
+ ``
143
+ ]
144
+ // Додаємо вміст task.md для контексту (без front-matter)
145
+ outLines.push(`### task.md`)
146
+ const bodyStart = taskContent.indexOf('\n---\n', 4)
147
+ const taskBody = bodyStart !== -1 ? taskContent.slice(bodyStart + 5).trimStart() : taskContent
148
+ outLines.push(taskBody.trimEnd())
149
+
150
+ console.log(outLines.join('\n'))
151
+
152
+ return 0
153
+ }