@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 +13 -0
- package/lib/models.mjs +68 -0
- package/package.json +2 -1
- package/scripts/dispatcher/index.mjs +20 -61
- package/scripts/dispatcher/lib/flow-plan.mjs +153 -0
- package/scripts/dispatcher/lib/flow-signals.mjs +235 -0
- package/scripts/dispatcher/lib/flow-verify.mjs +127 -0
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +42 -125
- package/scripts/dispatcher/lib/active.mjs +0 -222
- package/scripts/dispatcher/lib/artifact.mjs +0 -79
- package/scripts/dispatcher/lib/budget.mjs +0 -36
- package/scripts/dispatcher/lib/capability.mjs +0 -59
- package/scripts/dispatcher/lib/commands.mjs +0 -296
- package/scripts/dispatcher/lib/flow-lock.mjs +0 -39
- package/scripts/dispatcher/lib/gate.mjs +0 -91
- package/scripts/dispatcher/lib/level.mjs +0 -135
- package/scripts/dispatcher/lib/plan.mjs +0 -88
- package/scripts/dispatcher/lib/planner.mjs +0 -73
- package/scripts/dispatcher/lib/review.mjs +0 -176
- package/scripts/dispatcher/lib/reviewer.mjs +0 -44
- package/scripts/dispatcher/lib/snapshot.mjs +0 -58
- package/scripts/dispatcher/lib/spec.mjs +0 -97
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
|
+
"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` (
|
|
2
|
+
* CLI-диспетчер `n-cursor flow` (думка.MD — протокол всередині вузла графу).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
20
|
-
' npx @nitra/cursor flow
|
|
21
|
-
' npx @nitra/cursor flow
|
|
22
|
-
' npx @nitra/cursor flow
|
|
23
|
-
' npx @nitra/cursor flow
|
|
24
|
-
' npx @nitra/cursor flow
|
|
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 = {
|
|
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
|
|
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, ...
|
|
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
|
-
|
|
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
|
+
}
|