@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 +13 -0
- package/package.json +1 -3
- package/scripts/coverage-classify/index.mjs +60 -72
- package/scripts/coverage-fix.mjs +26 -23
- 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/dispatcher/lib/subagent-runner.mjs +33 -102
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +54 -178
- package/skills/fix/js/llm-worker.mjs +4 -4
- 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.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
|
+
"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
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @param {
|
|
34
|
-
* @
|
|
35
|
-
* @
|
|
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
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
+
// Tier 1: resolveModel('min') — каскад local→cloud якщо локалі нема
|
|
48
55
|
try {
|
|
49
|
-
|
|
56
|
+
const text = callPiFn(prompt, resolveModel('min'))
|
|
57
|
+
return parseVerdict(text)
|
|
50
58
|
} catch {
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 !==
|
|
83
|
+
if (cache.model !== cacheModel) {
|
|
59
84
|
cache.entries = {}
|
|
60
|
-
cache.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 =
|
|
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
|
-
}
|
package/scripts/coverage-fix.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n-cursor coverage --fix`: запускає
|
|
2
|
+
* `n-cursor coverage --fix`: запускає pi-агента для написання тестів
|
|
3
3
|
* по вцілілих мутантах Stryker. Агент отримує список мутантів з контекстом
|
|
4
4
|
* (file, line, оригінальний код, вцілілий варіант, тип мутації) і самостійно
|
|
5
5
|
* знаходить або створює відповідні test-файли.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
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
|
-
* Запускає
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
const callPiFn = opts.callPi ?? callPi
|
|
40
|
+
callPiFn(prompt, MODEL, { cwd: projectRoot })
|
|
41
|
+
}
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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` (
|
|
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
|
+
}
|