@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.
@@ -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
- * Режим `--oneshot` база для порівняння (один промпт на весь документ).
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
- /** Головний API: файл { md, genTok, ms }. */
110
- export async function generateDoc(file, { model = 'gemma3:4b', mode = 'orchestrated' } = {}) {
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
- const r = facts.unsupported
115
- ? await generateOneShot(facts, src, model) // fallback для не-JS
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
- return { ...r, ms: Date.now() - t0 }
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
- if (!file) { console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--model <m>]'); process.exit(1) }
130
- const r = await generateDoc(file, { model, mode })
131
- process.stderr.write(`[${mode}] ${r.ms}ms / ${r.genTok} tok\n`)
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
+ }
@@ -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`). Не запускай `bun run lint` із цього скілу і не намагайся виправляти його порушення тут — це задача `/n-lint`. Якщо `npx @nitra/cursor fix` чистий, а `bun run lint` лишився червоним — запусти `/n-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
- bun i
16
+ n_cursor_npx fix
35
17
  ```
36
18
 
37
- 5. **Форматування**відформатуй змінені файли:
19
+ Exit 0 = чисто, 1 = є unresolved (перевір вивід буде список правил що не закрились після 3 ітерацій).
38
20
 
39
- ```bash
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
- 7. **Результат** `failed` має стати `0` (усі правила `ok:true`). Якщо лишились `ok:false` — повтори кроки 3-6. Лінт-помилки від `bun run lint` тут **не виправляй** — вони на скіл `/n-lint`.
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
+ }