@nitra/cursor 12.15.1 → 12.16.1

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/n-cursor.js +1 -1
  3. package/lib/docs/index.md +9 -6
  4. package/lib/docs/pi-agent-fix.md +28 -0
  5. package/lib/docs/pi-agent-skill.md +36 -0
  6. package/lib/docs/pi-model-tiers.md +46 -0
  7. package/lib/docs/pi-one-shot.md +34 -0
  8. package/lib/docs/pi-telemetry-store.md +33 -0
  9. package/lib/docs/pi-trace.md +27 -0
  10. package/lib/docs/pi-write-guard.md +32 -0
  11. package/lib/pi-agent-fix.mjs +253 -0
  12. package/lib/pi-agent-skill.mjs +181 -0
  13. package/lib/pi-model-tiers.mjs +109 -0
  14. package/lib/pi-one-shot.mjs +129 -0
  15. package/lib/pi-telemetry-store.mjs +0 -0
  16. package/lib/pi-trace.mjs +40 -0
  17. package/lib/pi-write-guard.mjs +147 -0
  18. package/package.json +5 -1
  19. package/rules/doc-files/js/docgen-files-batch.mjs +20 -5
  20. package/rules/doc-files/js/docgen-gen.mjs +42 -25
  21. package/rules/doc-files/js/docgen-judge-measure.mjs +16 -13
  22. package/rules/doc-files/js/docgen-judge.mjs +11 -9
  23. package/rules/doc-files/js/docs/docgen-files-batch.md +3 -20
  24. package/rules/doc-files/js/docs/docgen-gen.md +3 -20
  25. package/rules/doc-files/js/docs/docgen-judge-measure.md +3 -18
  26. package/rules/doc-files/js/docs/docgen-judge.md +3 -22
  27. package/rules/npm-module/js/docs/skill_meta.md +22 -15
  28. package/rules/npm-module/js/skill_meta.mjs +5 -1
  29. package/rules/text/js/cspell-fix.mjs +15 -16
  30. package/rules/text/js/docs/cspell-fix.md +16 -9
  31. package/rules/text/main.mjs +4 -4
  32. package/schemas/skill-meta.json +8 -0
  33. package/scripts/docs/skills-cli.md +21 -25
  34. package/scripts/lib/adr/docs/normalize-cli.md +3 -20
  35. package/scripts/lib/adr/docs/normalize-pipeline.md +3 -33
  36. package/scripts/lib/adr/normalize-cli.mjs +2 -2
  37. package/scripts/lib/adr/normalize-pipeline.mjs +78 -44
  38. package/scripts/lib/docs/skill-meta.md +27 -10
  39. package/scripts/lib/fix/docs/escalation-log.md +10 -9
  40. package/scripts/lib/fix/docs/orchestrator.md +13 -20
  41. package/scripts/lib/fix/escalation-log.mjs +1 -1
  42. package/scripts/lib/fix/orchestrator.mjs +65 -31
  43. package/scripts/lib/skill-meta.mjs +22 -0
  44. package/scripts/skills-cli.mjs +52 -14
  45. package/scripts/utils/ast-extract.mjs +105 -0
  46. package/scripts/utils/docs/ast-extract.md +30 -0
  47. package/lib/docs/llm.md +0 -33
  48. package/lib/docs/models.md +0 -48
  49. package/lib/docs/omlx-trace.md +0 -49
  50. package/lib/docs/omlx.md +0 -41
  51. package/lib/llm.mjs +0 -215
  52. package/lib/models.mjs +0 -75
  53. package/lib/omlx-trace.mjs +0 -158
  54. package/lib/omlx.mjs +0 -220
  55. package/scripts/lib/fix/docs/llm-fix-apply.md +0 -31
  56. package/scripts/lib/fix/docs/llm-lint-fix.md +0 -31
  57. package/scripts/lib/fix/docs/llm-worker.md +0 -28
  58. package/scripts/lib/fix/docs/verbose-block.md +0 -27
  59. package/scripts/lib/fix/llm-fix-apply.mjs +0 -113
  60. package/scripts/lib/fix/llm-lint-fix.mjs +0 -82
  61. package/scripts/lib/fix/llm-worker.mjs +0 -346
  62. package/scripts/lib/fix/verbose-block.mjs +0 -82
