@nitra/cursor 1.8.220 → 1.8.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-template/npm-CLAUDE.md +4 -0
- package/CHANGELOG.md +21 -0
- package/bin/auto-rules.md +2 -0
- package/bin/n-cursor.js +25 -4
- package/mdc/ci4.mdc +51 -0
- package/mdc/tauri.mdc +20 -0
- package/package.json +1 -1
- package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
- package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
- package/policy/k8s/base_manifest/base_manifest.rego +154 -0
- package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
- package/policy/k8s/gateway/gateway.rego +151 -0
- package/policy/k8s/gateway/gateway_test.rego +122 -0
- package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
- package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
- package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
- package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
- package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
- package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
- package/policy/k8s/kustomization/kustomization.rego +220 -0
- package/policy/k8s/kustomization/kustomization_test.rego +128 -0
- package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
- package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
- package/policy/k8s/manifest/manifest.rego +151 -4
- package/policy/k8s/manifest/manifest_test.rego +309 -0
- package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
- package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
- package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
- package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
- package/scripts/auto-skills.mjs +8 -1
- package/scripts/check-bun.mjs +3 -3
- package/scripts/check-changelog.mjs +2 -3
- package/scripts/check-image-avif.mjs +14 -6
- package/scripts/check-image-compress.mjs +1 -1
- package/scripts/check-js-run.mjs +58 -47
- package/scripts/check-k8s.mjs +128 -51
- package/scripts/check-npm-module.mjs +1 -4
- package/scripts/check-php.mjs +5 -5
- package/scripts/claude-stop-hook.mjs +2 -2
- package/scripts/lint-conftest.mjs +88 -8
- package/scripts/lint-ga.mjs +1 -1
- package/scripts/lint-rego.mjs +19 -4
- package/scripts/run-shellcheck-text.mjs +94 -64
- package/scripts/sync-claude-config.mjs +1 -1
- package/scripts/utils/ast-scan-utils.mjs +28 -0
- package/scripts/utils/bun-sql-scan.mjs +53 -34
- package/scripts/utils/bunyan-imports.mjs +10 -61
- package/scripts/utils/conn-file-rules.mjs +76 -37
- package/scripts/utils/depcheck-workflow.mjs +27 -6
- package/scripts/utils/redis-imports.mjs +9 -51
- package/skills/llm-patch/SKILL.md +16 -5
|
@@ -19,13 +19,16 @@ import { parseProgramOrNull } from './ast-scan-utils.mjs'
|
|
|
19
19
|
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Канонічний шаблон імені
|
|
23
|
-
*
|
|
24
|
-
* - `(pg|mysql|mssql)-(read|write)(-<id>)?` для БД.
|
|
25
|
-
* `<id>` — починається з [a-z0-9], далі [a-z0-9-]*.
|
|
22
|
+
* Канонічний шаблон імені GraphQL-файла: `ql-<id>.<ext>`.
|
|
23
|
+
* `<id>` — kebab без leading/trailing-`-`, починається/закінчується на `[a-z0-9]`.
|
|
26
24
|
*/
|
|
27
|
-
const
|
|
28
|
-
|
|
25
|
+
const CONN_FILENAME_QL_RE = /^ql-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.[cm]?[jt]sx?$/u
|
|
26
|
+
/**
|
|
27
|
+
* Канонічний шаблон імені файла БД-підключення: `(pg|mysql|mssql)-(read|write)(-<id>)?.<ext>`.
|
|
28
|
+
* `<id>` — за тими ж правилами, що й для `ql-`. Розділили з GraphQL-формою, щоб
|
|
29
|
+
* не множити комплексність regex (sonarjs/regex-complexity).
|
|
30
|
+
*/
|
|
31
|
+
const CONN_FILENAME_DB_RE = /^(?:pg|mysql|mssql)-(?:read|write)(?:-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?\.[cm]?[jt]sx?$/u
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Чи це файл, який сканується правилом «conn-file» (JS/TS-сімʼя, без `.d.ts`).
|
|
@@ -43,7 +46,7 @@ export function isConnFileRulesSourceFile(relativePathPosix) {
|
|
|
43
46
|
*/
|
|
44
47
|
function basenameNoExt(relativePathPosix) {
|
|
45
48
|
const last = relativePathPosix.lastIndexOf('/')
|
|
46
|
-
const base = last
|
|
49
|
+
const base = last === -1 ? relativePathPosix : relativePathPosix.slice(last + 1)
|
|
47
50
|
const dot = base.lastIndexOf('.')
|
|
48
51
|
return dot > 0 ? base.slice(0, dot) : base
|
|
49
52
|
}
|
|
@@ -64,8 +67,71 @@ export function kebabToCamel(kebab) {
|
|
|
64
67
|
*/
|
|
65
68
|
export function isConnFileNameValid(relativePathPosix) {
|
|
66
69
|
const last = relativePathPosix.lastIndexOf('/')
|
|
67
|
-
const base = last
|
|
68
|
-
return
|
|
70
|
+
const base = last === -1 ? relativePathPosix : relativePathPosix.slice(last + 1)
|
|
71
|
+
return CONN_FILENAME_QL_RE.test(base) || CONN_FILENAME_DB_RE.test(base)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Витягує імена з `export const/let/var X = …` (включно з кількома declarators у одному `export const a, b`).
|
|
76
|
+
* @param {Record<string, unknown>} decl AST `VariableDeclaration`
|
|
77
|
+
* @returns {string[]} імена змінних
|
|
78
|
+
*/
|
|
79
|
+
function namesFromVariableDeclaration(decl) {
|
|
80
|
+
if (!Array.isArray(decl.declarations)) return []
|
|
81
|
+
/** @type {string[]} */
|
|
82
|
+
const out = []
|
|
83
|
+
for (const d of decl.declarations) {
|
|
84
|
+
const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
|
|
85
|
+
if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Витягує імʼя з `export function X` / `export class X`.
|
|
92
|
+
* @param {Record<string, unknown>} decl AST `FunctionDeclaration` або `ClassDeclaration`
|
|
93
|
+
* @returns {string | null} імʼя або `null`, якщо id-вузол анонімний
|
|
94
|
+
*/
|
|
95
|
+
function nameFromFnOrClassDeclaration(decl) {
|
|
96
|
+
if (decl.type !== 'FunctionDeclaration' && decl.type !== 'ClassDeclaration') return null
|
|
97
|
+
const id = /** @type {Record<string, unknown> | null} */ (decl.id ?? null)
|
|
98
|
+
if (!id || typeof id !== 'object') return null
|
|
99
|
+
return typeof id.name === 'string' ? id.name : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Витягує експортоване імʼя з одного `ExportSpecifier` (`export { X }` / `export { X as Y }`).
|
|
104
|
+
* @param {Record<string, unknown> | null | undefined} specifier AST `ExportSpecifier`
|
|
105
|
+
* @returns {string | null} імʼя або `null`
|
|
106
|
+
*/
|
|
107
|
+
function nameFromExportSpecifier(specifier) {
|
|
108
|
+
const exported = /** @type {Record<string, unknown> | null} */ (specifier?.exported ?? null)
|
|
109
|
+
if (!exported) return null
|
|
110
|
+
if (exported.type === 'Identifier' && typeof exported.name === 'string') return exported.name
|
|
111
|
+
if (typeof exported.value === 'string') return exported.value
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Імена з одного `ExportNamedDeclaration` — або з вкладеного `declaration`, або зі списку `specifiers`.
|
|
117
|
+
* @param {Record<string, unknown>} rec AST `ExportNamedDeclaration`
|
|
118
|
+
* @returns {string[]} імена цього експортного вузла
|
|
119
|
+
*/
|
|
120
|
+
function namesFromNamedExport(rec) {
|
|
121
|
+
const decl = /** @type {Record<string, unknown> | null} */ (rec.declaration ?? null)
|
|
122
|
+
if (decl) {
|
|
123
|
+
if (decl.type === 'VariableDeclaration') return namesFromVariableDeclaration(decl)
|
|
124
|
+
const fnOrClass = nameFromFnOrClassDeclaration(decl)
|
|
125
|
+
return fnOrClass ? [fnOrClass] : []
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(rec.specifiers)) return []
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const out = []
|
|
130
|
+
for (const s of rec.specifiers) {
|
|
131
|
+
const name = nameFromExportSpecifier(/** @type {Record<string, unknown> | null} */ (s ?? null))
|
|
132
|
+
if (name) out.push(name)
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
69
135
|
}
|
|
70
136
|
|
|
71
137
|
/**
|
|
@@ -87,34 +153,7 @@ function collectNamedExportNames(program) {
|
|
|
87
153
|
if (!node || typeof node !== 'object') continue
|
|
88
154
|
const rec = /** @type {Record<string, unknown>} */ (node)
|
|
89
155
|
if (rec.type !== 'ExportNamedDeclaration') continue
|
|
90
|
-
|
|
91
|
-
if (decl) {
|
|
92
|
-
// export const X = ... / export let / export var
|
|
93
|
-
if (decl.type === 'VariableDeclaration' && Array.isArray(decl.declarations)) {
|
|
94
|
-
for (const d of decl.declarations) {
|
|
95
|
-
const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
|
|
96
|
-
if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// export function X / export class X
|
|
100
|
-
if (
|
|
101
|
-
(decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
|
|
102
|
-
decl.id &&
|
|
103
|
-
typeof decl.id === 'object' &&
|
|
104
|
-
typeof (/** @type {Record<string, unknown>} */ (decl.id).name) === 'string'
|
|
105
|
-
) {
|
|
106
|
-
out.push(/** @type {string} */ (/** @type {Record<string, unknown>} */ (decl.id).name))
|
|
107
|
-
}
|
|
108
|
-
} else if (Array.isArray(rec.specifiers)) {
|
|
109
|
-
// export { X } / export { X as Y }
|
|
110
|
-
for (const s of rec.specifiers) {
|
|
111
|
-
const exported = /** @type {Record<string, unknown> | null} */ (s?.exported ?? null)
|
|
112
|
-
if (!exported) continue
|
|
113
|
-
// ESTree: Identifier (name) або Literal (value), залежно від спеки
|
|
114
|
-
if (exported.type === 'Identifier' && typeof exported.name === 'string') out.push(exported.name)
|
|
115
|
-
else if (typeof exported.value === 'string') out.push(exported.value)
|
|
116
|
-
}
|
|
117
|
-
}
|
|
156
|
+
out.push(...namesFromNamedExport(rec))
|
|
118
157
|
}
|
|
119
158
|
return out
|
|
120
159
|
}
|
|
@@ -23,8 +23,31 @@ import { flattenWorkflowSteps, getStepRun, parseWorkflowYaml } from './gha-workf
|
|
|
23
23
|
|
|
24
24
|
const WORKFLOWS_DIR_REL = '.github/workflows'
|
|
25
25
|
const REQUIRED_IGNORES = ['graphql', 'bun']
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// `npx depcheck` як ціла команда у одному рядку shell-скрипту.
|
|
27
|
+
// `[^\n]*` обмежено явним `\n`-stop'ом — `*` не може backtrack-нутися за межі рядка.
|
|
28
|
+
const DEPCHECK_RUN_RE = /(?:^|[\s;&|])npx[ \t]+depcheck\b([^\n]*)/u
|
|
29
|
+
// `--ignores=…` або `--ignores …` з трьома формами значення (двійкові, одинарні, без лапок).
|
|
30
|
+
// Розділювач — або `=` з опційними пробілами, або один+ пробіл. Альтернативи значення
|
|
31
|
+
// не перетинаються (стартують з різних символів), тож backtrack-у між ними нема.
|
|
32
|
+
const IGNORES_FLAG_RE = /--ignores(?:=[ \t]*|[ \t]+)(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Нормалізує шлях: бекслеші → forward, обрізає trailing-слеші. Без regex-у на trailing,
|
|
36
|
+
* щоб не тригерити `sonarjs/slow-regex` на `\/+$`.
|
|
37
|
+
* @param {string} p вхідний шлях
|
|
38
|
+
* @returns {string} нормалізований шлях
|
|
39
|
+
*/
|
|
40
|
+
function normalizePath(p) {
|
|
41
|
+
let end = p.length
|
|
42
|
+
while (end > 0) {
|
|
43
|
+
const cp = p.codePointAt(end - 1)
|
|
44
|
+
if (cp !== 47 && cp !== 92) break
|
|
45
|
+
end--
|
|
46
|
+
}
|
|
47
|
+
let out = end === p.length ? p : p.slice(0, end)
|
|
48
|
+
if (out.includes('\\')) out = out.replaceAll('\\', '/')
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
28
51
|
|
|
29
52
|
/**
|
|
30
53
|
* Чи містить workflow.on[event].paths хоча б один patten, що починається з `<pkgRoot>/`.
|
|
@@ -33,7 +56,7 @@ const IGNORES_FLAG_RE = /--ignores\s*=?\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
|
|
|
33
56
|
* @returns {boolean} `true`, якщо знайдено хоча б один підходящий glob
|
|
34
57
|
*/
|
|
35
58
|
export function workflowHasPathsScopedToPackage(root, pkgRoot) {
|
|
36
|
-
const prefix = `${pkgRoot
|
|
59
|
+
const prefix = `${normalizePath(pkgRoot)}/`
|
|
37
60
|
const on = root?.on
|
|
38
61
|
if (!on || typeof on !== 'object') return false
|
|
39
62
|
for (const event of /** @type {const} */ (['push', 'pull_request'])) {
|
|
@@ -81,9 +104,7 @@ export function extractDepcheckArgs(runText) {
|
|
|
81
104
|
export function stepWorkingDirectoryEquals(step, pkgRoot) {
|
|
82
105
|
const wd = step['working-directory']
|
|
83
106
|
if (typeof wd !== 'string') return false
|
|
84
|
-
|
|
85
|
-
const expected = pkgRoot.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
86
|
-
return norm === expected
|
|
107
|
+
return normalizePath(wd) === normalizePath(pkgRoot)
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
/**
|
|
@@ -19,7 +19,14 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { parseSync } from 'oxc-parser'
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
dynamicImportModule,
|
|
24
|
+
langFromPath,
|
|
25
|
+
normalizeSnippet,
|
|
26
|
+
offsetToLine,
|
|
27
|
+
requireCallModule,
|
|
28
|
+
walkAstWithAncestors
|
|
29
|
+
} from './ast-scan-utils.mjs'
|
|
23
30
|
|
|
24
31
|
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
25
32
|
const FORBIDDEN_MODULE_NAMES = new Set([
|
|
@@ -48,55 +55,6 @@ function isForbiddenRedisModule(mod) {
|
|
|
48
55
|
return mod.startsWith('ioredis/') || mod.startsWith('redis/') || mod.startsWith('@redis/')
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
/**
|
|
52
|
-
* Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
|
|
53
|
-
* @param {Record<string, unknown> | null | undefined} node вузол AST
|
|
54
|
-
* @returns {string | null} ім'я модуля з аргументу, інакше `null`
|
|
55
|
-
*/
|
|
56
|
-
function requireCallModule(node) {
|
|
57
|
-
if (!node || node.type !== 'CallExpression') return null
|
|
58
|
-
const callee = node.callee
|
|
59
|
-
if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
|
|
60
|
-
const arg = node.arguments?.[0]
|
|
61
|
-
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
|
|
62
|
-
return arg.value
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
|
|
67
|
-
* @param {Record<string, unknown> | null | undefined} node вузол AST
|
|
68
|
-
* @returns {string | null} ім'я модуля, інакше `null`
|
|
69
|
-
*/
|
|
70
|
-
function dynamicImportModule(node) {
|
|
71
|
-
if (!node || node.type !== 'ImportExpression') return null
|
|
72
|
-
const src = node.source
|
|
73
|
-
if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
|
|
74
|
-
return src.value
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Простий рекурсивний обхід AST: заходимо в усі обʼєкти/масиви, щоб знайти require/import-вузли.
|
|
79
|
-
* @param {unknown} node корінь або під-вузол AST
|
|
80
|
-
* @param {(n: unknown) => void} visit виклик для кожного обʼєкта-вузла
|
|
81
|
-
* @returns {void}
|
|
82
|
-
*/
|
|
83
|
-
function walkAst(node, visit) {
|
|
84
|
-
if (!node || typeof node !== 'object') return
|
|
85
|
-
if (Array.isArray(node)) {
|
|
86
|
-
for (const item of node) walkAst(item, visit)
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
if (typeof node.type === 'string') {
|
|
90
|
-
visit(node)
|
|
91
|
-
}
|
|
92
|
-
for (const key of Object.keys(node)) {
|
|
93
|
-
if (key !== 'parent') {
|
|
94
|
-
const v = node[key]
|
|
95
|
-
if (v && typeof v === 'object') walkAst(v, visit)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
58
|
/**
|
|
101
59
|
* Знаходить заборонені імпорти/require з `ioredis` / `node-redis` у тексті.
|
|
102
60
|
* @param {string} content вихідний код
|
|
@@ -130,7 +88,7 @@ export function findRedisImportsInText(content, virtualPath = 'scan.ts') {
|
|
|
130
88
|
}
|
|
131
89
|
}
|
|
132
90
|
|
|
133
|
-
|
|
91
|
+
walkAstWithAncestors(result.program, [], node => {
|
|
134
92
|
const reqMod = requireCallModule(node)
|
|
135
93
|
if (reqMod && isForbiddenRedisModule(reqMod)) {
|
|
136
94
|
out.push({
|
|
@@ -5,6 +5,10 @@ description: >-
|
|
|
5
5
|
read-only аналіз CWD без жодних змін у поточному репо
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
<!-- markdownlint-disable-file MD024 MD025 -->
|
|
9
|
+
<!-- Файл демонструє шаблон промпта з кількома H1 (`# Завдання`, `# Релевантні файли` тощо)
|
|
10
|
+
— це інтенціональна частина showcase, а не порушення one-title-per-document. -->
|
|
11
|
+
|
|
8
12
|
# Підготовка LLM-патчу (текстова комунікація між агентами)
|
|
9
13
|
|
|
10
14
|
Скіл готує **самодостатній текстовий промпт** ("патч") для іншої LLM-сесії
|
|
@@ -83,16 +87,16 @@ description: >-
|
|
|
83
87
|
- Документи правил: <CLAUDE.md / .cursor/rules — або "немає">
|
|
84
88
|
|
|
85
89
|
## Структура (skim)
|
|
86
|
-
|
|
87
90
|
```
|
|
91
|
+
|
|
88
92
|
<вивід tree -L 2>
|
|
89
|
-
|
|
93
|
+
````
|
|
90
94
|
|
|
91
95
|
# Релевантні файли
|
|
92
96
|
|
|
93
97
|
## `package.json`
|
|
94
98
|
|
|
95
|
-
```
|
|
99
|
+
```text
|
|
96
100
|
<повний вміст або ключові поля>
|
|
97
101
|
```
|
|
98
102
|
|
|
@@ -119,7 +123,11 @@ description: >-
|
|
|
119
123
|
- `<команда з scripts — npm test / bun test / lint>`
|
|
120
124
|
- <конкретні acceptance-checks: "у `engines.node` має бути `>=25`",
|
|
121
125
|
"CI зелений" тощо>
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
|
|
122
129
|
```
|
|
130
|
+
|
|
123
131
|
````
|
|
124
132
|
|
|
125
133
|
## Правила
|
|
@@ -158,7 +166,8 @@ description: >-
|
|
|
158
166
|
Очікуваний вивід (схематично):
|
|
159
167
|
|
|
160
168
|
````
|
|
161
|
-
|
|
169
|
+
|
|
170
|
+
````markdown
|
|
162
171
|
# Завдання
|
|
163
172
|
|
|
164
173
|
Підняти `engines.node` у `@nitra/eslint-config` до `>=25` і переглянути
|
|
@@ -182,6 +191,7 @@ description: >-
|
|
|
182
191
|
```json
|
|
183
192
|
{ "engines": { "node": ">=22" }, "peerDependencies": { "eslint": "^9" } }
|
|
184
193
|
```
|
|
194
|
+
````
|
|
185
195
|
|
|
186
196
|
# Що треба зробити
|
|
187
197
|
|
|
@@ -193,6 +203,7 @@ description: >-
|
|
|
193
203
|
|
|
194
204
|
- `bun test`
|
|
195
205
|
- `node -v` у CI ≥ 25
|
|
206
|
+
|
|
196
207
|
```
|
|
197
208
|
готово до копіювання — встав у чат з агентом у цільовому проєкті
|
|
198
|
-
|
|
209
|
+
```
|