From 4ec2420af6dc478c4fa816a2cc237158d5b44245 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 20 Jun 2026 19:54:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20=D1=81=D1=83=D1=84=D0=BB=D1=91?= =?UTF-8?q?=D1=80=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5/=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20+=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=B3=D0=B3=D0=B5=D1=80=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Подсказка зависит от действия: Read → info-навык (понять структуру), Edit|Write|MultiEdit → мутатор (meta-edit/form-edit/…). Throttle теперь по (сессия, группа, действие) — отдельно read- и write-подсказка. - Убран триггер на Grep|Glob (группа search): *-info помогают ПОНЯТЬ найденный объект, а не НАЙТИ по содержимому → подсказка вводила в заблуждение. Суфлёр только на файловых инструментах. - cfe-подсказка ведёт и на cf-info (читает свойства/состав расширения), и на cfe-diff (специфика); правка — cfe-borrow/cfe-patch-method. - README обновлён. Co-Authored-By: Claude Opus 4.8 --- hooks/README.md | 9 ++--- hooks/common/object-class.mjs | 67 +++++++++++++++++++++-------------- hooks/hooks.json | 2 +- hooks/skill-suggester.mjs | 41 +++++++++++---------- hooks/test/run.mjs | 39 ++++++++++++-------- 5 files changed, 93 insertions(+), 65 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index f5d3f5cc..5e81c862 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -6,9 +6,10 @@ изменить объект типовой конфигурации, который стоит на поддержке поставщика, редактирование **блокируется** — иначе оно молча сломает будущие обновления вендора. В отказе сразу даётся, что делать дальше под конкретный случай (доработать в расширении или явно разрешить редактирование). -- **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML, ищет по - метаданным), хук ненавязчиво напоминает, что для этой задачи есть профильный навык (`meta-info`, - `form-edit`, `mxl-*`, `skd-*` и т.п.). Не блокирует, подсказывает не чаще одного раза за сессию на группу. +- **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML или правит его + напрямую), хук ненавязчиво напоминает про профильный навык — и по делу: при **чтении** ведёт на `*-info` + (понять структуру), при **правке** — на мутатор (`meta-edit`/`form-edit`/`skd-edit`/…). Не блокирует, + подсказывает не чаще одного раза за сессию на группу и действие. Это дополнительный слой поверх проверок, которые уже встроены в сами навыки: навыки-мутаторы и так не дадут испортить объект на поддержке. Хуки добавляют защиту для случаев, когда правят файлы **в обход навыков**. @@ -40,7 +41,7 @@ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/support-guard.mjs\"" }] } ], "PostToolUse": [ - { "matcher": "Read|Grep|Glob|Edit|Write|MultiEdit", + { "matcher": "Read|Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/skill-suggester.mjs\"" }] } ] diff --git a/hooks/common/object-class.mjs b/hooks/common/object-class.mjs index d9d55a6f..40193824 100644 --- a/hooks/common/object-class.mjs +++ b/hooks/common/object-class.mjs @@ -1,7 +1,7 @@ // object-class.mjs v1.0 — classify a 1C source path → relevant skill group (suggester) // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // -// Conservative path→skill map for the skill-suggester hook. Returns { group, message } +// Conservative path→skill map for the skill-suggester hook. Returns { group, read, write } // or null (stay silent) when the path is not a recognizable 1C artifact. Distinguishes // cf vs cfe (extension) by sniffing in Configuration.xml, // and mxl vs skd templates by the root namespace. Never throws. @@ -21,17 +21,45 @@ const META_COLLECTIONS = new Set([ 'Sequences', 'ExternalDataSources', 'IntegrationServices', ]); +// Per-group nudges, split by action: `read` → info-skill (понять структуру), +// `write` → mutator-skill (безопасно изменить). Подсказка зависит от того, что делает модель. const MESSAGES = { - meta: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML), а структурные правки — `meta-edit` (реквизиты/ТЧ/измерения/ресурсы).', - form: 'Для управляемой формы 1С есть `form-info` (анализ элементов/реквизитов/команд) и `form-edit` (точечные правки).', - mxl: 'Это табличный документ 1С: `mxl-info`/`mxl-decompile` дают редактируемое описание, `mxl-compile` собирает обратно.', - skd: 'Это схема компоновки данных (СКД): `skd-info` для анализа, `skd-edit` для точечных правок.', - role: 'Для прав роли 1С есть `role-info` (сводка прав/RLS) и `role-compile` (создание из DSL).', - cf: 'Корень конфигурации 1С: `cf-info` (обзор состава/свойств) и `cf-edit` (правки настроек/состава).', - cfe: 'Это расширение конфигурации (CFE): `cfe-diff` для анализа, а доработку безопаснее вести через `cfe-borrow`/`cfe-patch-method`.', - subsystem: 'Подсистема 1С: `subsystem-info` (состав/дерево) и `subsystem-edit` (правки состава/свойств).', - template: 'Это макет объекта 1С: для табличного документа — навыки `mxl-*`, для СКД — `skd-*`.', - search: 'Для навигации по метаданным 1С есть структурированные навыки `*-info` (meta-info/cf-info/form-info/…) — обычно быстрее сырого поиска по XML.', + meta: { + read: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML).', + write: 'Структурные правки объекта (реквизиты/ТЧ/измерения/ресурсы) безопаснее через `meta-edit` — он следит за uuid, порядком и валидностью.', + }, + form: { + read: 'Управляемую форму 1С удобнее разбирать навыком `form-info` (элементы/реквизиты/команды/события).', + write: 'Правки формы (добавить элементы/реквизиты/команды) — через `form-edit`, а не ручной правкой XML.', + }, + mxl: { + read: 'Это табличный документ 1С: `mxl-info` показывает области/параметры, `mxl-decompile` даёт редактируемое описание.', + write: 'Табличный документ правят не вручную: `mxl-decompile` → правка JSON → `mxl-compile`.', + }, + skd: { + read: 'Это схема компоновки данных (СКД): `skd-info` показывает наборы/поля/параметры.', + write: 'Точечные правки СКД — через `skd-edit` (поля/итоги/фильтры/текст запроса).', + }, + role: { + read: 'Права роли удобнее смотреть навыком `role-info` (объекты/права/RLS).', + write: 'Роль создают и правят из DSL навыком `role-compile`.', + }, + cf: { + read: 'Корень конфигурации удобнее смотреть навыком `cf-info` (свойства/состав/счётчики объектов).', + write: 'Правки корня (свойства/состав/роли по умолчанию/интерфейс) — через `cf-edit`.', + }, + cfe: { + read: 'Это расширение конфигурации (CFE): свойства и состав читает `cf-info`, специфику (заимствования/перехватчики/проверку переноса) — `cfe-diff`.', + write: 'Доработку в расширении безопаснее вести навыками `cfe-borrow`/`cfe-patch-method`, а не ручной правкой XML.', + }, + subsystem: { + read: 'Подсистему удобнее смотреть навыком `subsystem-info` (состав/дерево/командный интерфейс).', + write: 'Правки подсистемы (состав/дочерние/свойства) — через `subsystem-edit`.', + }, + template: { + read: 'Это макет объекта 1С: для табличного документа — `mxl-info`, для СКД — `skd-info`.', + write: 'Макет правят навыками: табличный документ — `mxl-*`, СКД — `skd-*`.', + }, }; function segments(p) { @@ -48,7 +76,7 @@ function sniffRoot(path) { } } -// Classify a concrete file path. Returns { group, message } or null. +// Classify a concrete file path. Returns { group, read, write } (action-specific nudges) or null. export function classifyFile(path) { try { const segs = segments(path); @@ -90,19 +118,6 @@ export function classifyFile(path) { } } -// Classify a Grep/Glob search target: if it points inside a 1C config tree (or a known -// collection appears in the path/pattern) → suggest the info-skills. Best-effort, lean silent. -export function classifySearch(target) { - try { - if (!target) return null; - const segs = segments(target); - if (segs.some((s) => META_COLLECTIONS.has(s) || s === 'Roles' || s === 'Subsystems')) return mk('search'); - return null; - } catch { - return null; - } -} - function mk(group) { - return { group, message: MESSAGES[group] }; + return { group, read: MESSAGES[group].read, write: MESSAGES[group].write }; } diff --git a/hooks/hooks.json b/hooks/hooks.json index db1012c5..88d4db0e 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -13,7 +13,7 @@ ], "PostToolUse": [ { - "matcher": "Read|Grep|Glob|Edit|Write|MultiEdit", + "matcher": "Read|Edit|Write|MultiEdit", "hooks": [ { "type": "command", diff --git a/hooks/skill-suggester.mjs b/hooks/skill-suggester.mjs index 8bf5abe2..3ea4fc22 100644 --- a/hooks/skill-suggester.mjs +++ b/hooks/skill-suggester.mjs @@ -7,28 +7,24 @@ // Throttled to 1×/session/skill-group via marker files. Switch: skillSuggester (on|off) // in .v8-project.json. Never throws. -import { classifyFile, classifySearch } from './common/object-class.mjs'; +import { classifyFile } from './common/object-class.mjs'; import { findConfigRoot } from './common/support-state.mjs'; import { getSuggesterMode } from './common/project.mjs'; import { resolve, isAbsolute, join } from 'node:path'; import { existsSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +// Only file-targeting tools — these are where a skill genuinely substitutes the raw action. +// Content search (Grep/Glob) is intentionally NOT nudged: *-info skills help understand a +// located object, not find one by content. function pickTarget(input, cwd) { const ti = input.tool_input || {}; const tool = input.tool_name; - let raw = null, kind = 'file'; - if (tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') { - raw = typeof ti.file_path === 'string' ? ti.file_path - : (Array.isArray(ti.file_edits) && ti.file_edits[0]?.file_path) || null; - } else if (tool === 'Grep') { - raw = typeof ti.path === 'string' ? ti.path : null; kind = 'search'; - } else if (tool === 'Glob') { - raw = typeof ti.path === 'string' ? ti.path : (typeof ti.pattern === 'string' ? ti.pattern : null); kind = 'search'; - } + if (tool !== 'Read' && tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return null; + const raw = typeof ti.file_path === 'string' ? ti.file_path + : (Array.isArray(ti.file_edits) && ti.file_edits[0]?.file_path) || null; if (!raw) return null; - const path = isAbsolute(raw) ? raw : resolve(cwd, raw); - return { path, kind }; + return isAbsolute(raw) ? raw : resolve(cwd, raw); } function sanitize(s) { @@ -40,24 +36,31 @@ export function processInput(input, opts = {}) { const empty = { stdout: '', stderr: '', exitCode: 0 }; try { const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd(); - const t = pickTarget(input, cwd); - if (!t) return empty; + const path = pickTarget(input, cwd); + if (!path) return empty; - const hit = t.kind === 'search' ? classifySearch(t.path) : classifyFile(t.path); + const hit = classifyFile(path); if (!hit) return empty; - const { cfgDir } = findConfigRoot(t.path); + // Read → info-skill; Edit/Write/MultiEdit → mutator-skill. + const action = input.tool_name === 'Read' ? 'read' : 'write'; + const message = hit[action]; + if (!message) return empty; + + const { cfgDir } = findConfigRoot(path); if (getSuggesterMode(cfgDir, cwd) === 'off') return empty; + // Throttle per (session, group, action): at most one read-nudge and one write-nudge + // per skill-group per session. const dir = opts.throttleDir || tmpdir(); - const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}`); - if (existsSync(marker)) return empty; // already nudged this group this session + const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}-${action}`); + if (existsSync(marker)) return empty; try { writeFileSync(marker, ''); } catch { /* throttle best-effort */ } const decision = { hookSpecificOutput: { hookEventName: 'PostToolUse', - additionalContext: `[1c-skills] ${hit.message}`, + additionalContext: `[1c-skills] ${message}`, }, }; return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 }; diff --git a/hooks/test/run.mjs b/hooks/test/run.mjs index 0156d681..f4358e77 100644 --- a/hooks/test/run.mjs +++ b/hooks/test/run.mjs @@ -181,37 +181,46 @@ console.log('=== skill-suggester: PostToolUse nudge ==='); writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'), '\nCustomization'); - const read = (fp, session = 's1', tool = 'Read') => suggest({ tool_name: tool, session_id: session, cwd: REPO, tool_input: { file_path: fp } }, { throttleDir: THR }); + const call = (fp, session, tool) => suggest({ tool_name: tool, session_id: session, cwd: REPO, tool_input: { file_path: fp } }, { throttleDir: THR }); + const read = (fp, session = 's1') => call(fp, session, 'Read'); + const edit = (fp, session = 's1') => call(fp, session, 'Edit'); const grp = (r) => { try { return JSON.parse(r.stdout)?.hookSpecificOutput?.additionalContext; } catch { return null; } }; + // Read → info-skill; Edit → mutator-skill (same group, distinct nudge). const rMeta = read(join(SYNTH, 'Catalogs', 'Locked.xml'), 'A'); - check('suggest Catalogs/X.xml → meta nudge', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout); - const rMeta2 = read(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group - check('suggest second meta same session → silent (throttle)', rMeta2.stdout === '', rMeta2.stdout); + check('Read Catalogs/X.xml → meta-info', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout); + const rMetaEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group, write action + check('Edit Catalogs/X.xml → meta-edit (not throttled by the read)', /meta-edit/.test(grp(rMetaEdit) || ''), rMetaEdit.stdout); + const rMeta2 = read(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group+action → throttled + check('second Read meta same session → silent (throttle)', rMeta2.stdout === '', rMeta2.stdout); const rForm = read(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext', 'Form.xml'), 'A'); - check('suggest Form.xml (diff group, same session) → form nudge', /form-info/.test(grp(rForm) || ''), rForm.stdout); + check('Read Form.xml (diff group) → form-info', /form-info/.test(grp(rForm) || ''), rForm.stdout); const rMxl = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'), 'B'); - check('suggest spreadsheet Template → mxl', /mxl-/.test(grp(rMxl) || ''), rMxl.stdout); - const rSkd = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B'); - check('suggest DCS Template → skd', /skd-/.test(grp(rSkd) || ''), rSkd.stdout); + check('Read spreadsheet Template → mxl-info', /mxl-info/.test(grp(rMxl) || ''), rMxl.stdout); + const rSkd = edit(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B'); + check('Edit DCS Template → skd-edit', /skd-edit/.test(grp(rSkd) || ''), rSkd.stdout); const rRole = read(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), 'B'); - check('suggest Rights.xml → role', /role-/.test(grp(rRole) || ''), rRole.stdout); + check('Read Rights.xml → role-info', /role-info/.test(grp(rRole) || ''), rRole.stdout); const rCf = read(join(ACC, 'Configuration.xml'), 'C'); - check('suggest base Configuration.xml → cf', /cf-info/.test(grp(rCf) || ''), rCf.stdout); + check('Read base Configuration.xml → cf-info', /cf-info/.test(grp(rCf) || ''), rCf.stdout); const rCfe = read(join(SYNTH, 'ext', 'Configuration.xml'), 'C'); - check('suggest extension Configuration.xml → cfe', /cfe-/.test(grp(rCfe) || ''), rCfe.stdout); + check('Read extension Configuration.xml → cfe/cf-info', /cfe-diff|cf-info/.test(grp(rCfe) || ''), rCfe.stdout); + const rCfeEdit = edit(join(SYNTH, 'ext', 'Configuration.xml'), 'C'); + check('Edit extension Configuration.xml → cfe-borrow/patch', /cfe-borrow|cfe-patch-method/.test(grp(rCfeEdit) || ''), rCfeEdit.stdout); // blind spots const rBsl = read(join(SYNTH, 'Catalogs', 'Obj', 'Ext', 'ObjectModule.bsl'), 'D'); - check('suggest .bsl → silent', rBsl.stdout === '', rBsl.stdout); + check('Read .bsl → silent', rBsl.stdout === '', rBsl.stdout); const rReadme = read(join(REPO, 'README.md'), 'D'); - check('suggest non-1C file → silent', rReadme.stdout === '', rReadme.stdout); + check('Read non-1C file → silent', rReadme.stdout === '', rReadme.stdout); - // Grep/Glob search + // search tools no longer nudge const rGrep = suggest({ tool_name: 'Grep', session_id: 'E', cwd: REPO, tool_input: { path: join(ACC, 'Catalogs'), pattern: 'foo' } }, { throttleDir: THR }); - check('suggest Grep under Catalogs → search nudge', /\*-info/.test(grp(rGrep) || ''), rGrep.stdout); + check('Grep → silent (search trigger removed)', rGrep.stdout === '', rGrep.stdout); + const rGlob = suggest({ tool_name: 'Glob', session_id: 'E', cwd: REPO, tool_input: { pattern: '**/Catalogs/*.xml' } }, { throttleDir: THR }); + check('Glob → silent (search trigger removed)', rGlob.stdout === '', rGlob.stdout); // skillSuggester off writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' }));