@@ -0,0 +1,181 @@
1
+ /** @see ./docs/pi-agent-skill.md */
2
+
3
+ /**
4
+ * Agentic skill-runner поверх pi `createAgentSession`
5
+ * (спека docs/specs/2026-06-26-skills-cli-pi-runner-design.md §5).
6
+ *
7
+ * Виконує один скіл як pi-агента: готовий `buildSkillPrompt`-рядок → `session.prompt`.
8
+ * Повний user-trust набір вбудованих tools (`read/grep/find/edit/write/ls/bash`), БЕЗ
9
+ * write-guard — скіл є явною user-invocation (паритет із `claude -p`, який теж без
10
+ * обмежень). Модель — з ОДНОГО тиру (`main.json.tier`, дефолт `max`), без escalation-
11
+ * сходів fix-engine. Runaway-backstop: turn-ceiling + per-call timeout. Асистентський
12
+ * текст стрімиться у stdout (паритет із `claude -p`). Телеметрія `kind:"skill"` у
13
+ * глобальний trace ([pi-trace]).
14
+ *
15
+ * Pi вантажиться lazy (тверда межа CI). Логіка інжектована через `deps` для unit-тестів.
16
+ */
17
+
18
+ import { env, stdout } from 'node:process'
19
+ import { getRegistry, resolveModel, resolveModelSpec } from './pi-model-tiers.mjs'
20
+ import { writeTrace } from './pi-trace.mjs'
21
+
22
+ /** Аварійна стеля turns на сесію скіла (runaway-backstop). Override: `N_CURSOR_SKILL_TURN_CEILING`. */
23
+ const TURN_CEILING = Number(env.N_CURSOR_SKILL_TURN_CEILING) || 80
24
+
25
+ /** Дефолтний timeout одного скіл-виклику (скіли довгі). Override: `N_CURSOR_SKILL_TIMEOUT_MS`. */
26
+ const DEFAULT_TIMEOUT_MS = Number(env.N_CURSOR_SKILL_TIMEOUT_MS) || 600_000
27
+
28
+ /** Повний user-trust набір вбудованих tools. `bash` — обовʼязковий (taze→bun, coverage→тести). */
29
+ const SKILL_TOOLS = ['read', 'grep', 'find', 'edit', 'write', 'ls', 'bash']
30
+
31
+ /** Тира скіла → pi `thinkingLevel` (одна тира на виклик, без rung-сходів). */
32
+ const THINKING_BY_TIER = { min: 'low', avg: 'medium', max: 'high' }
33
+
34
+ /**
35
+ * Дефолтна фабрика pi-сесії: повний tool-set із `bash`, БЕЗ custom-tools і write-guard.
36
+ * @returns {Promise<object>} pi AgentSession
37
+ */
38
+ async function defaultCreateSession({ registry, model, cwd, thinkingLevel }) {
39
+ const { createAgentSession, SessionManager } = await import('@earendil-works/pi-coding-agent')
40
+ const { session } = await createAgentSession({
41
+ modelRegistry: registry,
42
+ model,
43
+ tools: SKILL_TOOLS,
44
+ thinkingLevel: thinkingLevel ?? 'medium',
45
+ cwd: cwd ?? process.cwd(),
46
+ sessionManager: SessionManager.inMemory()
47
+ })
48
+ return session
49
+ }
50
+
51
+ /** Гонка з таймаутом; на таймаут кличе `onTimeout` (abort) і реджектить. */
52
+ function withTimeout(promise, ms, onTimeout) {
53
+ if (!ms || ms <= 0) return promise
54
+ let timer
55
+ const timeout = new Promise((_, reject) => {
56
+ timer = setTimeout(() => {
57
+ onTimeout?.()
58
+ reject(new Error(`skill timeout ${ms}ms`))
59
+ }, ms)
60
+ })
61
+ return Promise.race([Promise.resolve(promise).finally(() => clearTimeout(timer)), timeout])
62
+ }
63
+
64
+ /**
65
+ * Виконує ОДИН скіл агентно через pi.
66
+ * @param {string} prompt готовий промпт (`buildSkillPrompt`)
67
+ * @param {{
68
+ * skillId?: string,
69
+ * tier?: 'min'|'avg'|'max',
70
+ * modelSpec?: string,
71
+ * cwd?: string,
72
+ * thinkingLevel?: 'off'|'minimal'|'low'|'medium'|'high'|'xhigh',
73
+ * timeoutMs?: number,
74
+ * caller?: string,
75
+ * deps?: { createSession?: Function, getRegistry?: Function, registry?: object, trace?: Function, clock?: () => number, out?: (s: string) => void }
76
+ * }} [opts]
77
+ * @returns {Promise<{ ok: boolean, telemetry: object|null, error: string|null }>}
78
+ */
79
+ export async function runPiAgentSkill(prompt, opts = {}) {
80
+ const {
81
+ skillId = 'skill',
82
+ tier = 'max',
83
+ modelSpec,
84
+ cwd = process.cwd(),
85
+ thinkingLevel,
86
+ timeoutMs = DEFAULT_TIMEOUT_MS,
87
+ caller = `skill:${skillId}`,
88
+ deps = {}
89
+ } = opts
90
+ const createSession = deps.createSession ?? defaultCreateSession
91
+ const getReg = deps.getRegistry ?? getRegistry
92
+ const trace = deps.trace ?? writeTrace
93
+ const clock = deps.clock ?? (() => Date.now())
94
+ const out = deps.out ?? (s => stdout.write(s))
95
+
96
+ const fail = (error, model) => {
97
+ trace({ caller, backend: 'pi-ai', kind: 'skill', skill: skillId, tier, model: model ?? null, cwd, error })
98
+ return { ok: false, telemetry: null, error }
99
+ }
100
+
101
+ let registry
102
+ let spec
103
+ let model
104
+ try {
105
+ registry = deps.registry ?? (await getReg())
106
+ spec = modelSpec ?? resolveModel(tier) // '' допустимо → дефолт провайдера pi
107
+ model = spec ? resolveModelSpec(registry, spec) : null
108
+ if (spec && !model) return fail(`модель не знайдена: ${spec}`, spec)
109
+ } catch (e) {
110
+ return fail(`registry: ${e.message}`, null)
111
+ }
112
+
113
+ let session
114
+ try {
115
+ session = await createSession({
116
+ registry,
117
+ model,
118
+ cwd,
119
+ thinkingLevel: thinkingLevel ?? THINKING_BY_TIER[tier] ?? 'medium'
120
+ })
121
+ } catch (e) {
122
+ return fail(`session: ${e.message}`, spec)
123
+ }
124
+
125
+ let turnCount = 0
126
+ let toolCallCount = 0
127
+ let backstopHit = false
128
+ session.subscribe(event => {
129
+ switch (event.type) {
130
+ case 'turn_start':
131
+ turnCount++
132
+ if (turnCount > TURN_CEILING) {
133
+ backstopHit = true
134
+ session.abort?.()
135
+ }
136
+ break
137
+ case 'tool_execution_start':
138
+ toolCallCount++
139
+ break
140
+ case 'message_update':
141
+ if (event.assistantMessageEvent?.type === 'text_delta') {
142
+ out(event.assistantMessageEvent.delta ?? '')
143
+ }
144
+ break
145
+ default:
146
+ break
147
+ }
148
+ })
149
+
150
+ const startedAt = clock()
151
+ let error = null
152
+ try {
153
+ await withTimeout(session.prompt(prompt), timeoutMs, () => session.abort?.())
154
+ } catch (e) {
155
+ error = e.message
156
+ }
157
+
158
+ const telemetry = {
159
+ skill: skillId,
160
+ tier,
161
+ model: spec || null,
162
+ turnCount,
163
+ toolCallCount,
164
+ backstopHit,
165
+ wallMs: clock() - startedAt
166
+ }
167
+ trace({
168
+ caller,
169
+ backend: 'pi-ai',
170
+ kind: 'skill',
171
+ skill: skillId,
172
+ tier,
173
+ model: spec || null,
174
+ cwd,
175
+ turnCount,
176
+ toolCallCount,
177
+ backstopHit,
178
+ error
179
+ })
180
+ return { ok: !error && !backstopHit, telemetry, error }
181
+ }
@@ -0,0 +1,109 @@
1
+ /** @see ./docs/pi-model-tiers.md */
2
+
3
+ /**
4
+ * Тир-конфіг моделей для pi-embed fix-engine і shared LLM consumers.
5
+ *
6
+ * Замінює `models.mjs` після міграції на pi-SDK: тири лишаються політикою n-cursor
7
+ * (які env-моделі = який тир), але резолвінг тепер через pi `ModelRegistry.find`,
8
+ * а не ручний routing на omlx/pi-CLI.
9
+ *
10
+ * Формат значень env — `"provider/model-id"` (pi-формат), напр.:
11
+ * N_LOCAL_MIN_MODEL=omlx/gemma-4-e4b-it-OptiQ-4bit
12
+ * N_CLOUD_MIN_MODEL=openai/gpt-5.4-mini
13
+ * N_CLOUD_AVG_MODEL=openai/gpt-5.4
14
+ *
15
+ * Pi вантажиться ВИКЛЮЧНО у `getRegistry()` (lazy dynamic import) — модуль сам по собі
16
+ * pi-free, тому pure-функції (`parseModelId`, `resolveModelSpec`, `thinkingLevelForTier`,
17
+ * `resolveModel`) юніт-тестуються без pi (registry інжектується).
18
+ */
19
+
20
+ import { env } from 'node:process'
21
+
22
+ // ── Тири (env-політика) ──────────────────────────────────────────────────────
23
+
24
+ /** Швидкий локальний inference. Напр.: omlx/gemma-4-e4b-it-OptiQ-4bit */
25
+ export const LOCAL_MIN = env.N_LOCAL_MIN_MODEL ?? ''
26
+ /** Середній локальний. */
27
+ export const LOCAL_AVG = env.N_LOCAL_AVG_MODEL ?? ''
28
+ /** Максимальний локальний. */
29
+ export const LOCAL_MAX = env.N_LOCAL_MAX_MODEL ?? ''
30
+ /** Мінімальний хмарний (потрібен ключ у pi auth). Напр.: openai/gpt-5.4-mini */
31
+ export const CLOUD_MIN = env.N_CLOUD_MIN_MODEL ?? ''
32
+ /** Середній хмарний. Напр.: openai/gpt-5.4 */
33
+ export const CLOUD_AVG = env.N_CLOUD_AVG_MODEL ?? ''
34
+ /** Максимальний хмарний. Напр.: openai/gpt-5.5 */
35
+ export const CLOUD_MAX = env.N_CLOUD_MAX_MODEL ?? ''
36
+
37
+ /**
38
+ * Каскадне розв'язання абстрактного тиру в `"provider/model-id"` (контракт із
39
+ * `models.mjs`, збережений для shared one-shot consumers):
40
+ * 'min' → LOCAL_MIN → LOCAL_AVG → LOCAL_MAX → CLOUD_MIN
41
+ * 'avg' → LOCAL_AVG → LOCAL_MAX → CLOUD_AVG
42
+ * 'max' → LOCAL_MAX → CLOUD_MAX
43
+ * @param {'min'|'avg'|'max'} tier тир
44
+ * @returns {string} `"provider/model-id"` або `''` (pi-дефолт провайдера)
45
+ * @throws {TypeError} якщо tier невідомий
46
+ */
47
+ export function resolveModel(tier) {
48
+ if (tier === 'min') return LOCAL_MIN || LOCAL_AVG || LOCAL_MAX || CLOUD_MIN
49
+ if (tier === 'avg') return LOCAL_AVG || LOCAL_MAX || CLOUD_AVG
50
+ if (tier === 'max') return LOCAL_MAX || CLOUD_MAX
51
+ throw new TypeError(`resolveModel: unknown tier "${tier}". Use 'min', 'avg', or 'max'.`)
52
+ }
53
+
54
+ // ── Escalation-rung → pi thinkingLevel (§3) ──────────────────────────────────
55
+
56
+ /**
57
+ * pi `thinkingLevel` за rung-тиром fix-драбини: слабка локальна — `low`,
58
+ * cloud-min — `medium`, cloud-avg (найдорожча, найскладніше) — `high`. Замінює
59
+ * ручний числовий `thinkingBudget`-плюмбінг старого omlx-каналу.
60
+ * @param {string} tier rung-тир (`local-min` | `local-min-retry` | `cloud-min` | `cloud-avg`)
61
+ * @returns {'off'|'minimal'|'low'|'medium'|'high'|'xhigh'} дискретний рівень
62
+ */
63
+ export function thinkingLevelForTier(tier) {
64
+ if (tier === 'cloud-avg') return 'high'
65
+ if (tier === 'cloud-min') return 'medium'
66
+ return 'low' // local-min, local-min-retry
67
+ }
68
+
69
+ // ── Парсинг і резолвінг через pi ─────────────────────────────────────────────
70
+
71
+ /**
72
+ * Розбирає `"provider/model-id"` у пару. Перший `/` — роздільник (model-id може
73
+ * містити власні `/`). Порожній провайдер чи id → `null` (malformed).
74
+ * @param {string} spec `"provider/model-id"`
75
+ * @returns {{ provider: string, id: string } | null} пара або null
76
+ */
77
+ export function parseModelId(spec) {
78
+ if (typeof spec !== 'string') return null
79
+ const i = spec.indexOf('/')
80
+ if (i < 1 || i === spec.length - 1) return null
81
+ return { provider: spec.slice(0, i), id: spec.slice(i + 1) }
82
+ }
83
+
84
+ /**
85
+ * Резолвить `"provider/model-id"` у pi Model-обʼєкт через інжектований registry.
86
+ * `createAgentSession` чекає саме Model-обʼєкт (НЕ рядок) — див. Спайк 2.
87
+ * @param {{ find: (provider: string, id: string) => object|null|undefined }} registry pi ModelRegistry
88
+ * @param {string} spec `"provider/model-id"`
89
+ * @returns {object|null} pi Model або null (malformed/не знайдено)
90
+ */
91
+ export function resolveModelSpec(registry, spec) {
92
+ const parsed = parseModelId(spec)
93
+ if (!parsed) return null
94
+ return registry.find(parsed.provider, parsed.id) ?? null
95
+ }
96
+
97
+ /**
98
+ * Lazy singleton pi `ModelRegistry` (вантажить `~/.pi/agent/models.json` + `auth.json`).
99
+ * **Єдина** точка, де модуль торкається pi — dynamic import тримає `--read-only`
100
+ * шлях pi-free (тверда межа CI). Кешується на процес.
101
+ * @returns {Promise<object>} pi ModelRegistry
102
+ */
103
+ let _registry = null
104
+ export async function getRegistry() {
105
+ if (_registry) return _registry
106
+ const { ModelRegistry, AuthStorage } = await import('@earendil-works/pi-coding-agent')
107
+ _registry = ModelRegistry.create(AuthStorage.create())
108
+ return _registry
109
+ }
@@ -0,0 +1,129 @@
1
+ /** @see ./docs/pi-one-shot.md */
2
+
3
+ /**
4
+ * Bounded one-shot LLM-виклик поверх pi (§3а спеки pi-migration).
5
+ *
6
+ * Для shared не-agent consumers (`doc-files` generation/judge, `text/cspell`
7
+ * класифікація, ADR-normalize): «messages → текст», без write-tool, без агентного
8
+ * циклу. Реалізовано як `createAgentSession({ noTools: 'all' })` + `session.prompt`
9
+ * (агент без tools = plain completion) — перевикористовує верифікований pi-embed
10
+ * (той самий ModelRegistry/AuthStorage, що й agent-fix), замість окремого
11
+ * raw-pi-ai streaming-плюмбінгу.
12
+ *
13
+ * Замінює `callLlm`/прямий omlx-канал для не-agent задач. Pi вантажиться lazy
14
+ * (тверда межа CI). Повертає structured `{ content, usage, error, model, caller }`.
15
+ */
16
+
17
+ import { getRegistry, resolveModel, resolveModelSpec } from './pi-model-tiers.mjs'
18
+ import { writeTrace } from './pi-trace.mjs'
19
+
20
+ /** Дефолтний timeout одного one-shot виклику. */
21
+ const DEFAULT_TIMEOUT_MS = 120_000
22
+
23
+ /**
24
+ * Дефолтна фабрика сесії (lazy pi). `noTools:'all'` → чистий completion.
25
+ * Інструкції НЕ йдуть через `replaceInstructions`: слабкі локальні моделі (gemma-4b)
26
+ * трактують system-prompt-інструкції як «правила для підтвердження» й мета-рамблять
27
+ * замість виконувати. Тому system-повідомлення зливаються у prompt (див. runOneShot) —
28
+ * перевірено: інлайн-інструкції модель ВИКОНУЄ, у system-промпті — переказує.
29
+ * @returns {Promise<object>} pi AgentSession
30
+ */
31
+ async function defaultCreateSession({ registry, model, cwd, thinkingLevel }) {
32
+ const { createAgentSession, SessionManager } = await import('@earendil-works/pi-coding-agent')
33
+ const { session } = await createAgentSession({
34
+ modelRegistry: registry,
35
+ model,
36
+ noTools: 'all',
37
+ thinkingLevel: thinkingLevel ?? 'off',
38
+ cwd: cwd ?? process.cwd(),
39
+ sessionManager: SessionManager.inMemory()
40
+ })
41
+ return session
42
+ }
43
+
44
+ /** Гонка проміса з таймаутом (мс ≤ 0 → без таймауту). */
45
+ function withTimeout(promise, ms) {
46
+ if (!ms || ms <= 0) return promise
47
+ let timer
48
+ const timeout = new Promise((_, reject) => {
49
+ timer = setTimeout(() => reject(new Error(`one-shot timeout ${ms}ms`)), ms)
50
+ })
51
+ return Promise.race([Promise.resolve(promise).finally(() => clearTimeout(timer)), timeout])
52
+ }
53
+
54
+ /**
55
+ * Виконує bounded one-shot LLM-виклик.
56
+ * @param {{
57
+ * messages: Array<{role: string, content: string}>,
58
+ * modelTier?: 'min'|'avg'|'max',
59
+ * modelSpec?: string,
60
+ * thinkingLevel?: 'off'|'minimal'|'low'|'medium'|'high'|'xhigh',
61
+ * timeoutMs?: number,
62
+ * caller?: string,
63
+ * cwd?: string,
64
+ * deps?: { createSession?: Function, getRegistry?: Function, registry?: object, trace?: Function }
65
+ * }} args параметри
66
+ * @returns {Promise<{ content: string, usage: object|null, error: string|null, model: string|null, caller: string }>} результат
67
+ */
68
+ export async function runOneShot({
69
+ messages,
70
+ modelTier = 'min',
71
+ modelSpec,
72
+ thinkingLevel,
73
+ timeoutMs = DEFAULT_TIMEOUT_MS,
74
+ caller = 'one-shot',
75
+ cwd,
76
+ deps = {}
77
+ } = {}) {
78
+ const createSession = deps.createSession ?? defaultCreateSession
79
+ const getReg = deps.getRegistry ?? getRegistry
80
+ const trace = deps.trace ?? writeTrace
81
+
82
+ // Усі повідомлення (system+user) зливаються в один prompt — інлайн-інструкції
83
+ // слабка локальна модель ВИКОНУЄ, а в system-промпті (replaceInstructions) — переказує.
84
+ const userText = messages.map(m => m.content).join('\n\n')
85
+
86
+ const fail = (error, model) => {
87
+ trace({ caller, backend: 'pi-ai', kind: 'one-shot', model: model ?? null, cwd: cwd ?? null, error })
88
+ return { content: '', usage: null, error, model: model ?? null, caller }
89
+ }
90
+
91
+ let registry
92
+ let spec
93
+ let model
94
+ try {
95
+ registry = deps.registry ?? (await getReg())
96
+ spec = modelSpec ?? resolveModel(modelTier)
97
+ model = spec ? resolveModelSpec(registry, spec) : null
98
+ if (spec && !model) return fail(`модель не знайдена: ${spec}`, spec)
99
+ } catch (e) {
100
+ return fail(`registry: ${e.message}`, null)
101
+ }
102
+
103
+ let session
104
+ try {
105
+ session = await createSession({ registry, model, cwd, thinkingLevel })
106
+ } catch (e) {
107
+ return fail(`session: ${e.message}`, spec)
108
+ }
109
+
110
+ let text = ''
111
+ let usage = null
112
+ session.subscribe(event => {
113
+ if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
114
+ text += event.assistantMessageEvent.delta ?? ''
115
+ } else if (event.type === 'message_end' && event.message?.usage) {
116
+ usage = event.message.usage
117
+ }
118
+ })
119
+
120
+ let error = null
121
+ try {
122
+ await withTimeout(session.prompt(userText), timeoutMs)
123
+ } catch (e) {
124
+ error = e.message
125
+ }
126
+
127
+ trace({ caller, backend: 'pi-ai', kind: 'one-shot', model: spec, cwd: cwd ?? null, usage, error })
128
+ return { content: text.trim(), usage, error, model: spec, caller }
129
+ }
Binary file
@@ -0,0 +1,40 @@
1
+ /** @see ./docs/pi-trace.md */
2
+
3
+ /**
4
+ * Глобальний append-only LLM wire-trace (§7 спеки pi-migration).
5
+ *
6
+ * Замінює project-local `omlx-trace.mjs`: єдиний trace живе глобально
7
+ * (`~/.n-cursor/llm-trace.jsonl`), щоб (1) прибрати службовий шум із consumer-репо,
8
+ * (2) лишити cross-project телеметрію mineable в одному місці. Старі project-local
9
+ * `<cwd>/.n-cursor/llm-trace.jsonl` більше не створюються.
10
+ *
11
+ * Записувач **best-effort**: будь-яка IO-помилка ковтається — трасування ніколи не
12
+ * валить виклик LLM. Шлях перевизначається `N_CURSOR_TRACE_PATH` (для тестів/CI).
13
+ */
14
+
15
+ import { appendFileSync, mkdirSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+ import { env } from 'node:process'
19
+
20
+ /** Шлях глобального trace (env-override `N_CURSOR_TRACE_PATH`). @returns {string} */
21
+ export function tracePath() {
22
+ return env.N_CURSOR_TRACE_PATH || join(homedir(), '.n-cursor', 'llm-trace.jsonl')
23
+ }
24
+
25
+ /**
26
+ * Дописує один trace-запис (JSONL). Поля за §7: `caller`, `rule`, `rung`, `model`,
27
+ * `backend:"pi-ai"`, `kind:"agent"|"one-shot"`, `cwd`, плюс довільна корисна навантага.
28
+ * Ніколи не кидає.
29
+ * @param {object} record запис трасування
30
+ * @param {string} [path] шлях (за замовч. `tracePath()`)
31
+ * @returns {void}
32
+ */
33
+ export function writeTrace(record, path = tracePath()) {
34
+ try {
35
+ mkdirSync(dirname(path), { recursive: true })
36
+ appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`)
37
+ } catch {
38
+ // best-effort: трасування не повинно валити виклик
39
+ }
40
+ }
@@ -0,0 +1,147 @@
1
+ /** @see ./docs/pi-write-guard.md */
2
+
3
+ /**
4
+ * Write-safety guard для pi-agent fix-engine (§12 спеки pi-migration).
5
+ *
6
+ * Рішення §1 «патч застосовує агент» забирає dry-run, тому контроль над записом
7
+ * виносимо у три незалежні під-механізми, що спираються на одну git-precondition:
8
+ * - **Scope/Denylist** — превентивний veto через `pi.on('tool_call')`: блок запису
9
+ * поза git-root, під `.git/`, або в будь-що, що матчить `git check-ignore`.
10
+ * - **Snapshot** — per-file pre-image (наявний вміст або позначка NEW) на перший
11
+ * дотик; покриває abort посеред запису.
12
+ * - **Rollback** — відновлення pre-image (або видалення NEW-файлів) на провал verdict-а.
13
+ *
14
+ * ⚠️ Розводка: фабрику передавати ВИКЛЮЧНО через `new DefaultResourceLoader({
15
+ * extensionFactories: [factory] })` → `resourceLoader`. Top-level
16
+ * `createAgentSession({ extensionFactories })` **мовчки ігнорується** (Спайк 3,
17
+ * fail-open). Тому caller ОБОВ'ЯЗКОВО перевіряє `state.attached` (fail-closed canary)
18
+ * перед `session.prompt` і скасовує fix, якщо guard не приєднався.
19
+ *
20
+ * Pi-free: фабрика лише shape-сумісна з ExtensionAPI (`pi.on`), імпортів pi нема.
21
+ */
22
+
23
+ import { spawnSync } from 'node:child_process'
24
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
26
+
27
+ /** Sentinel pre-image для файлу, якого до запису не існувало (rollback = видалити). */
28
+ export const NEW_FILE = Symbol('new-file')
29
+
30
+ /** Tool-и, що пишуть на диск і підлягають veto. */
31
+ const WRITE_TOOLS = new Set(['edit', 'write'])
32
+
33
+ /**
34
+ * git-root для cwd (`git rev-parse --show-toplevel`) або null, якщо не git-репо.
35
+ * Fix-шлях вимагає git → caller на null **пропускає fix** (§12 precondition).
36
+ * @param {string} cwd робоча директорія
37
+ * @returns {string|null} абсолютний git-root або null
38
+ */
39
+ export function gitRoot(cwd) {
40
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
41
+ if (r.status !== 0) return null
42
+ return r.stdout.trim() || null
43
+ }
44
+
45
+ /**
46
+ * realpath шляху з найкращих зусиль: для наявного — повний realpath; для ще-неіснуючого
47
+ * (NEW-файл) — realpath батьківської теки + basename; інакше — як є. Знімає розбіжність
48
+ * symlink-шляхів (macOS `/tmp` → `/private/tmp`), через яку tracked-файл хибно блокувався.
49
+ * @param {string} p шлях
50
+ * @returns {string} нормалізований абсолютний шлях
51
+ */
52
+ function realpathBestEffort(p) {
53
+ try {
54
+ return realpathSync(p)
55
+ } catch {
56
+ try {
57
+ return join(realpathSync(dirname(p)), basename(p))
58
+ } catch {
59
+ return p
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Чи `abs` під `root` (захист від `..`-escape). @param {string} root @param {string} abs */
65
+ function isUnder(root, abs) {
66
+ const rel = relative(root, abs)
67
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
68
+ }
69
+
70
+ /** Чи `abs` ігнорований git'ом (`git check-ignore -q`, exit 0 = ignored). */
71
+ function isIgnored(root, abs) {
72
+ return spawnSync('git', ['check-ignore', '-q', '--', abs], { cwd: root }).status === 0
73
+ }
74
+
75
+ /**
76
+ * Створює write-guard для однієї fix-сесії.
77
+ * @param {{ cwd: string, root?: string|null, checkIgnore?: (root: string, abs: string) => boolean }} opts
78
+ * cwd — робоча директорія; root — git-root (за替замовч. обчислюється); checkIgnore — інжекція для тестів
79
+ * @returns {{
80
+ * factory: (pi: { on: Function }) => void,
81
+ * state: { attached: boolean, root: string|null, preImages: Map<string, string|symbol>, blocks: Array<{path:string,reason:string}> },
82
+ * rollback: () => void,
83
+ * touchedFiles: () => string[]
84
+ * }} guard
85
+ */
86
+ export function createWriteGuard({ cwd, root, checkIgnore = isIgnored }) {
87
+ const rawRoot = root === undefined ? gitRoot(cwd) : root
88
+ const gitRootDir = rawRoot ? realpathBestEffort(rawRoot) : rawRoot
89
+ // editLog — повні правки агента (oldText/newText / content) для телеметрії §7.
90
+ const state = { attached: false, root: gitRootDir, preImages: new Map(), blocks: [], editLog: [] }
91
+
92
+ const factory = pi => {
93
+ state.attached = true
94
+ // Синхронний хендлер: уся робота (spawnSync/fs) синхронна; pi коректно
95
+ // awaitить і не-Promise return (Спайк 3). Так veto детерміновано тестується.
96
+ pi.on('tool_call', event => {
97
+ if (!WRITE_TOOLS.has(event?.toolName)) return undefined
98
+ const raw = event?.input?.path
99
+ if (typeof raw !== 'string' || raw === '') return undefined // не резолвимо — хай pi сам
100
+
101
+ // realpath-нормалізація: edit-шлях агента може бути symlink-варіантом root'а.
102
+ const abs = realpathBestEffort(isAbsolute(raw) ? raw : resolve(cwd, raw))
103
+ const block = reason => {
104
+ state.blocks.push({ path: abs, reason })
105
+ return { block: true, reason }
106
+ }
107
+
108
+ // 1. Scope: під git-root.
109
+ if (!gitRootDir || !isUnder(gitRootDir, abs)) return block(`запис поза git-root: ${raw}`)
110
+ // 2. .git/ (поза моделлю ignore).
111
+ const rel = relative(gitRootDir, abs)
112
+ if (rel === '.git' || rel.startsWith(`.git${sep}`)) return block(`запис у .git/ заблоковано: ${raw}`)
113
+ // 3. Denylist = git-ignored (build-артефакти, node_modules, .env, .worktrees…).
114
+ if (checkIgnore(gitRootDir, abs)) return block(`запис у git-ignored заблоковано: ${raw}`)
115
+
116
+ // 4. Allow + Snapshot pre-image на перший дотик + лог правки (телеметрія §7).
117
+ if (!state.preImages.has(abs)) {
118
+ state.preImages.set(abs, existsSync(abs) ? readFileSync(abs, 'utf8') : NEW_FILE)
119
+ }
120
+ state.editLog.push({
121
+ path: abs,
122
+ tool: event.toolName,
123
+ edits: event.input?.edits ?? null, // edit: [{oldText,newText}]
124
+ content: event.input?.content ?? null // write: повний вміст
125
+ })
126
+ return undefined
127
+ })
128
+ }
129
+
130
+ /** Відновлює pre-image усіх зачеплених файлів (NEW → видалити). */
131
+ function rollback() {
132
+ for (const [abs, pre] of state.preImages) {
133
+ if (pre === NEW_FILE) {
134
+ if (existsSync(abs)) rmSync(abs)
135
+ } else {
136
+ writeFileSync(abs, pre)
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Список абсолютних шляхів, яких агент торкнувся (для scoped re-check verdict §4+5). */
142
+ function touchedFiles() {
143
+ return [...state.preImages.keys()]
144
+ }
145
+
146
+ return { factory, state, rollback, touchedFiles }
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "12.15.1",
3
+ "version": "12.16.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -62,6 +62,10 @@
62
62
  "yaml": "^2.9.0",
63
63
  "zod": "^4.4.3"
64
64
  },
65
+ "optionalDependencies": {
66
+ "@earendil-works/pi-ai": "0.80.2",
67
+ "@earendil-works/pi-coding-agent": "0.80.2"
68
+ },
65
69
  "engines": {
66
70
  "bun": ">=1.3",
67
71
  "node": ">=25"
@@ -12,8 +12,24 @@ import {
12
12
  import { basename, dirname, join, relative } from 'node:path'
13
13
 
14
14
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
15
- import { classifyOmlxError, preflightLocalModel } from '../../../lib/llm.mjs'
16
15
  import { generateDoc, DEFAULT_LOCAL_MODEL } from './docgen-gen.mjs'
16
+
17
+ /**
18
+ * Класифікує помилку генерації для batch-логіки (замінює `classifyOmlxError` після
19
+ * pi-міграції — помилки приходять як винятки з generateDoc/pi-one-shot):
20
+ * - `permanent` — pre-send guard «Prompt too long» → skip (не ретраїти);
21
+ * - `systemic` — модель/сервер/registry/RAM упали → circuit-breaker abort;
22
+ * - `transient` — таймаут (можна було б ретраїти);
23
+ * - `infra` — інше (рахуємо як помилку, але без abort).
24
+ * @param {string} msg повідомлення помилки
25
+ * @returns {'permanent'|'systemic'|'transient'|'infra'} клас
26
+ */
27
+ function classifyDocgenError(msg) {
28
+ if (/prompt too long|pre-send guard|too long/i.test(msg)) return 'permanent'
29
+ if (/registry:|session:|не знайдена|memory|enomem|connection refused|econnrefused/i.test(msg)) return 'systemic'
30
+ if (/timeout|etimedout/i.test(msg)) return 'transient'
31
+ return 'infra'
32
+ }
17
33
  import { crc32, stampDoc, readDocQuality, readDocModel, QUALITY_THRESHOLD } from './docgen-crc.mjs'
18
34
  import { resolveRoot, scanForDocFiles, scanOrphanedDocs } from './docgen-scan.mjs'
19
35
 
@@ -134,7 +150,7 @@ async function generateOne(file, root, progress, stats) {
134
150
  }
135
151
  return 'ok'
136
152
  } catch (error) {
137
- const cls = classifyOmlxError(error.message)
153
+ const cls = classifyDocgenError(error.message)
138
154
  if (cls === 'permanent') {
139
155
  stats.skipped.push(file.sourcePath)
140
156
  process.stdout.write(`⊘ skip (permanent): ${error.message}\n`)
@@ -307,9 +323,8 @@ export async function runDocFilesGenCli(argv) {
307
323
  * @returns {Promise<number>} 0 — без помилок; 1 — фейл preflight або є помилки; 2 — systemic-abort
308
324
  */
309
325
  export async function runGenerationBatch(targets, root, { headline } = {}) {
310
- const problem = preflightLocalModel(DEFAULT_LOCAL_MODEL)
311
- if (problem) {
312
- console.error(`✗ fix-doc-files: ${problem}`)
326
+ if (!DEFAULT_LOCAL_MODEL) {
327
+ console.error('✗ fix-doc-files: локальну модель не задано (N_LOCAL_MIN_MODEL)')
313
328
  return 1
314
329
  }
315
330