@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.1.0] - 2026-06-07
4
+
5
+ ### Added
6
+
7
+ - checkOllama() + ollama HTTP як primary path, pi як fallback у docgen-gen
8
+
9
+ ## [4.0.0] - 2026-06-07
10
+
11
+ ### Changed
12
+
13
+ - major version bump to 4.0.0
14
+ - docgen Tier 1: пряму ollama HTTP замінено на pi+resolveModel('min') — universally через каскад
15
+
3
16
  ## [3.29.0] - 2026-06-07
4
17
 
5
18
  ### Added
package/lib/models.mjs ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Глобальна класифікація моделей для pi.
3
+ *
4
+ * Формат значень: "provider/model-id" (pi --model формат).
5
+ * Налаштовується один раз у середовищі; кожен скіл посилається на потрібний тир.
6
+ *
7
+ * Приклад ~/.bashrc або .env:
8
+ * N_LOCAL_MIN_MODEL=ollama/gemma3:4b
9
+ * N_CLOUD_MIN_MODEL=openai/gpt-5.4-mini
10
+ * N_CLOUD_AVG_MODEL=openai/gpt-5.4
11
+ * N_CLOUD_MAX_MODEL=openai/gpt-5.5
12
+ *
13
+ * Значення '' означає "pi дефолтний провайдер" (залежить від ~/.pi конфігу).
14
+ *
15
+ * ## Каскад local → cloud (контракт)
16
+ *
17
+ * Використовуйте resolveModel(tier) замість прямих констант — система прозоро
18
+ * відпрацює навіть без локальних моделей:
19
+ *
20
+ * resolveModel('min') → LOCAL_MIN → LOCAL_AVG → LOCAL_MAX → CLOUD_MIN
21
+ * resolveModel('avg') → LOCAL_AVG → LOCAL_MAX → CLOUD_AVG
22
+ * resolveModel('max') → LOCAL_MAX → CLOUD_MAX
23
+ *
24
+ * Якщо жоден тир не задано — повертає '' (pi-дефолт провайдера).
25
+ * Прямі константи (LOCAL_MIN тощо) залишені для випадків, де потрібен
26
+ * явний контроль (напр., ollama HTTP, explicit retry до хмари).
27
+ */
28
+
29
+ import { env } from 'node:process'
30
+
31
+ // ── Локальні (offline, без API-ключа) ────────────────────────────────────────
32
+
33
+ /** Швидкий локальний inference. Напр.: ollama/gemma3:4b */
34
+ export const LOCAL_MIN = env.N_LOCAL_MIN_MODEL ?? ''
35
+
36
+ /** Середній локальний. Напр.: ollama/gemma4:26b-moe */
37
+ export const LOCAL_AVG = env.N_LOCAL_AVG_MODEL ?? ''
38
+
39
+ /** Максимальний локальний. Напр.: ollama/llama4-maverick */
40
+ export const LOCAL_MAX = env.N_LOCAL_MAX_MODEL ?? ''
41
+
42
+ // ── Хмарні (потрібен API-ключ у pi) ─────────────────────────────────────────
43
+
44
+ /** Мінімальний хмарний. Напр.: openai/gpt-5.4-mini, google/gemini-2.5-flash, anthropic/claude-haiku-4-5 */
45
+ export const CLOUD_MIN = env.N_CLOUD_MIN_MODEL ?? ''
46
+
47
+ /** Середній хмарний. Напр.: openai/gpt-5.4, google/gemini-2.5-pro, anthropic/claude-sonnet-4-6 */
48
+ export const CLOUD_AVG = env.N_CLOUD_AVG_MODEL ?? ''
49
+
50
+ /** Максимальний хмарний. Напр.: openai/gpt-5.5, anthropic/claude-opus-4-8 */
51
+ export const CLOUD_MAX = env.N_CLOUD_MAX_MODEL ?? ''
52
+
53
+ // ── Каскадне розв'язання ─────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Повертає перший непорожній model-id для запитаного тиру,
57
+ * каскадно перевіряючи локальні тири, а тоді хмарний еквівалент.
58
+ *
59
+ * @param {'min'|'avg'|'max'} tier
60
+ * @returns {string} provider/model-id або '' для pi-дефолту
61
+ * @throws {TypeError} якщо tier невідомий
62
+ */
63
+ export function resolveModel(tier) {
64
+ if (tier === 'min') return LOCAL_MIN || LOCAL_AVG || LOCAL_MAX || CLOUD_MIN
65
+ if (tier === 'avg') return LOCAL_AVG || LOCAL_MAX || CLOUD_AVG
66
+ if (tier === 'max') return LOCAL_MAX || CLOUD_MAX
67
+ throw new TypeError(`resolveModel: unknown tier "${tier}". Use 'min', 'avg', or 'max'.`)
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.29.0",
3
+ "version": "4.1.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,6 +29,7 @@
29
29
  "bin",
30
30
  "github-actions",
31
31
  "schemas",
32
+ "lib",
32
33
  "scripts",
33
34
  "skills",
34
35
  ".claude-template",
@@ -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
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Handlers сигнальних команд `flow done/audit/failed/spawn` (думка.MD).
3
+ *
4
+ * Агент ніколи не знає свій абсолютний path — команди обчислюють path вузла з
5
+ * env var `NCURSOR_NODE_PATH` (встановлюється wrapper-скриптом) або з файлу
6
+ * `.n-cursor/current-node` у корені worktree. Якщо нічого — error.
7
+ *
8
+ * done → делегує `n-cursor graph done <path>`
9
+ * audit → створює `pending-audit_NNN.md` → делегує `n-cursor graph audit <path>`
10
+ * failed → делегує `n-cursor graph failed <path>`
11
+ * spawn → делегує `n-cursor graph spawn <path>`
12
+ *
13
+ * Всі IO ін'єктуються для тестування без реальних процесів і диска.
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { cwd as processCwd } from 'node:process'
19
+
20
+ /**
21
+ * Резолвить шлях вузла з env або fallback-файлу.
22
+ * @param {{
23
+ * env?: Record<string, string | undefined>,
24
+ * cwd?: string,
25
+ * readFile?: (p: string, enc: string) => string,
26
+ * exists?: (p: string) => boolean
27
+ * }} deps ін'єкції
28
+ * @returns {{ nodePath: string | null, error: string | null }} результат
29
+ */
30
+ export function resolveNodePath(deps = {}) {
31
+ const env = deps.env ?? process.env
32
+ const cwd = deps.cwd ?? processCwd()
33
+ const exists = deps.exists ?? existsSync
34
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
35
+
36
+ // 1. Env var
37
+ const fromEnv = env['NCURSOR_NODE_PATH']
38
+ if (fromEnv && fromEnv.trim().length > 0) {
39
+ return { nodePath: fromEnv.trim(), error: null }
40
+ }
41
+
42
+ // 2. Fallback-файл .n-cursor/current-node у CWD (корінь worktree)
43
+ const fallbackPath = join(cwd, '.n-cursor', 'current-node')
44
+ if (exists(fallbackPath)) {
45
+ try {
46
+ const content = readFile(fallbackPath, 'utf8').trim()
47
+ if (content.length > 0) {
48
+ return { nodePath: content, error: null }
49
+ }
50
+ } catch {
51
+ // якщо не читається — fallthrough до error
52
+ }
53
+ }
54
+
55
+ return { nodePath: null, error: 'NCURSOR_NODE_PATH not set and .n-cursor/current-node not found' }
56
+ }
57
+
58
+ /**
59
+ * Знаходить поточний найбільший номер `outputs_NNN.md`.
60
+ * @param {string} dir директорія вузла
61
+ * @param {(dir: string) => string[]} readdir ін'єктована readdir
62
+ * @returns {string | null} рядок типу `001` або null якщо не знайдено
63
+ */
64
+ function findCurrentOutputsNum(dir, readdir) {
65
+ const files = readdir(dir)
66
+ let max = -1
67
+ for (const f of files) {
68
+ const m = f.match(/^outputs_(\d+)\.md$/)
69
+ if (m) {
70
+ const n = parseInt(m[1], 10)
71
+ if (n > max) max = n
72
+ }
73
+ }
74
+ return max >= 0 ? String(max).padStart(3, '0') : null
75
+ }
76
+
77
+ /**
78
+ * Виконує n-cursor graph <sub> <nodePath>.
79
+ * @param {string} sub підкоманда graph
80
+ * @param {string} nodePath шлях вузла
81
+ * @param {{
82
+ * run: (cmd: string, args: string[]) => { status: number, stdout: string, stderr: string }
83
+ * }} deps
84
+ * @returns {number} exit code
85
+ */
86
+ function delegateToGraph(sub, nodePath, deps) {
87
+ const result = deps.run('npx', ['@nitra/cursor', 'graph', sub, nodePath])
88
+ return result.status ?? 1
89
+ }
90
+
91
+ /**
92
+ * Реальний sync-runner процесу.
93
+ * @param {string} cmd
94
+ * @param {string[]} args
95
+ * @returns {{ status: number, stdout: string, stderr: string }}
96
+ */
97
+ function realRun(cmd, args) {
98
+ const r = spawnSync(cmd, args, { encoding: 'utf8' })
99
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
100
+ }
101
+
102
+ /**
103
+ * Базовий handler для сигнальних команд без аудиту.
104
+ * @param {string} sub підкоманда graph
105
+ * @param {{
106
+ * cwd?: string,
107
+ * env?: Record<string, string | undefined>,
108
+ * log?: (m: string) => void,
109
+ * run?: (cmd: string, args: string[]) => { status: number, stdout: string, stderr: string },
110
+ * readFile?: (p: string, enc: string) => string,
111
+ * exists?: (p: string) => boolean
112
+ * }} deps ін'єкції
113
+ * @returns {Promise<number>} exit code
114
+ */
115
+ async function signalHandler(sub, deps = {}) {
116
+ const cwd = deps.cwd ?? processCwd()
117
+ const log = deps.log ?? console.error
118
+ const run = deps.run ?? realRun
119
+
120
+ const { nodePath, error } = resolveNodePath({ env: deps.env, cwd, readFile: deps.readFile, exists: deps.exists })
121
+ if (!nodePath) {
122
+ log(`flow ${sub}: ${error}`)
123
+ return 1
124
+ }
125
+
126
+ log(`flow ${sub}: node path = ${nodePath}`)
127
+ const code = delegateToGraph(sub, nodePath, { run })
128
+ if (code !== 0) {
129
+ log(`flow ${sub}: graph ${sub} завершився з кодом ${code}`)
130
+ }
131
+ return code
132
+ }
133
+
134
+ /**
135
+ * `flow done` — сигналізує успіх → `graph done <path>`.
136
+ * @param {string[]} _rest аргументи (не використовуються)
137
+ * @param {object} [deps] ін'єкції
138
+ * @returns {Promise<number>} exit code
139
+ */
140
+ export async function done(_rest, deps = {}) {
141
+ return signalHandler('done', deps)
142
+ }
143
+
144
+ /**
145
+ * `flow failed` — сигналізує провал → `graph failed <path>`.
146
+ * @param {string[]} _rest аргументи (не використовуються)
147
+ * @param {object} [deps] ін'єкції
148
+ * @returns {Promise<number>} exit code
149
+ */
150
+ export async function failed(_rest, deps = {}) {
151
+ return signalHandler('failed', deps)
152
+ }
153
+
154
+ /**
155
+ * `flow spawn` — сигналізує розклад → `graph spawn <path>`.
156
+ * @param {string[]} _rest аргументи (не використовуються)
157
+ * @param {object} [deps] ін'єкції
158
+ * @returns {Promise<number>} exit code
159
+ */
160
+ export async function spawn(_rest, deps = {}) {
161
+ return signalHandler('spawn', deps)
162
+ }
163
+
164
+ /**
165
+ * `flow audit` — створює `pending-audit_NNN.md` → `graph audit <path>`.
166
+ *
167
+ * NNN у `pending-audit_NNN.md` = NNN відповідного `outputs_NNN.md`.
168
+ * Якщо outputs відсутні — error.
169
+ *
170
+ * @param {string[]} _rest аргументи (не використовуються)
171
+ * @param {{
172
+ * cwd?: string,
173
+ * env?: Record<string, string | undefined>,
174
+ * log?: (m: string) => void,
175
+ * run?: (cmd: string, args: string[]) => { status: number, stdout: string, stderr: string },
176
+ * readFile?: (p: string, enc: string) => string,
177
+ * writeFile?: (p: string, content: string, enc: string) => void,
178
+ * readdir?: (dir: string) => string[],
179
+ * exists?: (p: string) => boolean,
180
+ * now?: () => string
181
+ * }} [deps] ін'єкції
182
+ * @returns {Promise<number>} exit code
183
+ */
184
+ export async function audit(_rest, deps = {}) {
185
+ const cwd = deps.cwd ?? processCwd()
186
+ const log = deps.log ?? console.error
187
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
188
+ const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
189
+ const exists = deps.exists ?? existsSync
190
+ const nowFn = deps.now ?? (() => new Date().toISOString())
191
+ const run = deps.run ?? realRun
192
+
193
+ const { nodePath, error } = resolveNodePath({ env: deps.env, cwd, readFile: deps.readFile, exists })
194
+ if (!nodePath) {
195
+ log(`flow audit: ${error}`)
196
+ return 1
197
+ }
198
+
199
+ // Знаходимо поточний outputs NNN
200
+ const outputsNum = findCurrentOutputsNum(cwd, readdir)
201
+ if (!outputsNum) {
202
+ log('flow audit: outputs_NNN.md не знайдено — спершу напиши outputs')
203
+ return 1
204
+ }
205
+
206
+ const pendingPath = join(cwd, `pending-audit_${outputsNum}.md`)
207
+ if (exists(pendingPath)) {
208
+ log(`flow audit: ${pendingPath} вже існує — audit вже запитано для outputs_${outputsNum}.md`)
209
+ return 1
210
+ }
211
+
212
+ const content = [
213
+ '---',
214
+ `created_at: ${nowFn()}`,
215
+ `outputs_ref: outputs_${outputsNum}.md`,
216
+ `actor: agent`,
217
+ '---',
218
+ ''
219
+ ].join('\n')
220
+
221
+ try {
222
+ writeFile(pendingPath, content, 'utf8')
223
+ } catch (err) {
224
+ log(`flow audit: не вдалося записати ${pendingPath} — ${err instanceof Error ? err.message : String(err)}`)
225
+ return 1
226
+ }
227
+
228
+ log(`flow audit: ${pendingPath} створено`)
229
+ log(`flow audit: node path = ${nodePath}`)
230
+ const code = delegateToGraph('audit', nodePath, { run })
231
+ if (code !== 0) {
232
+ log(`flow audit: graph audit завершився з кодом ${code}`)
233
+ }
234
+ return code
235
+ }