@nitra/cursor 3.26.0 → 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.
- package/CHANGELOG.md +7 -0
- package/bin/n-cursor.js +29 -9
- package/package.json +1 -1
- package/rules/ga/docs/fix.md +16 -149
- package/rules/ga/js/docs/lint.md +12 -93
- package/rules/ga/js/docs/workflows.md +28 -213
- package/rules/ga/lint/docs/lint.md +24 -206
- package/skills/docgen/js/docgen-gen.mjs +212 -11
- package/skills/docgen/js/docgen-ignore.mjs +3 -1
- package/skills/docgen/js/docgen-prompts.mjs +7 -1
- package/skills/fix/SKILL.md +5 -31
- package/skills/fix/js/llm-worker.mjs +181 -0
- package/skills/fix/js/orchestrator.mjs +128 -0
- package/skills/fix/js/t0.mjs +229 -0
- package/skills/fix/meta.json +1 -1
|
@@ -4,14 +4,25 @@
|
|
|
4
4
|
* Інверсія керування: веде цей JS, а локальна модель — лише сервіс перефразування.
|
|
5
5
|
* Stage 0 extractFacts — факти з коду (0 токенів)
|
|
6
6
|
* Stage 1 sectionInstructions — точкові промпти на кожну секцію (спільний KV-cached префікс)
|
|
7
|
+
* Stage 2 stripSignatures — детермінований зріз сигнатур (0 токенів)
|
|
8
|
+
* Stage 2.5 scoreDoc — детермінований скоринг проти фактів (0 токенів)
|
|
7
9
|
* Stage 3 assemble — фіксовані заголовки/порядок + зрізання fence
|
|
8
|
-
*
|
|
10
|
+
* Tier 2 claudeOneShot — хмарний fallback якщо score < QUALITY_THRESHOLD
|
|
11
|
+
*
|
|
12
|
+
* Hybrid routing (sym-threshold):
|
|
13
|
+
* sym < BORDERLINE_SYM_LOW → Tier 1 local (без хмарного рефері)
|
|
14
|
+
* sym ∈ [BORDERLINE_SYM_LOW, sym<4) → Tier 1 + cloudScoreDoc (Haiku) → при низькому балі → Tier 2
|
|
15
|
+
* sym >= DEFAULT_SYM_THRESHOLD → одразу Tier 2 (pre-routing, без local)
|
|
9
16
|
*/
|
|
10
17
|
import { readFileSync } from 'node:fs'
|
|
11
18
|
import { basename } from 'node:path'
|
|
12
19
|
import { request } from 'node:http'
|
|
20
|
+
import { env } from 'node:process'
|
|
21
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
13
22
|
import { extractFacts } from './docgen-extract.mjs'
|
|
14
|
-
import { sectionMessages, oneShotMessages } from './docgen-prompts.mjs'
|
|
23
|
+
import { sectionMessages, oneShotMessages, STYLE, oneShotPromptText } from './docgen-prompts.mjs'
|
|
24
|
+
|
|
25
|
+
const QUALITY_THRESHOLD = 70
|
|
15
26
|
|
|
16
27
|
/** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
|
|
17
28
|
async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
@@ -70,6 +81,116 @@ function stripSignatures(text) {
|
|
|
70
81
|
return t
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
/** Розбиває md на секції за ## заголовками → { огляд, поведінка, api, гарантіїповедінки, … } */
|
|
85
|
+
function parseSections(md) {
|
|
86
|
+
const result = {}
|
|
87
|
+
let cur = null
|
|
88
|
+
for (const line of md.split('\n')) {
|
|
89
|
+
const m = line.match(/^##\s+(.+)/)
|
|
90
|
+
if (m) { cur = m[1].toLowerCase().replace(/[^а-яіїєґa-z0-9]/gi, ''); result[cur] = '' }
|
|
91
|
+
else if (cur) result[cur] += line + '\n'
|
|
92
|
+
}
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stage 2.5 — детермінований скоринг (0 токенів): перевіряє вихід проти фактів.
|
|
98
|
+
* @returns {{ score: number, issues: string[] }}
|
|
99
|
+
*/
|
|
100
|
+
function scoreDoc(md, facts) {
|
|
101
|
+
const s = parseSections(md)
|
|
102
|
+
let score = 100
|
|
103
|
+
const issues = []
|
|
104
|
+
|
|
105
|
+
if (!s['огляд'])
|
|
106
|
+
{ score -= 25; issues.push('no-overview') }
|
|
107
|
+
|
|
108
|
+
const behavior = s['поведінка'] ?? ''
|
|
109
|
+
if (behavior.length < 60)
|
|
110
|
+
{ score -= 20; issues.push('short-behavior') }
|
|
111
|
+
|
|
112
|
+
const guarantees = s['гарантіїповедінки'] ?? ''
|
|
113
|
+
// Будь-яка згадка "кеш" у Гарантіях коли файл не кешує — галюцинація
|
|
114
|
+
// Негація: "не кешує", "не має кешування", "без кешування", "немає кешу"
|
|
115
|
+
const cacheHit = /кеш/i.test(guarantees) && !/(?:не|без)\s+(?:\S+\s+)?кеш|немає\s+кеш/i.test(guarantees)
|
|
116
|
+
if (!facts.markers?.caches && cacheHit)
|
|
117
|
+
{ score -= 20; issues.push('cache-hallucination') }
|
|
118
|
+
|
|
119
|
+
// Перевіряємо лише бектік-обгорнуті імена (`sym`) — уникаємо substring false positives
|
|
120
|
+
const hasName = (text, sym) => text.includes('`' + sym + '`')
|
|
121
|
+
for (const sym of facts.internalSymbols ?? []) {
|
|
122
|
+
const inDoc = hasName(guarantees, sym) || hasName(s['огляд'] ?? '', sym) || hasName(s['поведінка'] ?? '', sym)
|
|
123
|
+
if (inDoc) { score -= 10; issues.push(`internal-name:${sym}`) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { score: Math.max(0, score), issues }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SCORE_RUBRIC = `Оціни якість документації для JavaScript-модуля за 4 критеріями (1-3 кожен):
|
|
130
|
+
|
|
131
|
+
- огляд: 3=описує роль модуля в системі (ЩО і НАВІЩО); 2=частково розмитий; 1=відсутній або перераховує функції
|
|
132
|
+
- поведінка: 3=бізнес-терміни, без деталей реалізації; 2=деякі impl-деталі; 1=переважно реалізація або відсутня
|
|
133
|
+
- гарантії: 3=лише реальні інваріанти підтверджені кодом, без галюцинацій; 2=частково правильні; 1=вигадані або відсутні
|
|
134
|
+
- стиль: 3=без сигнатур/internal-імен, правильна markdown-структура; 2=дрібні порушення; 1=сигнатури/internal-імена/відсутні заголовки
|
|
135
|
+
|
|
136
|
+
Відповідай ТІЛЬКИ JSON без пояснень:
|
|
137
|
+
{"огляд":N,"поведінка":N,"гарантії":N,"стиль":N,"issues":["коротко про кожен мінус 1-5 слів"]}`
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stage 2.5 cloud: Claude Haiku оцінює якість доку проти коду + фактів.
|
|
141
|
+
* Використовує найдешевшу хмарну модель — haiku — для мінімальної вартості судді.
|
|
142
|
+
* @returns {{ score: number, scores: object, issues: string[], tok: number }}
|
|
143
|
+
*/
|
|
144
|
+
async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001') {
|
|
145
|
+
const client = new Anthropic()
|
|
146
|
+
const factsTxt = [
|
|
147
|
+
facts.exports?.length ? `Публічні функції: ${facts.exports.map(e => e.name).join(', ')}` : '',
|
|
148
|
+
facts.internalSymbols?.length ? `Внутрішні (не публічні): ${facts.internalSymbols.join(', ')}` : '',
|
|
149
|
+
facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
|
|
150
|
+
facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
|
|
151
|
+
facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
|
|
152
|
+
].filter(Boolean).join('\n')
|
|
153
|
+
|
|
154
|
+
const msg = await client.messages.create({
|
|
155
|
+
model,
|
|
156
|
+
max_tokens: 256,
|
|
157
|
+
system: SCORE_RUBRIC,
|
|
158
|
+
messages: [{
|
|
159
|
+
role: 'user',
|
|
160
|
+
content: [
|
|
161
|
+
{ type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
|
|
162
|
+
{ type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
|
|
163
|
+
{ type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
|
|
164
|
+
]
|
|
165
|
+
}]
|
|
166
|
+
})
|
|
167
|
+
const tok = (msg.usage?.input_tokens ?? 0) + (msg.usage?.output_tokens ?? 0)
|
|
168
|
+
try {
|
|
169
|
+
const j = JSON.parse(msg.content[0]?.text ?? '{}')
|
|
170
|
+
const total = ((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12 * 100
|
|
171
|
+
return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
|
|
172
|
+
} catch {
|
|
173
|
+
return { score: 50, scores: {}, issues: ['parse-error'], tok }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Tier 2: хмарний fallback через Claude коли local-score < QUALITY_THRESHOLD. */
|
|
178
|
+
async function claudeOneShot(facts, src, model = 'claude-sonnet-4-6') {
|
|
179
|
+
const client = new Anthropic()
|
|
180
|
+
const prompt = oneShotPromptText(facts, src)
|
|
181
|
+
const msg = await client.messages.create({
|
|
182
|
+
model,
|
|
183
|
+
max_tokens: 1500,
|
|
184
|
+
system: STYLE,
|
|
185
|
+
messages: [{ role: 'user', content: prompt }]
|
|
186
|
+
})
|
|
187
|
+
const text = msg.content[0]?.text ?? ''
|
|
188
|
+
const genTok = msg.usage?.output_tokens ?? 0
|
|
189
|
+
let md = stripSignatures(stripSection(text))
|
|
190
|
+
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
191
|
+
return { md: md + '\n', genTok }
|
|
192
|
+
}
|
|
193
|
+
|
|
73
194
|
/** Stage 3: фіксовані заголовки у фіксованому порядку. */
|
|
74
195
|
function assemble(stem, sections) {
|
|
75
196
|
const order = [
|
|
@@ -106,28 +227,108 @@ async function generateOneShot(facts, src, model) {
|
|
|
106
227
|
return { md: md + '\n', genTok }
|
|
107
228
|
}
|
|
108
229
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
230
|
+
/** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без локального проходу). */
|
|
231
|
+
const DEFAULT_SYM_THRESHOLD = 4
|
|
232
|
+
/** Файли з sym ≥ цього значення отримують хмарного рефері (Haiku) після локального проходу. */
|
|
233
|
+
const BORDERLINE_SYM_LOW = 2
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Головний API: файл → { md, genTok, ms, score, issues, tier }.
|
|
237
|
+
*
|
|
238
|
+
* Routing (sym-threshold):
|
|
239
|
+
* sym < BORDERLINE_SYM_LOW → Tier 1 (без хмарного рефері)
|
|
240
|
+
* sym ∈ [BORDERLINE_SYM_LOW, symThreshold) → Tier 1 + cloudScoreDoc (Haiku) як рефері
|
|
241
|
+
* sym >= symThreshold → Pre-routing одразу Tier 2
|
|
242
|
+
* scoreCloud=true → примусово запускає cloudScoreDoc для всіх Tier 1
|
|
243
|
+
*
|
|
244
|
+
* @param {string} scoreModel — модель для хмарного рефері (Haiku за замовч.)
|
|
245
|
+
* @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
|
|
246
|
+
* @param {boolean} scoreCloud — якщо true, cloudScoreDoc запускається для всіх Tier 1 файлів
|
|
247
|
+
*/
|
|
248
|
+
export async function generateDoc(file, {
|
|
249
|
+
model = 'gemma3:4b',
|
|
250
|
+
mode = 'orchestrated',
|
|
251
|
+
scoreModel = 'claude-haiku-4-5-20251001',
|
|
252
|
+
cloudModel = 'claude-sonnet-4-6',
|
|
253
|
+
threshold = QUALITY_THRESHOLD,
|
|
254
|
+
scoreCloud = false,
|
|
255
|
+
symThreshold = DEFAULT_SYM_THRESHOLD
|
|
256
|
+
} = {}) {
|
|
111
257
|
const src = readFileSync(file, 'utf8')
|
|
112
258
|
const facts = extractFacts(src, file)
|
|
113
259
|
const t0 = Date.now()
|
|
114
|
-
|
|
115
|
-
|
|
260
|
+
|
|
261
|
+
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2, не витрачаємо local-час
|
|
262
|
+
const complexity = facts.internalSymbols?.length ?? 0
|
|
263
|
+
if (complexity >= symThreshold && env.ANTHROPIC_API_KEY) {
|
|
264
|
+
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
265
|
+
return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2 }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let r = facts.unsupported
|
|
269
|
+
? await generateOneShot(facts, src, model)
|
|
116
270
|
: mode === 'oneshot'
|
|
117
271
|
? await generateOneShot(facts, src, model)
|
|
118
272
|
: await generateOrchestrated(facts, src, model)
|
|
119
|
-
|
|
273
|
+
|
|
274
|
+
// Stage 2.5a: детермінований скоринг (0 токенів)
|
|
275
|
+
const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
|
|
276
|
+
|
|
277
|
+
// Stage 2.5b: cloudScoreDoc (Haiku) як рефері для borderline-файлів або при scoreCloud=true
|
|
278
|
+
const isBorderline = complexity >= BORDERLINE_SYM_LOW && complexity < symThreshold
|
|
279
|
+
if ((isBorderline || scoreCloud) && env.ANTHROPIC_API_KEY) {
|
|
280
|
+
const cs = await cloudScoreDoc(r.md, facts, src, scoreModel)
|
|
281
|
+
if (cs.score < threshold) {
|
|
282
|
+
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
283
|
+
return { ...r2, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
|
|
284
|
+
issues: cs.issues, detScore, detIssues, tier: 2 }
|
|
285
|
+
}
|
|
286
|
+
return { ...r, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
|
|
287
|
+
issues: cs.issues, detScore, detIssues, tier: 1 }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Детермінований fallback (без хмарного рефері)
|
|
291
|
+
if (detScore < threshold && env.ANTHROPIC_API_KEY) {
|
|
292
|
+
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
293
|
+
return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2 }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1 }
|
|
120
297
|
}
|
|
121
298
|
|
|
122
|
-
// CLI: node docgen-gen.mjs <file> [--oneshot] [--model <m>]
|
|
299
|
+
// CLI: node docgen-gen.mjs <file> [--oneshot] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]
|
|
123
300
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
124
301
|
if (isRunAsCli(import.meta.url)) {
|
|
125
302
|
const args = process.argv.slice(2)
|
|
126
303
|
const file = args.find(a => !a.startsWith('--'))
|
|
127
304
|
const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
|
|
305
|
+
const scoreCloud = args.includes('--score-cloud')
|
|
306
|
+
const tierOnly = args.includes('--tier-only')
|
|
128
307
|
const mi = args.indexOf('--model'); const model = mi >= 0 ? args[mi + 1] : 'gemma3:4b'
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
308
|
+
const smi = args.indexOf('--score-model'); const scoreModel = smi >= 0 ? args[smi + 1] : 'claude-haiku-4-5-20251001'
|
|
309
|
+
const si = args.indexOf('--sym-threshold'); const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
|
|
310
|
+
if (!file) {
|
|
311
|
+
console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]')
|
|
312
|
+
process.exit(1)
|
|
313
|
+
}
|
|
314
|
+
if (tierOnly) {
|
|
315
|
+
const src = readFileSync(file, 'utf8')
|
|
316
|
+
const facts = extractFacts(src, file)
|
|
317
|
+
const sym = facts.internalSymbols?.length ?? 0
|
|
318
|
+
let label, icon
|
|
319
|
+
if (sym >= symThreshold) {
|
|
320
|
+
icon = '☁️ '; label = `Tier 2 cloud (sym=${sym} ≥ ${symThreshold}, pre-routed)`
|
|
321
|
+
} else if (sym >= BORDERLINE_SYM_LOW) {
|
|
322
|
+
icon = '🔀'; label = `Tier 1+judge (sym=${sym} ∈ [${BORDERLINE_SYM_LOW},${symThreshold}), Haiku рефері)`
|
|
323
|
+
} else {
|
|
324
|
+
icon = '💻'; label = `Tier 1 local (sym=${sym} < ${BORDERLINE_SYM_LOW})`
|
|
325
|
+
}
|
|
326
|
+
process.stdout.write(`${icon} ${label} | ${file}\n`)
|
|
327
|
+
process.exit(0)
|
|
328
|
+
}
|
|
329
|
+
const r = await generateDoc(file, { model, mode, scoreCloud, scoreModel, symThreshold })
|
|
330
|
+
const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
|
|
331
|
+
const cloudTxt = r.cloudScores ? ` cloud-scores=${JSON.stringify(r.cloudScores)}` : ''
|
|
332
|
+
process.stderr.write(`[tier${r.tier} ${mode}] ${r.ms}ms / ${r.genTok} tok / score=${r.score}${issuesTxt}${cloudTxt}\n`)
|
|
132
333
|
process.stdout.write(r.md)
|
|
133
334
|
}
|
|
@@ -20,7 +20,9 @@ export const DOCGEN_IGNORE_GLOBS = Object.freeze([
|
|
|
20
20
|
'.worktrees/**',
|
|
21
21
|
'**/benchmarks/**',
|
|
22
22
|
'**/demo/**',
|
|
23
|
-
'**/docs/**'
|
|
23
|
+
'**/docs/**',
|
|
24
|
+
'npm/reports/**',
|
|
25
|
+
'npm/bin/**'
|
|
24
26
|
])
|
|
25
27
|
|
|
26
28
|
const IGNORE_MATCHERS = DOCGEN_IGNORE_GLOBS.map(glob => picomatch(glob, { dot: true }))
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* оплачується один раз (а не на кожну секцію), і оркестрація перестає програвати в часі.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const STYLE = [
|
|
9
|
+
export const STYLE = [
|
|
10
10
|
'Ти технічний письменник. Пишеш лаконічну ПОВЕДІНКОВУ документацію до коду українською, чистим Markdown.',
|
|
11
11
|
'Пиши ЩО і НАВІЩО, не ЯК. Без вступів і висновків. Не обгортай у ```-блок.',
|
|
12
12
|
'Заборонено: сигнатури, типи, параметри функцій; перелік stdlib-модулів; опис regex чи внутрішніх приватних імен.'
|
|
@@ -80,3 +80,9 @@ export function oneShotMessages(facts, src) {
|
|
|
80
80
|
return msgs(STYLE,
|
|
81
81
|
`Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``)
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Лише текст user-промпту для one-shot (для хмарного fallback через Anthropic SDK). */
|
|
85
|
+
export function oneShotPromptText(facts, src) {
|
|
86
|
+
const multi = (facts.exports?.length || 0) > 1
|
|
87
|
+
return `Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``
|
|
88
|
+
}
|
package/skills/fix/SKILL.md
CHANGED
|
@@ -8,42 +8,16 @@ description: >-
|
|
|
8
8
|
|
|
9
9
|
## Scope
|
|
10
10
|
|
|
11
|
-
Цей скіл відповідає **лише за структуру** проєкту: щоб `.cursor/rules/` + `npx @nitra/cursor fix` були задоволені (наявність конфігів, залежностей, скриптів, GitHub workflows, відсутність заборонених файлів). **Лінт-порушення у самому коді** (ESLint, oxlint, jscpd, cspell, knip, sonarjs, stylelint тощо) — **поза скоупом**; їх діагностує й виправляє **`/n-lint`** (`bun run lint`).
|
|
11
|
+
Цей скіл відповідає **лише за структуру** проєкту: щоб `.cursor/rules/` + `npx @nitra/cursor fix` були задоволені (наявність конфігів, залежностей, скриптів, GitHub workflows, відсутність заборонених файлів). **Лінт-порушення у самому коді** (ESLint, oxlint, jscpd, cspell, knip, sonarjs, stylelint тощо) — **поза скоупом**; їх діагностує й виправляє **`/n-lint`** (`bun run lint`).
|
|
12
12
|
|
|
13
13
|
## Workflow
|
|
14
14
|
|
|
15
|
-
1. **Діагностика** — запусти перевірку через retry-обгортку `n_cursor_npx` (визначена у worktree-preflight, крок 0.1: переживає транзитну CDN-гонку щойно опублікованої версії, а реальний `❌` від `fix` віддає одразу). Прапорець `--json` дає **структурований** результат у stdout, щоб не парсити термінальний текст. За замовчуванням — лише правила з `.cursor/rules/*.mdc`, для яких у пакеті є programmatic check; повний набір — явні аргументи: `n_cursor_npx fix bun ga --json`:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
n_cursor_npx fix --json
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
2. **Аналіз** — розбери JSON `{ total, failed, rules: [{ ruleId, ok, output }] }`. Працюй **лише** з елементами `ok:false`; їх `output` містить готові `❌`-повідомлення правила (не парси stdout вручну, не визначай правила з тексту). Якщо `failed === 0` — нічого виправляти.
|
|
22
|
-
|
|
23
|
-
3. **Виправлення** — для кожного `❌` відкрий відповідне правило з `.cursor/rules/` і виправ:
|
|
24
|
-
- Створи відсутні конфігураційні файли (`.cspell.json`, `.oxfmtrc.json`, `eslint.config.js`, тощо)
|
|
25
|
-
- Додай відсутні залежності до `package.json`
|
|
26
|
-
- Створи або оновити `.vscode/settings.json` та `extensions.json`
|
|
27
|
-
- Створи відсутні GitHub Actions workflows у `.github/workflows/`
|
|
28
|
-
- Видали заборонені файли та залежності (`package-lock.json`, `yarn.lock`, prettier, тощо)
|
|
29
|
-
- Оновити скрипти в `package.json`
|
|
30
|
-
|
|
31
|
-
4. **Встановлення** — якщо були змінені залежності:
|
|
32
|
-
|
|
33
15
|
```bash
|
|
34
|
-
|
|
16
|
+
n_cursor_npx fix
|
|
35
17
|
```
|
|
36
18
|
|
|
37
|
-
|
|
19
|
+
Exit 0 = чисто, 1 = є unresolved (перевір вивід — буде список правил що не закрились після 3 ітерацій).
|
|
38
20
|
|
|
39
|
-
|
|
40
|
-
oxfmt .
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
6. **Верифікація** — перевір що все виправлено (та сама retry-обгортка `n_cursor_npx`); чекаєш `failed === 0`:
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
n_cursor_npx fix --json
|
|
47
|
-
```
|
|
21
|
+
Якщо змінились залежності — `bun i`. Якщо змінились JS/TS файли — `oxfmt .`.
|
|
48
22
|
|
|
49
|
-
|
|
23
|
+
Для конкретних правил: `n_cursor_npx fix bun ga`.
|
|
@@ -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
|
+
}
|