@nitra/cursor 12.15.0 → 12.16.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/n-cursor.js +2 -11
  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/bun/docs/main.md +7 -6
  20. package/rules/doc-files/js/docgen-files-batch.mjs +20 -5
  21. package/rules/doc-files/js/docgen-gen.mjs +42 -25
  22. package/rules/doc-files/js/docgen-judge-measure.mjs +16 -13
  23. package/rules/doc-files/js/docgen-judge.mjs +11 -9
  24. package/rules/doc-files/js/docs/docgen-files-batch.md +3 -20
  25. package/rules/doc-files/js/docs/docgen-gen.md +3 -20
  26. package/rules/doc-files/js/docs/docgen-judge-measure.md +3 -18
  27. package/rules/doc-files/js/docs/docgen-judge.md +3 -22
  28. package/rules/npm-module/js/docs/skill_meta.md +22 -15
  29. package/rules/npm-module/js/skill_meta.mjs +5 -1
  30. package/rules/python/docs/main.md +11 -11
  31. package/rules/rust/docs/main.md +5 -5
  32. package/rules/text/js/cspell-fix.mjs +15 -16
  33. package/rules/text/js/docs/cspell-fix.md +16 -9
  34. package/rules/text/main.mjs +4 -4
  35. package/schemas/skill-meta.json +8 -0
  36. package/scripts/docs/skills-cli.md +21 -25
  37. package/scripts/docs/update-blue-oak.md +8 -8
  38. package/scripts/lib/adr/docs/normalize-cli.md +3 -20
  39. package/scripts/lib/adr/docs/normalize-pipeline.md +3 -33
  40. package/scripts/lib/adr/normalize-cli.mjs +2 -2
  41. package/scripts/lib/adr/normalize-pipeline.mjs +78 -44
  42. package/scripts/lib/docs/discover-checkable-rules.md +6 -6
  43. package/scripts/lib/docs/inline-template-links.md +8 -6
  44. package/scripts/lib/docs/list-project-rules-mdc.md +5 -3
  45. package/scripts/lib/docs/root-notice.md +13 -16
  46. package/scripts/lib/docs/run-lint.md +10 -8
  47. package/scripts/lib/docs/skill-meta.md +29 -10
  48. package/scripts/lib/fix/docs/discover-t0-patterns.md +10 -13
  49. package/scripts/lib/fix/docs/escalation-log.md +10 -9
  50. package/scripts/lib/fix/docs/index.md +0 -1
  51. package/scripts/lib/fix/docs/orchestrator.md +15 -13
  52. package/scripts/lib/fix/escalation-log.mjs +1 -1
  53. package/scripts/lib/fix/orchestrator.mjs +67 -32
  54. package/scripts/lib/run-lint.mjs +2 -10
  55. package/scripts/lib/skill-meta.mjs +22 -0
  56. package/scripts/skills-cli.mjs +52 -14
  57. package/scripts/utils/ast-extract.mjs +105 -0
  58. package/scripts/utils/docs/ast-extract.md +30 -0
  59. package/scripts/utils/docs/walkDir.md +17 -20
  60. package/lib/docs/llm.md +0 -33
  61. package/lib/docs/models.md +0 -48
  62. package/lib/docs/omlx-trace.md +0 -49
  63. package/lib/docs/omlx.md +0 -41
  64. package/lib/llm.mjs +0 -215
  65. package/lib/models.mjs +0 -75
  66. package/lib/omlx-trace.mjs +0 -158
  67. package/lib/omlx.mjs +0 -220
  68. package/scripts/lib/fix/analyze-escalation.mjs +0 -353
  69. package/scripts/lib/fix/docs/analyze-escalation.md +0 -44
  70. package/scripts/lib/fix/docs/llm-fix-apply.md +0 -31
  71. package/scripts/lib/fix/docs/llm-lint-fix.md +0 -31
  72. package/scripts/lib/fix/docs/llm-worker.md +0 -33
  73. package/scripts/lib/fix/docs/verbose-block.md +0 -27
  74. package/scripts/lib/fix/llm-fix-apply.mjs +0 -113
  75. package/scripts/lib/fix/llm-lint-fix.mjs +0 -82
  76. package/scripts/lib/fix/llm-worker.mjs +0 -332
  77. 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.0",
3
+ "version": "12.16.0",
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"
@@ -3,24 +3,25 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/bun/main.mjs
5
5
  docgen:
6
- crc: 18950415
6
+ crc: 7cca0901
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  ---
10
10
 
11
11
  ## Огляд
12
12
 
13
- Модуль застосовує політики до коду та перевіряє ліцензії залежностей (bun.mdc). Функція `run` застосовує політику до коду, використовуючи кешування у межах прогону для прискорення. Функція `lint` перевіряє ліцензії npm-залежностей, спираючись на конфігурацію в .licensee.json. У режимі `fix` вона створює .licensee.json, а в режимі `readOnly` блокує виконання.
13
+ Модуль виконує виконання політики доступу до коду проєкту та перевірку ліцензій залежностей. Функція `run` застосовує політику, що включає посилання на (bun.mdc), тоді як функція `lint` перевіряє ліцензії npm-залежностей відповідно до конфігурації, визначеної у .licensee.json. Кешування результатів відбувається у межах одного прогону.
14
14
 
15
15
  ## Поведінка
16
16
 
17
- run виконує перевірку, застосовуючи політику до коду, використовуючи кешування.
18
- lint виконує перевірку ліцензій npm-залежностей, генеруючи `.licensee.json` у fix-режимі або відмовляючись у readOnly режимі.
17
+ run виконує перевірку, застосовуючи політику до коду проєкту, включаючи посилання на mdc.
18
+
19
+ lint виконує перевірку ліцензій npm-залежностей через `.licensee.json`. У fix-режимі створює `.licensee.json` з дефолтним allowlist (blueOak: bronze), а у readOnly (CI) відсутність файлу призводить до невдачі.
19
20
 
20
21
  ## Публічний API
21
22
 
22
- run — Точка входу правила, що виконує перевірку: застосовує логіку, перевіряє відповідність політиці та посилання на маркери повідомлень (bun.mdc).
23
- lint — Інструмент для перевірки ліцензій npm-залежностей у всьому репозиторії. У режимі `--full` він працює повноцінно; у режимі виправлення автоматично створює файл .licensee.json, якщо його немає.
23
+ run — виконує основний потік правила: застосовує логіку, перевіряє відповідність політикам та посиланням (bun.mdc).
24
+ lint — проводить перевірку ліцензій npm-залежностей у всьому репозиторії. У режимі `--full` і `--fix` автоматично створює файл .licensee.json, якщо він відсутній.
24
25
 
25
26
  ## Гарантії поведінки
26
27