@nitra/cursor 1.9.23 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-template/hooks/capture-decisions.sh +3 -3
- package/.claude-template/hooks/normalize-decisions.sh +370 -0
- package/CHANGELOG.md +52 -0
- package/bin/n-cursor.js +30 -29
- package/package.json +2 -1
- package/rules/abie/js/applies/check.mjs +24 -0
- package/rules/abie/js/env_dns/check.mjs +53 -0
- package/rules/abie/js/firebase_hosting/check.mjs +49 -0
- package/rules/abie/js/hc_pairing/check.mjs +58 -0
- package/rules/abie/js/ua_http_route/check.mjs +86 -0
- package/rules/abie/js/ua_node_selector/check.mjs +65 -0
- package/rules/abie/policy/base_deployment_preem/target.json +10 -0
- package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
- package/rules/abie/policy/health_check_policy/target.json +4 -0
- package/rules/abie/policy/http_route_base/target.json +4 -0
- package/rules/abie/utils/enabled.mjs +35 -0
- package/rules/abie/utils/env-dns.mjs +81 -0
- package/rules/abie/utils/hc-yaml.mjs +27 -0
- package/rules/abie/utils/http-route.mjs +93 -0
- package/rules/abie/utils/k8s-tree.mjs +102 -0
- package/rules/abie/utils/kustomization-patches.mjs +224 -0
- package/rules/abie/utils/overlay-paths.mjs +97 -0
- package/rules/abie/utils/yaml.mjs +72 -0
- package/rules/adr/adr.mdc +82 -18
- package/rules/adr/js/check.mjs +84 -40
- package/rules/adr/policy/settings_json/settings_json.rego +17 -11
- package/rules/adr/policy/settings_json/target.json +4 -0
- package/rules/adr/policy/settings_local_json/settings_local_json.rego +24 -12
- package/rules/adr/policy/settings_local_json/target.json +4 -0
- package/rules/bun/policy/bunfig/target.json +4 -0
- package/rules/bun/policy/package_json/target.json +4 -0
- package/rules/capacitor/policy/package_json/target.json +4 -0
- package/rules/docker/policy/lint_docker_yml/target.json +4 -0
- package/rules/docker/policy/package_json/target.json +4 -0
- package/rules/hasura/policy/svc_hl/target.json +4 -0
- package/rules/image-avif/policy/package_json/target.json +4 -0
- package/rules/image-compress/policy/package_json/target.json +4 -0
- package/rules/js-bun-db/policy/package_json/target.json +4 -0
- package/rules/js-bun-redis/policy/package_json/target.json +4 -0
- package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
- package/rules/js-lint/policy/package_json/target.json +4 -0
- package/rules/js-mssql/policy/package_json/target.json +4 -0
- package/rules/js-run/policy/configmap/target.json +4 -0
- package/rules/js-run/policy/package_json/target.json +4 -0
- package/rules/k8s/policy/base_kustomization/target.json +4 -0
- package/rules/k8s/policy/base_manifest/target.json +10 -0
- package/rules/k8s/policy/gateway/target.json +4 -0
- package/rules/k8s/policy/hpa_pdb/target.json +4 -0
- package/rules/k8s/policy/kustomization/target.json +4 -0
- package/rules/k8s/policy/manifest/target.json +4 -0
- package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
- package/rules/k8s/policy/svc_yaml/target.json +4 -0
- package/rules/npm-module/policy/emit_types_config/target.json +4 -0
- package/rules/npm-module/policy/npm_package_json/target.json +4 -0
- package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
- package/rules/npm-module/policy/root_package_json/target.json +4 -0
- package/rules/php/policy/lint_php_yml/target.json +4 -0
- package/rules/php/policy/package_json/target.json +4 -0
- package/rules/rego/js/applies/check.mjs +54 -0
- package/rules/rego/policy/package_json/target.json +5 -0
- package/rules/rego/policy/vscode_extensions/target.json +5 -0
- package/rules/rego/policy/vscode_settings/target.json +5 -0
- package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
- package/rules/style-lint/policy/package_json/target.json +4 -0
- package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
- package/rules/style-lint/policy/vscode_settings/target.json +4 -0
- package/rules/text/policy/cspell/target.json +4 -0
- package/rules/text/policy/markdownlint/target.json +4 -0
- package/rules/text/policy/oxfmtrc/target.json +4 -0
- package/rules/text/policy/package_json/target.json +4 -0
- package/rules/text/policy/vscode_extensions/target.json +4 -0
- package/rules/text/policy/vscode_settings/target.json +4 -0
- package/rules/vue/policy/package_json/target.json +4 -0
- package/schemas/target.json +58 -0
- package/scripts/auto-skills.mjs +2 -0
- package/scripts/lint-conftest.mjs +65 -414
- package/scripts/sync-claude-config.mjs +70 -14
- package/scripts/utils/discover-checkable-rules.mjs +123 -0
- package/scripts/utils/resolve-target-files.mjs +109 -0
- package/scripts/utils/run-rule.mjs +131 -0
- package/skills/adr-normalize/SKILL.md +71 -0
- package/skills/adr-normalize/auto.md +1 -0
- package/rules/abie/js/check.mjs +0 -1152
- package/rules/rego/js/check.mjs +0 -106
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Обхід k8s-дерева abie з кешуванням на час одного прогону:
|
|
3
|
+
* - `findK8sYamlFiles(root, ignorePaths)` — yaml/yml файли під сегментом `k8s/`.
|
|
4
|
+
* - `collectDeploymentDirs(root, yamlAbs)` — каталоги, де знайдено `kind: Deployment`.
|
|
5
|
+
*
|
|
6
|
+
* Кеш — module-level singleton, ключований за `(root, ignorePaths)`. Перший виклик
|
|
7
|
+
* платить за обхід; наступні концерни в межах того ж прогону отримують готове.
|
|
8
|
+
* Для тестів — `resetAbieK8sTreeCache()` (інакше withTmpCwd-фікстури злипатимуться).
|
|
9
|
+
*/
|
|
10
|
+
import { dirname, relative } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { pathHasK8sSegment } from '../../k8s/js/check.mjs'
|
|
13
|
+
import { walkDir } from '../../../scripts/utils/walkDir.mjs'
|
|
14
|
+
import { isDeploymentDoc, readAndParseYamlDocs } from './yaml.mjs'
|
|
15
|
+
|
|
16
|
+
const YAML_EXTENSION_RE = /\.ya?ml$/iu
|
|
17
|
+
|
|
18
|
+
/** @type {Map<string, Promise<string[]>>} */
|
|
19
|
+
const yamlCache = new Map()
|
|
20
|
+
/** @type {Map<string, Promise<Set<string>>>} */
|
|
21
|
+
const deploymentCache = new Map()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Скидає кеш — тести мусять викликати між фікстурами.
|
|
25
|
+
* @returns {void}
|
|
26
|
+
*/
|
|
27
|
+
export function resetAbieK8sTreeCache() {
|
|
28
|
+
yamlCache.clear()
|
|
29
|
+
deploymentCache.clear()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Стабільний ключ кешу за (root, ignorePaths).
|
|
34
|
+
* @param {string} root
|
|
35
|
+
* @param {string[]} ignorePaths
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function cacheKey(root, ignorePaths) {
|
|
39
|
+
return `${root}|${[...ignorePaths].toSorted((a, b) => a.localeCompare(b)).join(':')}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Збирає абсолютні шляхи до `.yaml`/`.yml` під деревом, де є сегмент `k8s/`.
|
|
44
|
+
* Каталог `.github/` свідомо пропускається (належить `ga.mdc`).
|
|
45
|
+
* @param {string} root корінь репозиторію
|
|
46
|
+
* @param {string[]} [ignorePaths] абсолютні шляхи каталогів-виключень
|
|
47
|
+
* @returns {Promise<string[]>}
|
|
48
|
+
*/
|
|
49
|
+
export function findK8sYamlFiles(root, ignorePaths = []) {
|
|
50
|
+
const key = cacheKey(root, ignorePaths)
|
|
51
|
+
const cached = yamlCache.get(key)
|
|
52
|
+
if (cached) return cached
|
|
53
|
+
const promise = (async () => {
|
|
54
|
+
/** @type {string[]} */
|
|
55
|
+
const out = []
|
|
56
|
+
await walkDir(
|
|
57
|
+
root,
|
|
58
|
+
p => {
|
|
59
|
+
const rel = relative(root, p).replaceAll('\\', '/')
|
|
60
|
+
if (rel.startsWith('.github/')) return
|
|
61
|
+
if (!pathHasK8sSegment(p, root)) return
|
|
62
|
+
if (!YAML_EXTENSION_RE.test(p)) return
|
|
63
|
+
out.push(p)
|
|
64
|
+
},
|
|
65
|
+
ignorePaths
|
|
66
|
+
)
|
|
67
|
+
return [...out].toSorted((a, b) => a.localeCompare(b))
|
|
68
|
+
})()
|
|
69
|
+
yamlCache.set(key, promise)
|
|
70
|
+
return promise
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Каталоги, де є хоча б один `kind: Deployment` у YAML під `k8s/`.
|
|
75
|
+
* @param {string} root корінь репозиторію
|
|
76
|
+
* @param {string[]} yamlAbs абсолютні шляхи `.yaml`/`.yml` під `k8s/` (як з `findK8sYamlFiles`)
|
|
77
|
+
* @param {(msg: string) => void} [fail] репортер помилок парсингу (опц.)
|
|
78
|
+
* @returns {Promise<Set<string>>} абсолютні шляхи директорій
|
|
79
|
+
*/
|
|
80
|
+
export function collectDeploymentDirs(root, yamlAbs, fail = () => {}) {
|
|
81
|
+
const key = `${root}|${[...yamlAbs].toSorted((a, b) => a.localeCompare(b)).join(':')}`
|
|
82
|
+
const cached = deploymentCache.get(key)
|
|
83
|
+
if (cached) return cached
|
|
84
|
+
const promise = (async () => {
|
|
85
|
+
/** @type {Set<string>} */
|
|
86
|
+
const dirs = new Set()
|
|
87
|
+
for (const abs of yamlAbs) {
|
|
88
|
+
const rel = relative(root, abs).replaceAll('\\', '/') || abs
|
|
89
|
+
const docs = await readAndParseYamlDocs(abs, rel, fail)
|
|
90
|
+
if (docs) {
|
|
91
|
+
for (const doc of docs) {
|
|
92
|
+
if (doc.errors.length === 0 && isDeploymentDoc(doc.toJSON())) {
|
|
93
|
+
dirs.add(dirname(abs))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return dirs
|
|
99
|
+
})()
|
|
100
|
+
deploymentCache.set(key, promise)
|
|
101
|
+
return promise
|
|
102
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Парсинг inline JSON6902-патчів у abie ua-kustomization:
|
|
3
|
+
* - **nodeSelector** patch на `Deployment` (preem: false);
|
|
4
|
+
* - **HTTPRoute** patch (hostnames, parentRefs namespace, backendRefs namespace).
|
|
5
|
+
*
|
|
6
|
+
* Regex використовуються, бо `patch:` — це YAML-string з вкладеним JSON6902, який ми не парсимо
|
|
7
|
+
* вдруге; підрядки на кшталт `path: /spec/hostnames` і `value: ua` достатньо інформативні.
|
|
8
|
+
*/
|
|
9
|
+
import { parseAllDocuments } from 'yaml'
|
|
10
|
+
|
|
11
|
+
import { LINE_SPLIT_RE, MODELINE_RE, stripBom } from './yaml.mjs'
|
|
12
|
+
|
|
13
|
+
const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
|
|
14
|
+
const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
|
|
15
|
+
const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
|
|
16
|
+
// Overlay namespaces: дозволено `ua` і `ua-*` (наприклад `ua-b2b`).
|
|
17
|
+
const PATCH_PARENT_REF_NS_UA_RE =
|
|
18
|
+
/path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
|
|
19
|
+
|
|
20
|
+
/** Домени `hostnames` для overlay `ua` (підрядки у JSON6902-тексті patch). */
|
|
21
|
+
const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
|
|
22
|
+
|
|
23
|
+
// ── nodeSelector (ua) ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Чи patch-рядок містить очікуваний ua nodeSelector (preem: false).
|
|
27
|
+
* @param {string} patchText
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
|
|
31
|
+
if (typeof patchText !== 'string' || patchText.trim() === '') return false
|
|
32
|
+
if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) return false
|
|
33
|
+
if (!PATCH_PREEM_FALSE_RE.test(patchText)) return false
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Чи один елемент `patches` відповідає abie nodeSelector для `mode`.
|
|
39
|
+
* @param {unknown} p
|
|
40
|
+
* @param {'ua'} mode
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function inlineKustomizationPatchMatchesAbieMode(p, mode) {
|
|
44
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) return false
|
|
45
|
+
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
46
|
+
const target = pr.target
|
|
47
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) return false
|
|
48
|
+
const tg = /** @type {Record<string, unknown>} */ (target)
|
|
49
|
+
if (tg.kind !== 'Deployment') return false
|
|
50
|
+
const patchStr = pr.patch
|
|
51
|
+
if (typeof patchStr !== 'string') return false
|
|
52
|
+
if (mode === 'ua' && jsonPatchTextHasUaDeploymentNodeSelector(patchStr)) return true
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Чи документ Kustomization містить відповідний inline patch на Deployment.
|
|
58
|
+
* @param {import('yaml').Document} doc
|
|
59
|
+
* @param {'ua'} mode
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
|
|
63
|
+
if (doc.errors.length > 0) return false
|
|
64
|
+
const root = doc.toJSON()
|
|
65
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) return false
|
|
66
|
+
const rec = /** @type {Record<string, unknown>} */ (root)
|
|
67
|
+
if (rec.kind !== 'Kustomization') return false
|
|
68
|
+
const patches = rec.patches
|
|
69
|
+
if (!Array.isArray(patches)) return false
|
|
70
|
+
for (const p of patches) {
|
|
71
|
+
if (inlineKustomizationPatchMatchesAbieMode(p, mode)) return true
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Чи `kustomization.yaml` містить валідні inline patch для Deployment nodeSelector (ua).
|
|
78
|
+
* @param {string} raw повний текст файла
|
|
79
|
+
* @param {'ua'} mode
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
|
|
83
|
+
const body = stripBom(raw)
|
|
84
|
+
const lines = body.split(LINE_SPLIT_RE)
|
|
85
|
+
const first = lines[0] ?? ''
|
|
86
|
+
const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
|
|
87
|
+
/** @type {import('yaml').Document[]} */
|
|
88
|
+
let docs
|
|
89
|
+
try {
|
|
90
|
+
docs = parseAllDocuments(rest)
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
for (const doc of docs) {
|
|
95
|
+
if (kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode)) return true
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── HTTPRoute (ua) ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {unknown} p
|
|
104
|
+
* @returns {string | null}
|
|
105
|
+
*/
|
|
106
|
+
function extractHttpRoutePatchString(p) {
|
|
107
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
|
|
108
|
+
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
109
|
+
const target = pr.target
|
|
110
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
|
|
111
|
+
const tg = /** @type {Record<string, unknown>} */ (target)
|
|
112
|
+
if (tg.kind !== 'HTTPRoute' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
|
|
113
|
+
const patchStr = pr.patch
|
|
114
|
+
return typeof patchStr === 'string' && patchStr.trim() !== '' ? patchStr : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Збирає inline `patch`-рядки для HTTPRoute (непорожній `target.name`) з одного Kustomization-документа.
|
|
119
|
+
* @param {import('yaml').Document} doc
|
|
120
|
+
* @returns {string[]}
|
|
121
|
+
*/
|
|
122
|
+
function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
|
|
123
|
+
if (doc.errors.length > 0) return []
|
|
124
|
+
const root = doc.toJSON()
|
|
125
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
|
|
126
|
+
const rec = /** @type {Record<string, unknown>} */ (root)
|
|
127
|
+
if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const out = []
|
|
130
|
+
for (const p of rec.patches) {
|
|
131
|
+
const s = extractHttpRoutePatchString(p)
|
|
132
|
+
if (s !== null) out.push(s)
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Збирає всі inline JSON6902-фрагменти HTTPRoute (непорожній `target.name`) у kustomization.yaml.
|
|
139
|
+
* @param {string} raw повний текст файла
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
export function getCombinedNginxRunPatchTextFromKustomization(raw) {
|
|
143
|
+
const body = stripBom(raw)
|
|
144
|
+
const lines = body.split(LINE_SPLIT_RE)
|
|
145
|
+
const first = lines[0] ?? ''
|
|
146
|
+
const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
|
|
147
|
+
/** @type {import('yaml').Document[]} */
|
|
148
|
+
let docs
|
|
149
|
+
try {
|
|
150
|
+
docs = parseAllDocuments(rest)
|
|
151
|
+
} catch {
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
const chunks = []
|
|
156
|
+
for (const doc of docs) {
|
|
157
|
+
chunks.push(...collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc))
|
|
158
|
+
}
|
|
159
|
+
return chunks.join('\n')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Рахує операції JSON6902 з `path: /spec/rules/.../backendRefs/.../namespace` і `value: ua[-…]`.
|
|
164
|
+
* @param {string} combined сукупний текст patch
|
|
165
|
+
* @param {'ua'} mode
|
|
166
|
+
* @returns {number}
|
|
167
|
+
*/
|
|
168
|
+
function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
|
|
169
|
+
if (mode !== 'ua') return 0
|
|
170
|
+
const re =
|
|
171
|
+
/path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
|
|
172
|
+
return [...combined.matchAll(re)].length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Перевіряє сукупний текст patch(ів) HTTPRoute на відповідність abie.mdc.
|
|
177
|
+
* @param {string} combined сукупний текст patch
|
|
178
|
+
* @param {'ua'} mode
|
|
179
|
+
* @param {string} [_fullKustomizationRaw] зберігається для API-сумісності, не використовується
|
|
180
|
+
* @param {number} [sharedCrossNsBackendRefCount] кількість `auth-run-hl`/`file-link-hl` у base HTTPRoute
|
|
181
|
+
* @returns {string | null} повідомлення про помилку або null
|
|
182
|
+
*/
|
|
183
|
+
export function validateAbieNginxRunHttpRoutePatches(
|
|
184
|
+
combined,
|
|
185
|
+
mode,
|
|
186
|
+
_fullKustomizationRaw,
|
|
187
|
+
sharedCrossNsBackendRefCount = 0
|
|
188
|
+
) {
|
|
189
|
+
if (typeof combined !== 'string' || combined.trim() === '') {
|
|
190
|
+
return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}) — abie.mdc`
|
|
191
|
+
}
|
|
192
|
+
if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
|
|
193
|
+
return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
|
|
194
|
+
}
|
|
195
|
+
const markers = ABIE_UA_HTTPROUTE_HOST_MARKERS
|
|
196
|
+
if (!markers.some(m => combined.includes(m))) {
|
|
197
|
+
return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
|
|
198
|
+
}
|
|
199
|
+
if (!PATCH_PARENT_REF_NS_UA_RE.test(combined)) {
|
|
200
|
+
return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
|
|
201
|
+
}
|
|
202
|
+
const sharedCount =
|
|
203
|
+
typeof sharedCrossNsBackendRefCount === 'number' && Number.isFinite(sharedCrossNsBackendRefCount)
|
|
204
|
+
? Math.max(0, Math.floor(sharedCrossNsBackendRefCount))
|
|
205
|
+
: 0
|
|
206
|
+
if (sharedCount > 0) {
|
|
207
|
+
const patchHits = countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode)
|
|
208
|
+
if (patchHits < sharedCount) {
|
|
209
|
+
return `HTTPRoute: для backendRefs до спільних сервісів auth-run-hl, file-link-hl очікується ${sharedCount} JSON6902 patch(ів) з path /spec/rules/…/backendRefs/…/namespace та value ${mode} (зараз ${patchHits}) — abie.mdc`
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Чи kustomization містить валідні patch для HTTPRoute (ua).
|
|
217
|
+
* @param {string} raw повний текст kustomization.yaml
|
|
218
|
+
* @param {'ua'} mode
|
|
219
|
+
* @returns {boolean}
|
|
220
|
+
*/
|
|
221
|
+
export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
|
|
222
|
+
const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
|
|
223
|
+
return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
|
|
224
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-хелпери для overlay-перевірок abie:
|
|
3
|
+
* - класифікація шляхів (`isUaKustomizationPath`, `isAbieK8sBaseYamlPath`),
|
|
4
|
+
* - вилучення каталогу пакета з overlay-шляху (`abiePackageDirFromK8sOverlay`),
|
|
5
|
+
* - умовний gate для HTTPRoute через наявність `vite.config.*` (`abieOverlayRequiresHttpRouteByVite`),
|
|
6
|
+
* - перевірка наявності `Deployment` у дереві пакета (`abieOverlayK8sTreeHasDeployment`),
|
|
7
|
+
* - чи yaml належить base-шару пакета (`isK8sYamlInAbiePackageExcludingUaOverlay`).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { join, relative } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
|
|
13
|
+
const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/ua\/kustomization\.yaml$/u
|
|
14
|
+
const BASE_SEGMENT_RE = /(^|\/)base\//u
|
|
15
|
+
const TRAILING_SLASH_RE = /\/$/u
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Чи `rel` — це `…/ua/kustomization.yaml` (abie overlay).
|
|
19
|
+
* @param {string} rel посі від кореня репозиторію
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isUaKustomizationPath(rel) {
|
|
23
|
+
const norm = rel.replaceAll('\\', '/')
|
|
24
|
+
return UA_KUSTOMIZATION_PATH_RE.test(norm)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Каталог пакета (батько `k8s/`) для overlay `…/k8s/ua/kustomization.yaml`.
|
|
29
|
+
* @param {string} root корінь репозиторію
|
|
30
|
+
* @param {string} kustomizationAbs абсолютний шлях до ua kustomization
|
|
31
|
+
* @returns {string | null}
|
|
32
|
+
*/
|
|
33
|
+
export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
|
|
34
|
+
const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
|
|
35
|
+
const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
|
|
36
|
+
return m ? join(root, m[1]) : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Чи у каталозі пакета (батько `k8s/`) є `vite.config.{js,mjs,ts}` — HTTPRoute-вимога abie
|
|
41
|
+
* застосовується лише до Vite-пакетів.
|
|
42
|
+
* @param {string} root корінь репозиторію
|
|
43
|
+
* @param {string} kustomizationAbs абсолютний шлях до ua kustomization
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function abieOverlayRequiresHttpRouteByVite(root, kustomizationAbs) {
|
|
47
|
+
const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
|
|
48
|
+
if (!pkg) return false
|
|
49
|
+
return (
|
|
50
|
+
existsSync(join(pkg, 'vite.config.js')) ||
|
|
51
|
+
existsSync(join(pkg, 'vite.config.mjs')) ||
|
|
52
|
+
existsSync(join(pkg, 'vite.config.ts'))
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Чи у дереві `k8s/` пакета є `Deployment` (за каталогами з `collectDeploymentDirs`).
|
|
58
|
+
* @param {Set<string>} deploymentDirs абсолютні каталоги з Deployment
|
|
59
|
+
* @param {string} root корінь репозиторію
|
|
60
|
+
* @param {string} kustomizationAbs абсолютний шлях до ua kustomization
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizationAbs) {
|
|
64
|
+
const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
|
|
65
|
+
if (!pkg) return false
|
|
66
|
+
const k8sRoot = join(pkg, 'k8s').replaceAll('\\', '/')
|
|
67
|
+
for (const dir of deploymentDirs) {
|
|
68
|
+
const norm = dir.replaceAll('\\', '/')
|
|
69
|
+
if (norm === k8sRoot || norm.startsWith(`${k8sRoot}/`)) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Чи rel-шлях `…/k8s/base/…` (base-шар abie, не overlay).
|
|
76
|
+
* @param {string} rel
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function isAbieK8sBaseYamlPath(rel) {
|
|
80
|
+
const norm = rel.replaceAll('\\', '/')
|
|
81
|
+
return BASE_SEGMENT_RE.test(norm)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Чи yaml належить до `<pkgRel>/k8s/**` поза `ua/` піддеревом (base-шар abie).
|
|
86
|
+
* @param {string} relFromRoot шлях від кореня
|
|
87
|
+
* @param {string} pkgRelFromRoot каталог пакета від кореня
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isK8sYamlInAbiePackageExcludingUaOverlay(relFromRoot, pkgRelFromRoot) {
|
|
91
|
+
const normRel = relFromRoot.replaceAll('\\', '/')
|
|
92
|
+
const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
|
|
93
|
+
const prefix = `${pkg}/k8s/`
|
|
94
|
+
if (!normRel.startsWith(prefix)) return false
|
|
95
|
+
const after = normRel.slice(prefix.length)
|
|
96
|
+
return !after.startsWith('ua/')
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Спільні YAML-хелпери для abie-перевірок: парсинг документів з опційним modeline,
|
|
3
|
+
* BOM-strip, regex для `# yaml-language-server: $schema=` і поділу на рядки.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
|
|
7
|
+
import { parseAllDocuments } from 'yaml'
|
|
8
|
+
|
|
9
|
+
export const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
|
|
10
|
+
export const LINE_SPLIT_RE = /\r?\n/u
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Прибирає BOM на початку файлу.
|
|
14
|
+
* @param {string} s вміст
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function stripBom(s) {
|
|
18
|
+
return s.startsWith('') ? s.slice(1) : s
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Чи YAML-документ — це `kind: Deployment`.
|
|
23
|
+
* @param {unknown} obj корінь YAML-документа
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isDeploymentDoc(obj) {
|
|
27
|
+
return (
|
|
28
|
+
obj !== null &&
|
|
29
|
+
typeof obj === 'object' &&
|
|
30
|
+
!Array.isArray(obj) &&
|
|
31
|
+
/** @type {Record<string, unknown>} */ (obj).kind === 'Deployment'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* No-op fail-handler для функцій, що мовчки повертають null/[] при помилці парсингу.
|
|
37
|
+
* @param {string} _msg ігнорується
|
|
38
|
+
*/
|
|
39
|
+
export const silentFail = _msg => {
|
|
40
|
+
/* silent — пошкоджені файли ловить check-k8s */
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Зчитує і парсить YAML-документи з файлу. BOM і modeline (перший рядок `$schema`)
|
|
45
|
+
* автоматично прибираються перед `parseAllDocuments`. При помилці читання/парсингу
|
|
46
|
+
* викликає `failFn` і повертає `null`.
|
|
47
|
+
* @param {string} abs абсолютний шлях
|
|
48
|
+
* @param {string} rel відносний (для повідомлень)
|
|
49
|
+
* @param {(msg: string) => void} failFn
|
|
50
|
+
* @returns {Promise<import('yaml').Document[] | null>}
|
|
51
|
+
*/
|
|
52
|
+
export async function readAndParseYamlDocs(abs, rel, failFn) {
|
|
53
|
+
let raw
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(abs, 'utf8')
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
58
|
+
failFn(`${rel}: не вдалося прочитати (${msg})`)
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
const body = stripBom(raw)
|
|
62
|
+
const lines = body.split(LINE_SPLIT_RE)
|
|
63
|
+
const first = lines[0] ?? ''
|
|
64
|
+
const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
|
|
65
|
+
try {
|
|
66
|
+
return parseAllDocuments(rest)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
69
|
+
failFn(`${rel}: YAML (${msg})`)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
package/rules/adr/adr.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток
|
|
2
|
+
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток і батч-нормалізація у `docs/adr/` через Stop-хук Claude Code
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '
|
|
4
|
+
version: '2.0'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
|
|
@@ -9,37 +9,90 @@ version: '1.0'
|
|
|
9
9
|
Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
|
|
10
10
|
|
|
11
11
|
- копіює канонічний bash-скрипт у **`.claude/hooks/capture-decisions.sh`** (executable, повністю керується пакетом — на кожен sync перезаписується);
|
|
12
|
-
-
|
|
13
|
-
-
|
|
12
|
+
- копіює канонічний bash-скрипт у **`.claude/hooks/normalize-decisions.sh`** (батч-нормалізація чернеток через LLM);
|
|
13
|
+
- додає у **`hooks.Stop`** в **`.claude/settings.json`** дві managed-групи: capture (`async: true`, `timeout: 180`) і normalize (`async: true`, `timeout: 600`);
|
|
14
|
+
- ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); усі три групи живуть поряд у `Stop`.
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
+
## Дві фази, один каталог
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
ADR живуть у єдиному каталозі **`docs/adr/`**. Є два стани файлу, які відрізняються YAML frontmatter:
|
|
19
|
+
|
|
20
|
+
- **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
|
|
21
|
+
- **Clean** — файл без frontmatter, з kebab-case-іменем `<slug>.md` (наприклад `ланцюжок-запуску-abie.md`). Створює `normalize-decisions.sh` або людина руками.
|
|
22
|
+
|
|
23
|
+
`normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
|
|
24
|
+
|
|
25
|
+
### Фаза 1 — Capture
|
|
26
|
+
|
|
27
|
+
Stop-hook `capture-decisions.sh` зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
|
|
28
|
+
|
|
29
|
+
### Фаза 2 — Normalize
|
|
30
|
+
|
|
31
|
+
Stop-hook `normalize-decisions.sh` спрацьовує на тому самому `Stop`-евенті, але:
|
|
32
|
+
|
|
33
|
+
- Виходить миттєво, якщо чернеток (`session:` у frontmatter) менше ніж **`ADR_NORMALIZE_THRESHOLD`** (default 30).
|
|
34
|
+
- Виходить миттєво, якщо від попередньої спроби пройшло менше **`ADR_NORMALIZE_MIN_INTERVAL_HOURS`** годин (default 6) — щоб не крутитися щоразу, коли поріг постійний.
|
|
35
|
+
- Бере не більше **`ADR_NORMALIZE_BATCH`** чернеток (default 30, найстарші за іменем-timestamp), формує один промпт LLM і чекає JSON-відповідь зі списком операцій.
|
|
36
|
+
- Виходить миттєво, якщо репозиторій у стані `MERGE_HEAD` / `rebase-*` — небезпечно правити файли посеред конфлікту.
|
|
37
|
+
- Виходить миттєво, якщо інший normalize-запуск тримає `flock` на `.claude/hooks/.normalize.lock` (тільки де `flock` доступний).
|
|
38
|
+
|
|
39
|
+
LLM повертає масив операцій:
|
|
40
|
+
|
|
41
|
+
| `op` | Семантика | Поля |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `delete` | Чернетка тривіальна / повністю покрита іншим clean-ADR-ом. | `file`, `reason` |
|
|
44
|
+
| `rewrite` | Чернетка стає окремим clean-файлом: frontmatter знімається, ім'я → `<slug>.md`, додаються `**Status: Accepted**` і `**Date:**` з `captured`. | `file`, `slug`, `content` |
|
|
45
|
+
| `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
|
|
46
|
+
|
|
47
|
+
`slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. Колізія slug-ів обробляється детермінованим суфіксом `-2`, `-3`.
|
|
48
|
+
|
|
49
|
+
### Жодних git-операцій
|
|
50
|
+
|
|
51
|
+
`normalize-decisions.sh` **не комітить, не `git add`, нічого з git**. Усі зміни — у робочому дереві. Розробник у зручний момент дивиться `git status` / `git diff` і вирішує: `git add` + commit, `git checkout -- <file>` для відкату, або правки руками. Це і є review-вікно.
|
|
52
|
+
|
|
53
|
+
### Recursion guard і ENV-керування
|
|
54
|
+
|
|
55
|
+
Інший LLM CLI, який запустить normalize, успадковує `ADR_NORMALIZE_RUNNING=1` — внутрішній Stop-hook вийде відразу. Доступні ENV:
|
|
56
|
+
|
|
57
|
+
| Змінна | Default | Призначення |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `ADR_NORMALIZE_THRESHOLD` | `30` | Поріг чернеток для запуску фази. |
|
|
60
|
+
| `ADR_NORMALIZE_BATCH` | `30` | Максимум чернеток у одному виклику LLM. |
|
|
61
|
+
| `ADR_NORMALIZE_MIN_INTERVAL_HOURS` | `6` | Мінімум між спробами (навіть якщо поріг). |
|
|
62
|
+
| `ADR_NORMALIZE_DRY` | `0` | `1` — лише лог запланованих операцій, без змін на диску. |
|
|
63
|
+
| `ADR_NORMALIZE_MODEL` | `sonnet` | Модель для `claude -p`. |
|
|
64
|
+
| `ADR_NORMALIZE_CURSOR_MODEL` | `claude-4.6-sonnet-medium` | Модель для `cursor-agent -p`. |
|
|
65
|
+
|
|
66
|
+
Для ручного запуску (поза порогом і поза Stop-хуком) є **`/n-adr-normalize`** — slash-команда тимчасово виставляє `ADR_NORMALIZE_THRESHOLD=0` і `ADR_NORMALIZE_MIN_INTERVAL_HOURS=0` та викликає скрипт напряму.
|
|
18
67
|
|
|
19
68
|
## LLM CLI: claude → cursor-agent fallback
|
|
20
69
|
|
|
21
|
-
|
|
70
|
+
Обидва скрипти обирають доступний CLI (порядок фіксований):
|
|
22
71
|
|
|
23
|
-
1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "
|
|
24
|
-
2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "
|
|
72
|
+
1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "<model>"`.
|
|
73
|
+
2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "<model>"`.
|
|
25
74
|
3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
|
|
26
75
|
|
|
27
|
-
`--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації
|
|
76
|
+
`--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації / нормалізації інструменти не потрібні.
|
|
28
77
|
|
|
29
78
|
## Структура каталогу
|
|
30
79
|
|
|
31
80
|
```text
|
|
32
81
|
docs/adr/
|
|
33
|
-
|
|
34
|
-
|
|
82
|
+
├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
|
|
83
|
+
└── <slug>.md # clean ADR-и (без frontmatter)
|
|
35
84
|
.claude/hooks/
|
|
36
|
-
├── capture-decisions.sh
|
|
37
|
-
|
|
85
|
+
├── capture-decisions.sh # auto-synced з пакета
|
|
86
|
+
├── normalize-decisions.sh # auto-synced з пакета
|
|
87
|
+
├── capture-decisions.log # лог запусків capture (НЕ коміти)
|
|
88
|
+
├── normalize-decisions.log # лог запусків normalize (НЕ коміти)
|
|
89
|
+
├── .normalize-state # timestamp останнього normalize-запуску (НЕ коміти)
|
|
90
|
+
└── .normalize.lock # lock-файл (НЕ коміти)
|
|
38
91
|
```
|
|
39
92
|
|
|
40
|
-
`.gitignore` повинен містити
|
|
93
|
+
`.gitignore` повинен містити рядки, що покривають **`.claude/hooks/*.log`** і службові файли normalize (`.claude/hooks/.normalize-*`).
|
|
41
94
|
|
|
42
|
-
|
|
95
|
+
> Якщо в репозиторії лишився старий каталог **`docs/adr/_inbox/`** з попередньої версії правила — `normalize-decisions.sh` бачить його рекурсивно й поступово розчистить. Можна також одразу `git mv docs/adr/_inbox/*.md docs/adr/` і прибрати порожній каталог.
|
|
43
96
|
|
|
44
97
|
## Stop-hook у `.claude/settings.json`
|
|
45
98
|
|
|
@@ -69,17 +122,28 @@ docs/adr/
|
|
|
69
122
|
"timeout": 180
|
|
70
123
|
}
|
|
71
124
|
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"matcher": "",
|
|
128
|
+
"hooks": [
|
|
129
|
+
{
|
|
130
|
+
"type": "command",
|
|
131
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/normalize-decisions.sh\"",
|
|
132
|
+
"async": true,
|
|
133
|
+
"timeout": 600
|
|
134
|
+
}
|
|
135
|
+
]
|
|
72
136
|
}
|
|
73
137
|
]
|
|
74
138
|
}
|
|
75
139
|
}
|
|
76
140
|
```
|
|
77
141
|
|
|
78
|
-
|
|
142
|
+
Усі три групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook`, `.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
|
|
79
143
|
|
|
80
144
|
## Локальні vs project-shared налаштування
|
|
81
145
|
|
|
82
|
-
Stop-hook ADR
|
|
146
|
+
Обидва Stop-hook'и ADR живуть у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
|
|
83
147
|
|
|
84
148
|
## Перевірка
|
|
85
149
|
|