@nitra/cursor 3.25.1 → 3.27.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * LLM-worker для n-fix оркестратора — C1 pattern:
3
+ * script збирає контекст (rule .mdc + файли з violation) →
4
+ * pi повертає JSON зі змінами →
5
+ * script застосовує.
6
+ *
7
+ * Всі LLM-виклики через `pi` (користувач налаштовує ключі самостійно).
8
+ * Tool-use не використовується — LLM отримує повний контекст у промпті.
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { spawnSync } from 'node:child_process'
14
+
15
+ export const MODEL_HAIKU = 'claude-haiku-4-5-20251001'
16
+ export const MODEL_SONNET = 'claude-sonnet-4-6'
17
+
18
+ /**
19
+ * Витягує відносні шляхи файлів із violation output.
20
+ * Шукає патерни типу `path/to/file.ext` або `[ws] path/to/file.ext:123`.
21
+ *
22
+ * @param {string} output violation output з fix check
23
+ * @returns {string[]} унікальні відносні шляхи
24
+ */
25
+ function extractFilePaths(output) {
26
+ const seen = new Set()
27
+ const results = []
28
+ // Матчить шляхи: слово/крапка/тире, з розширенням, необов'язковий :рядок на кінці
29
+ const re = /(?:^|\s|\[[\w/]+\]\s)([\w./][\w./\-]*\.(?:json|js|mjs|ts|vue|yml|yaml|toml|mdc|md|sh|py))(?::\d+)?/gm
30
+ for (const m of output.matchAll(re)) {
31
+ const p = m[1]
32
+ if (!seen.has(p)) { seen.add(p); results.push(p) }
33
+ }
34
+ return results
35
+ }
36
+
37
+ /**
38
+ * Будує prompt для pi: правило + порушення + поточний вміст файлів.
39
+ *
40
+ * @param {string} ruleId
41
+ * @param {string} ruleMdc вміст .mdc-файлу правила
42
+ * @param {string} output violation output
43
+ * @param {Array<{path:string, content:string}>} files
44
+ * @returns {string}
45
+ */
46
+ function buildPrompt(ruleId, ruleMdc, output, files) {
47
+ const filesBlock = files.length === 0
48
+ ? '(no files identified)'
49
+ : files.map(f => `<file path="${f.path}">\n${f.content}\n</file>`).join('\n\n')
50
+
51
+ return [
52
+ `You fix project structure violations. Return ONLY valid JSON — no explanation, no markdown.`,
53
+ ``,
54
+ `Rule (n-${ruleId}.mdc):`,
55
+ `---`,
56
+ ruleMdc,
57
+ `---`,
58
+ ``,
59
+ `Violation output:`,
60
+ output,
61
+ ``,
62
+ `Current file contents:`,
63
+ filesBlock,
64
+ ``,
65
+ `Return JSON with this exact shape:`,
66
+ `{"changes":[{"path":"relative/path/to/file","content":"full corrected file content"}]}`,
67
+ ``,
68
+ `Rules:`,
69
+ `- "path" is relative to the project root`,
70
+ `- "content" is the complete new file content (not a diff)`,
71
+ `- Only include files that actually need to change`,
72
+ `- If nothing can be fixed automatically, return {"changes":[],"error":"reason"}`,
73
+ ].join('\n')
74
+ }
75
+
76
+ /**
77
+ * Запускає pi і повертає stdout як рядок.
78
+ *
79
+ * @param {string} prompt
80
+ * @param {string} model
81
+ * @returns {{ text: string, error?: string }}
82
+ */
83
+ function callPi(prompt, model) {
84
+ const r = spawnSync(
85
+ 'pi',
86
+ ['-p', prompt, '--model', model, '--no-session', '--mode', 'text', '--no-tools'],
87
+ { encoding: 'utf8', timeout: 120_000 }
88
+ )
89
+ if (r.error) return { text: '', error: r.error.message }
90
+ if (r.status !== 0) {
91
+ const stderr = r.stderr?.slice(0, 300) ?? ''
92
+ return { text: '', error: `pi exit ${r.status}: ${stderr}` }
93
+ }
94
+ return { text: r.stdout?.trim() ?? '' }
95
+ }
96
+
97
+ /**
98
+ * Парсить JSON-відповідь від pi.
99
+ * pi може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
100
+ *
101
+ * @param {string} text
102
+ * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null}
103
+ */
104
+ function parseResponse(text) {
105
+ // Спроба 1: прямий JSON
106
+ try { return JSON.parse(text) } catch { /* fallthrough */ }
107
+
108
+ // Спроба 2: витягти з ```json ... ```
109
+ const m = text.match(/```(?:json)?\s*([\s\S]*?)```/)
110
+ if (m) {
111
+ try { return JSON.parse(m[1].trim()) } catch { /* fallthrough */ }
112
+ }
113
+
114
+ // Спроба 3: перший { ... } блок
115
+ const start = text.indexOf('{')
116
+ const end = text.lastIndexOf('}')
117
+ if (start !== -1 && end > start) {
118
+ try { return JSON.parse(text.slice(start, end + 1)) } catch { /* fallthrough */ }
119
+ }
120
+
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * LLM-worker: виправляє одне rule-порушення через pi (C1 pattern).
126
+ *
127
+ * @param {string} ruleId
128
+ * @param {string} violationOutput output з fix check для цього rule
129
+ * @param {string} projectRoot абсолютний шлях до кореня проєкту
130
+ * @param {{ model?: string }} opts
131
+ * @returns {Promise<{ ok: boolean, error?: string }>}
132
+ */
133
+ export async function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
134
+ const model = opts.model ?? MODEL_HAIKU
135
+
136
+ // 1. Читаємо rule .mdc
137
+ const mdcPath = join(projectRoot, '.cursor', 'rules', `n-${ruleId}.mdc`)
138
+ const ruleMdc = existsSync(mdcPath) ? readFileSync(mdcPath, 'utf8') : '(rule file not found)'
139
+
140
+ // 2. Витягуємо файли з violation output і читаємо їх
141
+ const filePaths = extractFilePaths(violationOutput)
142
+ const files = filePaths
143
+ .map(p => {
144
+ const abs = join(projectRoot, p)
145
+ if (!existsSync(abs)) return null
146
+ try {
147
+ return { path: p, content: readFileSync(abs, 'utf8') }
148
+ } catch {
149
+ return null
150
+ }
151
+ })
152
+ .filter(Boolean)
153
+
154
+ // 3. Будуємо prompt і викликаємо pi
155
+ const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
156
+ const { text, error: piError } = callPi(prompt, model)
157
+
158
+ if (piError) return { ok: false, error: piError }
159
+ if (!text) return { ok: false, error: 'pi returned empty response' }
160
+
161
+ // 4. Парсимо відповідь
162
+ const parsed = parseResponse(text)
163
+ if (!parsed) return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}` }
164
+ if (parsed.error) return { ok: false, error: parsed.error }
165
+
166
+ const changes = parsed.changes ?? []
167
+ if (changes.length === 0) return { ok: false, error: 'pi returned no changes' }
168
+
169
+ // 5. Застосовуємо зміни
170
+ for (const change of changes) {
171
+ if (!change.path || typeof change.content !== 'string') continue
172
+ const abs = join(projectRoot, change.path)
173
+ try {
174
+ writeFileSync(abs, change.content, 'utf8')
175
+ } catch (e) {
176
+ return { ok: false, error: `write ${change.path}: ${e.message}` }
177
+ }
178
+ }
179
+
180
+ return { ok: true }
181
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Автономний оркестратор n-fix: convergence-loop без участі агента-LLM.
3
+ *
4
+ * Тири:
5
+ * T0 — детерміністична перевірка (runFixCheck, 0 LLM)
6
+ * T0-auto — regex-парсинг violation → програмний фікс (0 LLM)
7
+ * T1 — LLM через pi (haiku → sonnet ескалація)
8
+ * check-gate — re-run T0 після кожного тіру; loop до maxIter
9
+ *
10
+ * meta.json: { "orchestrator": true } — CLI маршрутизує `fix` сюди.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { join } from 'node:path'
16
+
17
+ const HERE = fileURLToPath(new URL('.', import.meta.url))
18
+ const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
19
+
20
+ const DEFAULT_MAX_ITER = 3
21
+ const ESCALATE_AFTER = 2
22
+
23
+ /**
24
+ * @param {string[]} args CLI аргументи після 'fix'
25
+ * @param {string} cwd корінь проєкту
26
+ * @returns {Promise<number>} 0 = all clean, 1 = unresolved
27
+ */
28
+ export async function runOrchestratorCli(args, cwd) {
29
+ const { runLlmWorker, MODEL_HAIKU, MODEL_SONNET } = await import('./llm-worker.mjs')
30
+
31
+ const maxIterIdx = args.indexOf('--max-iter')
32
+ const maxIter =
33
+ maxIterIdx !== -1
34
+ ? Number(args[maxIterIdx + 1] ?? DEFAULT_MAX_ITER) || DEFAULT_MAX_ITER
35
+ : DEFAULT_MAX_ITER
36
+ const skipIdxs = new Set(maxIterIdx !== -1 ? [maxIterIdx, maxIterIdx + 1] : [])
37
+ const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skipIdxs.has(i))
38
+
39
+ console.log(`🔄 n-cursor fix`)
40
+ if (ruleFilter.length) console.log(` rules: ${ruleFilter.join(', ')}`)
41
+
42
+ /** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */
43
+ const failCount = new Map()
44
+
45
+ for (let iter = 1; iter <= maxIter; iter++) {
46
+ console.log(`\n── Ітерація ${iter}/${maxIter} ──`)
47
+
48
+ // ── T0: check ──
49
+ const state = runFixCheck(cwd, ruleFilter)
50
+ if (!state) {
51
+ console.error(`❌ fix: перевірка повернула помилку`)
52
+ return 1
53
+ }
54
+
55
+ const failed = state.rules.filter(r => !r.ok)
56
+ if (failed.length === 0) {
57
+ console.log(`✅ fix: 0/${state.total} порушень`)
58
+ return 0
59
+ }
60
+
61
+ console.log(` ❌ ${failed.length}: ${failed.map(r => r.ruleId).join(', ')}`)
62
+
63
+ // ── T0-auto: детермінований фікс без LLM ──
64
+ spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'inherit' })
65
+
66
+ const stateAfterT0 = runFixCheck(cwd, ruleFilter)
67
+ const failedAfterT0 = stateAfterT0?.rules.filter(r => !r.ok) ?? failed
68
+ if (failedAfterT0.length === 0) {
69
+ console.log(`✅ fix: всі правила закриті T0-auto`)
70
+ return 0
71
+ }
72
+
73
+ // ── T1: LLM через pi ──
74
+ for (const rule of failedAfterT0) {
75
+ const prevFails = failCount.get(rule.ruleId) ?? 0
76
+ const model = prevFails >= ESCALATE_AFTER ? MODEL_SONNET : MODEL_HAIKU
77
+ const tier = prevFails >= ESCALATE_AFTER ? 'sonnet' : 'haiku'
78
+
79
+ console.log(`\n⚡ [${tier}] → ${rule.ruleId}`)
80
+
81
+ const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
82
+
83
+ if (result.ok) {
84
+ console.log(` ✅ закрито`)
85
+ failCount.delete(rule.ruleId)
86
+ } else {
87
+ failCount.set(rule.ruleId, prevFails + 1)
88
+ console.log(` ❌ (${prevFails + 1}× fail): ${result.error ?? ''}`)
89
+ }
90
+ }
91
+ }
92
+
93
+ // ── Фінальна перевірка ──
94
+ const final = runFixCheck(cwd, ruleFilter)
95
+ const finalFailed = final?.rules.filter(r => !r.ok) ?? []
96
+
97
+ if (finalFailed.length === 0) {
98
+ console.log(`\n✅ fix: чисто`)
99
+ return 0
100
+ }
101
+
102
+ console.log(`\n❌ fix: ${finalFailed.length} unresolved після ${maxIter} ітерацій`)
103
+ console.log(` ${finalFailed.map(r => r.ruleId).join(', ')}`)
104
+ return 1
105
+ }
106
+
107
+ /**
108
+ * Внутрішня check-gate: запускає fix-перевірки і повертає структурований результат.
109
+ * Не є публічним CLI — викликається лише оркестратором.
110
+ *
111
+ * @param {string} cwd
112
+ * @param {string[]} ruleFilter
113
+ * @returns {{ total: number, failed: number, rules: Array<{ ruleId: string, ok: boolean, output: string }> } | null}
114
+ */
115
+ function runFixCheck(cwd, ruleFilter = []) {
116
+ const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], {
117
+ cwd,
118
+ encoding: 'utf8',
119
+ timeout: 120_000,
120
+ })
121
+ const stdout = r.stdout?.trim()
122
+ if (!stdout) return null
123
+ try {
124
+ return JSON.parse(stdout)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * T0-auto: детермінований рівень виправлень для n-fix оркестратора.
3
+ *
4
+ * Парсить `output` з `n-cursor fix --json` → застосовує програмний фікс без LLM.
5
+ * Умова застосування: violation-message містить конкретне цільове значення
6
+ * (назву файлу, рядок для вставки, ім'я залежності), яке можна видобути regex.
7
+ *
8
+ * Ієрархія: T0 (rm/create, знаний тип) → T0-auto (parse violation) → T1 (LLM).
9
+ * T0-auto запускається першим у конвергентному циклі; T1 — лише для решти.
10
+ *
11
+ * Публічний API:
12
+ * applyT0Auto(ruleId, violationOutput, cwd) → { applied: boolean, actions: string[] }
13
+ * listT0AutoRules() → string[] (ids що мають хоч один паттерн)
14
+ * runT0AutoCli(args, cwd) → Promise<number> (exit 0=clean, 1=violation)
15
+ */
16
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+ import { dirname } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ /**
23
+ * Патерни T0-auto.
24
+ * Кожен паттерн: {
25
+ * id: string — унікальна назва паттерну (для логу)
26
+ * test: (output)=>bool — чи підходить цей output до паттерну
27
+ * apply: (match, cwd)=>{ ok: bool, action: string } — застосувати фікс
28
+ * }
29
+ */
30
+ const PATTERNS = [
31
+ // ── vscode-ext-add ──────────────────────────────────────────────────────────
32
+ // Violation: «recommendations має містити "tsandall.opa"»
33
+ // Fix: додати рядок у .vscode/extensions.json#recommendations
34
+ {
35
+ id: 'vscode-ext-add',
36
+ test: out => /recommendations має містити "[^"]+"/.test(out),
37
+ apply: (out, cwd) => {
38
+ const matches = [...out.matchAll(/recommendations має містити "([^"]+)"/g)]
39
+ if (matches.length === 0) return { ok: false, action: 'no match' }
40
+
41
+ const extPath = join(cwd, '.vscode/extensions.json')
42
+ if (!existsSync(extPath)) {
43
+ return { ok: false, action: '.vscode/extensions.json не знайдено' }
44
+ }
45
+
46
+ let parsed
47
+ try {
48
+ parsed = JSON.parse(readFileSync(extPath, 'utf8'))
49
+ } catch {
50
+ return { ok: false, action: '.vscode/extensions.json: невалідний JSON' }
51
+ }
52
+
53
+ const recs = Array.isArray(parsed.recommendations) ? parsed.recommendations : []
54
+ const toAdd = matches.map(m => m[1]).filter(e => !recs.includes(e))
55
+ if (toAdd.length === 0) return { ok: false, action: 'вже є' }
56
+
57
+ parsed.recommendations = [...recs, ...toAdd]
58
+ writeFileSync(extPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
59
+ return { ok: true, action: `додано до extensions.json: ${toAdd.join(', ')}` }
60
+ },
61
+ },
62
+
63
+ // ── rm-forbidden-file ────────────────────────────────────────────────────────
64
+ // Violation: «Знайдено заборонений файл: package-lock.json»
65
+ // Fix: видалити файл
66
+ {
67
+ id: 'rm-forbidden-file',
68
+ test: out => /Знайдено заборонений файл: \S+/.test(out),
69
+ apply: (out, cwd) => {
70
+ const matches = [...out.matchAll(/Знайдено заборонений файл: (\S+)/g)]
71
+ if (matches.length === 0) return { ok: false, action: 'no match' }
72
+
73
+ const removed = []
74
+ for (const m of matches) {
75
+ const filePath = join(cwd, m[1])
76
+ if (existsSync(filePath)) {
77
+ rmSync(filePath, { force: true })
78
+ removed.push(m[1])
79
+ }
80
+ }
81
+ if (removed.length === 0) return { ok: false, action: 'файлів не знайдено' }
82
+ return { ok: true, action: `видалено: ${removed.join(', ')}` }
83
+ },
84
+ },
85
+ ]
86
+
87
+ /**
88
+ * Застосовує всі T0-auto паттерни до одного violation-output.
89
+ *
90
+ * @param {string} ruleId id правила (для логу)
91
+ * @param {string} violationOutput рядок з поля `output` у `fix --json`
92
+ * @param {string} cwd корінь проєкту
93
+ * @returns {{ applied: boolean, actions: string[] }}
94
+ */
95
+ export function applyT0Auto(ruleId, violationOutput, cwd) {
96
+ const actions = []
97
+ let applied = false
98
+
99
+ for (const p of PATTERNS) {
100
+ if (!p.test(violationOutput)) continue
101
+ const result = p.apply(violationOutput, cwd)
102
+ actions.push(`[${p.id}] ${result.action}`)
103
+ if (result.ok) {
104
+ applied = true
105
+ }
106
+ }
107
+
108
+ return { applied, actions }
109
+ }
110
+
111
+ /**
112
+ * Повертає список id правил, для яких є хоча б один T0-auto паттерн
113
+ * (визначається по violation-output із `fix --json`).
114
+ *
115
+ * @param {{ ruleId: string, output: string }[]} failedRules
116
+ * @returns {string[]}
117
+ */
118
+ export function filterT0AutoRules(failedRules) {
119
+ return failedRules
120
+ .filter(r => PATTERNS.some(p => p.test(r.output)))
121
+ .map(r => r.ruleId)
122
+ }
123
+
124
+ // ─── CLI entry-point ──────────────────────────────────────────────────────────
125
+
126
+ const HERE = dirname(fileURLToPath(import.meta.url))
127
+ /** Абсолютний шлях до npm/bin/n-cursor.js відносно цього файлу */
128
+ const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
129
+
130
+ /**
131
+ * CLI підкоманда `n-cursor fix-t0 [rule...]`.
132
+ * Запускає `fix --json`, застосовує T0-auto для кожного violation,
133
+ * повторно перевіряє check-gate, виводить підсумок.
134
+ *
135
+ * @param {string[]} args аргументи підкоманди (опційний список rule-ids)
136
+ * @param {string} cwd корінь проєкту
137
+ * @returns {Promise<number>} 0 — T0-auto закрив всі або немає порушень; 1 — лишились
138
+ */
139
+ export async function runT0AutoCli(args, cwd) {
140
+ const ruleFilter = args.filter(a => !a.startsWith('--'))
141
+ const verbose = args.includes('--verbose') || args.includes('-v')
142
+
143
+ // 1. Запустити fix --json
144
+ const fixResult = spawnSync(
145
+ 'bun',
146
+ [N_CURSOR_BIN, '_fix-check', ...ruleFilter],
147
+ { cwd, encoding: 'utf8', timeout: 120_000 }
148
+ )
149
+ const raw = fixResult.stdout?.trim()
150
+ if (!raw) {
151
+ console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`)
152
+ console.error(fixResult.stderr?.slice(0, 300) ?? '')
153
+ return 1
154
+ }
155
+
156
+ let fixJson
157
+ try {
158
+ fixJson = JSON.parse(raw)
159
+ } catch {
160
+ console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`)
161
+ return 1
162
+ }
163
+
164
+ const failed = fixJson.rules.filter(r => !r.ok)
165
+ if (failed.length === 0) {
166
+ console.log(`✅ fix-t0: всі правила чисті — T0 не потрібен`)
167
+ return 0
168
+ }
169
+
170
+ // 2. Застосувати T0-auto
171
+ const applied = []
172
+ const skipped = []
173
+ for (const r of failed) {
174
+ const result = applyT0Auto(r.ruleId, r.output, cwd)
175
+ if (result.applied) {
176
+ applied.push({ ruleId: r.ruleId, actions: result.actions })
177
+ } else {
178
+ skipped.push(r.ruleId)
179
+ }
180
+ }
181
+
182
+ if (applied.length === 0) {
183
+ console.log(`⏭️ fix-t0: T0-auto паттерн не підходить для: ${failed.map(r => r.ruleId).join(', ')}`)
184
+ return 1
185
+ }
186
+
187
+ // 3. Вивести що зробили
188
+ for (const { ruleId, actions } of applied) {
189
+ console.log(`⚙️ ${ruleId}:`)
190
+ for (const a of actions) console.log(` ${a}`)
191
+ }
192
+
193
+ // 4. Check-gate: перевірити лише ті правила, що ми чіпали
194
+ const recheck = spawnSync(
195
+ 'bun',
196
+ [N_CURSOR_BIN, '_fix-check', ...applied.map(a => a.ruleId)],
197
+ { cwd, encoding: 'utf8', timeout: 120_000 }
198
+ )
199
+ const recheckRaw = recheck.stdout?.trim()
200
+ if (!recheckRaw) {
201
+ console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`)
202
+ return 1
203
+ }
204
+
205
+ const recheckJson = JSON.parse(recheckRaw)
206
+ const stillFailed = recheckJson.rules.filter(r => !r.ok)
207
+
208
+ if (verbose) {
209
+ for (const r of recheckJson.rules) {
210
+ console.log(` ${r.ok ? '✅' : '❌'} ${r.ruleId}`)
211
+ }
212
+ }
213
+
214
+ if (stillFailed.length > 0) {
215
+ console.log(`❌ fix-t0 check-gate: ${stillFailed.map(r => r.ruleId).join(', ')} — лишились`)
216
+ if (skipped.length > 0) {
217
+ console.log(`⏭️ без T0 паттерну: ${skipped.join(', ')} → потрібен LLM`)
218
+ }
219
+ return 1
220
+ }
221
+
222
+ const totalFixed = applied.length
223
+ const total = failed.length
224
+ console.log(
225
+ `✅ fix-t0: ${totalFixed}/${total} правил закрито T0-auto` +
226
+ (skipped.length > 0 ? `; ${skipped.join(', ')} → T1 (LLM)` : '')
227
+ )
228
+ return 0
229
+ }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": true }
1
+ { "auto": "завжди", "worktree": true, "orchestrator": true }