feat(hooks): суфлёр различает чтение/правку + убран триггер на поиск

- Подсказка зависит от действия: 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 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-20 19:54:06 +03:00
co-authored by Claude Opus 4.8
parent ba0880a5c5
commit 4ec2420af6
5 changed files with 93 additions and 65 deletions
+5 -4
View File
@@ -6,9 +6,10 @@
изменить объект типовой конфигурации, который стоит на поддержке поставщика, редактирование изменить объект типовой конфигурации, который стоит на поддержке поставщика, редактирование
**блокируется** — иначе оно молча сломает будущие обновления вендора. В отказе сразу даётся, что делать **блокируется** — иначе оно молча сломает будущие обновления вендора. В отказе сразу даётся, что делать
дальше под конкретный случай (доработать в расширении или явно разрешить редактирование). дальше под конкретный случай (доработать в расширении или явно разрешить редактирование).
- **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML, ищет по - **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML или правит его
метаданным), хук ненавязчиво напоминает, что для этой задачи есть профильный навык (`meta-info`, напрямую), хук ненавязчиво напоминает про профильный навык — и по делу: при **чтении** ведёт на `*-info`
`form-edit`, `mxl-*`, `skd-*` и т.п.). Не блокирует, подсказывает не чаще одного раза за сессию на группу. (понять структуру), при **правке** — на мутатор (`meta-edit`/`form-edit`/`skd-edit`/…). Не блокирует,
подсказывает не чаще одного раза за сессию на группу и действие.
Это дополнительный слой поверх проверок, которые уже встроены в сами навыки: навыки-мутаторы и так не дадут Это дополнительный слой поверх проверок, которые уже встроены в сами навыки: навыки-мутаторы и так не дадут
испортить объект на поддержке. Хуки добавляют защиту для случаев, когда правят файлы **в обход навыков**. испортить объект на поддержке. Хуки добавляют защиту для случаев, когда правят файлы **в обход навыков**.
@@ -40,7 +41,7 @@
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/support-guard.mjs\"" }] } "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/support-guard.mjs\"" }] }
], ],
"PostToolUse": [ "PostToolUse": [
{ "matcher": "Read|Grep|Glob|Edit|Write|MultiEdit", { "matcher": "Read|Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "hooks": [{ "type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/skill-suggester.mjs\"" }] } "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/skill-suggester.mjs\"" }] }
] ]
+41 -26
View File
@@ -1,7 +1,7 @@
// object-class.mjs v1.0 — classify a 1C source path → relevant skill group (suggester) // object-class.mjs v1.0 — classify a 1C source path → relevant skill group (suggester)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // 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 // or null (stay silent) when the path is not a recognizable 1C artifact. Distinguishes
// cf vs cfe (extension) by sniffing <ConfigurationExtensionPurpose> in Configuration.xml, // cf vs cfe (extension) by sniffing <ConfigurationExtensionPurpose> in Configuration.xml,
// and mxl vs skd templates by the root namespace. Never throws. // and mxl vs skd templates by the root namespace. Never throws.
@@ -21,17 +21,45 @@ const META_COLLECTIONS = new Set([
'Sequences', 'ExternalDataSources', 'IntegrationServices', 'Sequences', 'ExternalDataSources', 'IntegrationServices',
]); ]);
// Per-group nudges, split by action: `read` → info-skill (понять структуру),
// `write` → mutator-skill (безопасно изменить). Подсказка зависит от того, что делает модель.
const MESSAGES = { const MESSAGES = {
meta: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML), а структурные правки — `meta-edit` (реквизиты/ТЧ/измерения/ресурсы).', meta: {
form: 'Для управляемой формы 1С есть `form-info` (анализ элементов/реквизитов/команд) и `form-edit` (точечные правки).', read: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML).',
mxl: 'Это табличный документ 1С: `mxl-info`/`mxl-decompile` дают редактируемое описание, `mxl-compile` собирает обратно.', write: 'Структурные правки объекта (реквизиты/ТЧ/измерения/ресурсы) безопаснее через `meta-edit` — он следит за uuid, порядком и валидностью.',
skd: 'Это схема компоновки данных (СКД): `skd-info` для анализа, `skd-edit` для точечных правок.', },
role: 'Для прав роли 1С есть `role-info` (сводка прав/RLS) и `role-compile` (создание из DSL).', form: {
cf: 'Корень конфигурации 1С: `cf-info` (обзор состава/свойств) и `cf-edit` (правки настроек/состава).', read: 'Управляемую форму 1С удобнее разбирать навыком `form-info` (элементы/реквизиты/команды/события).',
cfe: 'Это расширение конфигурации (CFE): `cfe-diff` для анализа, а доработку безопаснее вести через `cfe-borrow`/`cfe-patch-method`.', write: 'Правки формы (добавить элементы/реквизиты/команды) — через `form-edit`, а не ручной правкой XML.',
subsystem: 'Подсистема 1С: `subsystem-info` (состав/дерево) и `subsystem-edit` (правки состава/свойств).', },
template: 'Это макет объекта 1С: для табличного документа — навыки `mxl-*`, для СКД — `skd-*`.', mxl: {
search: 'Для навигации по метаданным 1С есть структурированные навыки `*-info` (meta-info/cf-info/form-info/…) — обычно быстрее сырого поиска по XML.', 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) { 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) { export function classifyFile(path) {
try { try {
const segs = segments(path); 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) { function mk(group) {
return { group, message: MESSAGES[group] }; return { group, read: MESSAGES[group].read, write: MESSAGES[group].write };
} }
+1 -1
View File
@@ -13,7 +13,7 @@
], ],
"PostToolUse": [ "PostToolUse": [
{ {
"matcher": "Read|Grep|Glob|Edit|Write|MultiEdit", "matcher": "Read|Edit|Write|MultiEdit",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
+22 -19
View File
@@ -7,28 +7,24 @@
// Throttled to 1×/session/skill-group via marker files. Switch: skillSuggester (on|off) // Throttled to 1×/session/skill-group via marker files. Switch: skillSuggester (on|off)
// in .v8-project.json. Never throws. // 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 { findConfigRoot } from './common/support-state.mjs';
import { getSuggesterMode } from './common/project.mjs'; import { getSuggesterMode } from './common/project.mjs';
import { resolve, isAbsolute, join } from 'node:path'; import { resolve, isAbsolute, join } from 'node:path';
import { existsSync, writeFileSync } from 'node:fs'; import { existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; 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) { function pickTarget(input, cwd) {
const ti = input.tool_input || {}; const ti = input.tool_input || {};
const tool = input.tool_name; const tool = input.tool_name;
let raw = null, kind = 'file'; if (tool !== 'Read' && tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return null;
if (tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') { const raw = typeof ti.file_path === 'string' ? ti.file_path
raw = typeof ti.file_path === 'string' ? ti.file_path : (Array.isArray(ti.file_edits) && ti.file_edits[0]?.file_path) || null;
: (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 (!raw) return null; if (!raw) return null;
const path = isAbsolute(raw) ? raw : resolve(cwd, raw); return isAbsolute(raw) ? raw : resolve(cwd, raw);
return { path, kind };
} }
function sanitize(s) { function sanitize(s) {
@@ -40,24 +36,31 @@ export function processInput(input, opts = {}) {
const empty = { stdout: '', stderr: '', exitCode: 0 }; const empty = { stdout: '', stderr: '', exitCode: 0 };
try { try {
const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd(); const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
const t = pickTarget(input, cwd); const path = pickTarget(input, cwd);
if (!t) return empty; if (!path) return empty;
const hit = t.kind === 'search' ? classifySearch(t.path) : classifyFile(t.path); const hit = classifyFile(path);
if (!hit) return empty; 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; 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 dir = opts.throttleDir || tmpdir();
const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}`); const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}-${action}`);
if (existsSync(marker)) return empty; // already nudged this group this session if (existsSync(marker)) return empty;
try { writeFileSync(marker, ''); } catch { /* throttle best-effort */ } try { writeFileSync(marker, ''); } catch { /* throttle best-effort */ }
const decision = { const decision = {
hookSpecificOutput: { hookSpecificOutput: {
hookEventName: 'PostToolUse', hookEventName: 'PostToolUse',
additionalContext: `[1c-skills] ${hit.message}`, additionalContext: `[1c-skills] ${message}`,
}, },
}; };
return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 }; return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 };
+24 -15
View File
@@ -181,37 +181,46 @@ console.log('=== skill-suggester: PostToolUse nudge ===');
writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'), writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'),
'<?xml version="1.0"?>\n<MetaDataObject><Configuration uuid="x"><Properties><ConfigurationExtensionPurpose>Customization</ConfigurationExtensionPurpose></Properties></Configuration></MetaDataObject>'); '<?xml version="1.0"?>\n<MetaDataObject><Configuration uuid="x"><Properties><ConfigurationExtensionPurpose>Customization</ConfigurationExtensionPurpose></Properties></Configuration></MetaDataObject>');
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; } }; 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'); const rMeta = read(join(SYNTH, 'Catalogs', 'Locked.xml'), 'A');
check('suggest Catalogs/X.xml → meta nudge', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout); check('Read Catalogs/X.xml → meta-info', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout);
const rMeta2 = read(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group const rMetaEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group, write action
check('suggest second meta same session → silent (throttle)', rMeta2.stdout === '', rMeta2.stdout); 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'); 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'); const rMxl = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'), 'B');
check('suggest spreadsheet Template → mxl', /mxl-/.test(grp(rMxl) || ''), rMxl.stdout); check('Read spreadsheet Template → mxl-info', /mxl-info/.test(grp(rMxl) || ''), rMxl.stdout);
const rSkd = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B'); const rSkd = edit(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B');
check('suggest DCS Template → skd', /skd-/.test(grp(rSkd) || ''), rSkd.stdout); check('Edit DCS Template → skd-edit', /skd-edit/.test(grp(rSkd) || ''), rSkd.stdout);
const rRole = read(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), 'B'); 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'); 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'); 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 // blind spots
const rBsl = read(join(SYNTH, 'Catalogs', 'Obj', 'Ext', 'ObjectModule.bsl'), 'D'); 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'); 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 }); 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 // skillSuggester off
writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' })); writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' }));