sshlg-skills 0.22.0 → 0.22.2

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/CHANGELOG.md CHANGED
@@ -1,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
- ## v0.22.0 — 2026-08-05
3
+ ## v0.22.2 — 2026-08-05
4
+
5
+ ### Fixed
6
+ - **`--dry-run` previewed the refusal instead of the block.** Down a pipe it
7
+ reported `would-opt-out`, which answers "what happens if I decline" — not
8
+ the question a preview is asked. It now shows the block that would be
9
+ written and states plainly that nothing was changed and no consent was
10
+ requested or recorded.
11
+
12
+ — 2026-08-05
13
+
14
+ ### Fixed
15
+ - **`lib/` was missing from the published package**, so `sshlg-skills routers`
16
+ died with `MODULE_NOT_FOUND` for anyone installing through npx. The code was
17
+ correct and complete in the repository and absent from the tarball —
18
+ `files[]` listed `bin` and not `lib`.
19
+ - `test/validate.py` now derives the requirement from the source: every
20
+ `require('../…')` in `bin/` must have its top-level directory in `files[]`.
21
+ A hand-kept list is what was already wrong, so the check reads the code
22
+ instead of trusting one.
23
+
24
+ — 2026-08-05
4
25
 
5
26
  ### Added
6
27
  - **`sshlg-skills routers`** — writes a managed routing block into the global
@@ -225,9 +225,16 @@ function cmdRouters(f) {
225
225
 
226
226
  const mode = f.mode || 'install';
227
227
  let decision = 'yes';
228
- if (mode === 'install') {
228
+ if (f.dryRun) {
229
+ // A preview answers "what would I get", not "what happens if I decline".
230
+ // Nothing is written either way, so it previews the block itself and says
231
+ // plainly that no decision was made.
232
+ log('--dry-run: показываю, что было бы записано. Ничего не изменено, ' +
233
+ 'согласие не запрошено и не записано.');
234
+ } else if (mode === 'install') {
229
235
  decision = consent.askConsent({
230
236
  home,
237
+ persist: !f.dryRun,
231
238
  interactive: process.stdin.isTTY === true,
232
239
  prompt: (q) => { process.stdout.write(q); return readLineSync(); },
233
240
  log,
package/lib/apply.js ADDED
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+ /**
3
+ * Writing the routing block to the operator's agent instruction files.
4
+ *
5
+ * This is the only module in the feature that touches disk, and it is
6
+ * deliberately thin: every decision about what the text should become was
7
+ * already made and tested in `routers.js`, without a filesystem. What is left
8
+ * here is which files to consider, and when not to write at all.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const R = require('./routers.js');
14
+
15
+ /**
16
+ * The agents this family writes for, and the directory that proves each one
17
+ * is installed.
18
+ *
19
+ * The directory's existence is the evidence. An agent's home is never created
20
+ * — a file appearing under `~/.codex/` on a machine with no Codex is
21
+ * confusing at best, and at worst it is the launcher inventing a config for a
22
+ * tool the operator never chose.
23
+ */
24
+ const TARGETS = [
25
+ { agent: 'claude', dir: '.claude', file: 'CLAUDE.md' },
26
+ { agent: 'codex', dir: '.codex', file: 'AGENTS.md' },
27
+ ];
28
+
29
+ const EMPTY_BLOCK = [
30
+ R.BEGIN + ' — managed by sshlg-skills. To opt out: replace this whole block\n with a single SSHLG:ROUTERS:OPTOUT comment line. -->',
31
+ '## Роутинг работы — семья ssheleg',
32
+ '',
33
+ '<!-- SSHLG:ROUTERS:TABLE:BEGIN -->',
34
+ '<!-- SSHLG:ROUTERS:TABLE:END -->',
35
+ R.END,
36
+ '',
37
+ ].join('\n');
38
+
39
+ /**
40
+ * Record a refusal in the file itself.
41
+ *
42
+ * The state file is the launcher's memory; this marker is the operator's. It
43
+ * lives where they will see it, it survives a reinstall, a new machine's
44
+ * restored dotfiles, and a state file nobody knew about — and unlike a
45
+ * deleted block, it cannot be confused with a botched merge.
46
+ */
47
+ function writeOptOut(file) {
48
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
49
+ if (R.OPTOUT_RE.test(before)) return { file, action: 'opted-out' };
50
+ const gap = before === '' ? '' : before.endsWith('\n') ? '\n' : '\n\n';
51
+ fs.writeFileSync(file, before + gap + R.OPTOUT + '\n', 'utf8');
52
+ return { file, action: 'opted-out-recorded' };
53
+ }
54
+
55
+
56
+ function applyOne(file, routers, opts) {
57
+ const exists = fs.existsSync(file);
58
+ const before = exists ? fs.readFileSync(file, 'utf8') : '';
59
+ const parsed = R.parse(before);
60
+
61
+ if (parsed.state === R.STATE.OPTED_OUT) return { file, action: 'opted-out' };
62
+ if (parsed.state === R.STATE.MALFORMED) return { file, action: 'malformed' };
63
+
64
+ let source = before;
65
+ if (parsed.state === R.STATE.ABSENT) {
66
+ // `update` refreshes what exists; it never introduces the block. A user
67
+ // who has not got one has not agreed to one, and an update is not the
68
+ // moment to ask.
69
+ if (opts.mode !== 'install') return { file, action: exists ? 'no-block' : 'absent' };
70
+ if (opts.consent === 'no') {
71
+ return opts.dryRun ? { file, action: 'would-opt-out' } : writeOptOut(file);
72
+ }
73
+ if (opts.consent !== 'yes') return { file, action: 'no-consent' };
74
+ const gap = !exists || before === '' ? '' : before.endsWith('\n') ? '\n' : '\n\n';
75
+ source = before + gap + EMPTY_BLOCK;
76
+ }
77
+
78
+ const result = R.upsert(source, routers);
79
+ const next = result.text;
80
+ if (next === before) return { file, action: 'unchanged' };
81
+
82
+ if (opts.dryRun) return { file, action: 'would-write', diff: R.diff(before, next) };
83
+
84
+ fs.writeFileSync(file, next, 'utf8');
85
+ return { file, action: exists ? 'updated' : 'created' };
86
+ }
87
+
88
+ /**
89
+ * Apply the routers to every agent instruction file that exists on this
90
+ * machine. Returns one record per considered target; writes nothing that the
91
+ * record does not report.
92
+ */
93
+ function apply(opts) {
94
+ const home = opts.home;
95
+ const log = opts.log || ((m) => console.log(m));
96
+ const targets = [];
97
+
98
+ for (const t of TARGETS) {
99
+ const dir = path.join(home, t.dir);
100
+ if (!fs.existsSync(dir)) {
101
+ targets.push({ file: path.join(dir, t.file), action: 'agent-absent' });
102
+ continue;
103
+ }
104
+ const record = applyOne(path.join(dir, t.file), opts.routers || {}, opts);
105
+ targets.push(record);
106
+
107
+ if (record.action === 'malformed') {
108
+ log(
109
+ `${record.file}: блок роутинга повреждён (несбалансированные ` +
110
+ `маркеры) — ничего не записано. Почини вручную или удали блок целиком.`
111
+ );
112
+ }
113
+ }
114
+
115
+ return { targets };
116
+ }
117
+
118
+ module.exports = { apply, applyOne, writeOptOut, TARGETS, EMPTY_BLOCK };
package/lib/consent.js ADDED
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ /**
3
+ * Consent for editing the operator's global agent instructions.
4
+ *
5
+ * Installing a skill is not permission to change persistent instructions.
6
+ * That file governs every project and every session the operator runs, so the
7
+ * answer is asked once, recorded, and never asked again — and the absence of
8
+ * an answer is never treated as one.
9
+ *
10
+ * `home` is a parameter rather than `process.env.HOME` so the fixtures run
11
+ * against a temp directory and cannot reach the real state file.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ function statePath(home) {
18
+ return path.join(home, '.sshlg-skills', 'state.json');
19
+ }
20
+
21
+ /** The recorded state, or `{}`. A file we cannot parse is not consent. */
22
+ function readState(home) {
23
+ try {
24
+ const raw = fs.readFileSync(statePath(home), 'utf8');
25
+ const parsed = JSON.parse(raw);
26
+ return parsed && typeof parsed === 'object' ? parsed : {};
27
+ } catch (e) {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ /** Merge `patch` into the recorded state. Owner-only, like anything else here. */
33
+ function writeState(home, patch) {
34
+ const file = statePath(home);
35
+ fs.mkdirSync(path.dirname(file), { recursive: true });
36
+ const next = Object.assign(readState(home), patch);
37
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
38
+ // writeFileSync's mode is subject to umask on create and ignored entirely on
39
+ // an existing file, so the permission is set rather than requested.
40
+ fs.chmodSync(file, 0o600);
41
+ return next;
42
+ }
43
+
44
+ function defaultPrompt() {
45
+ // Node has no synchronous stdin read that works everywhere, so the caller in
46
+ // bin/ supplies the real prompt. Reaching this means an interactive path ran
47
+ // without one -- refuse loudly rather than return an empty string, which
48
+ // would read as a decline the operator never made.
49
+ throw new Error(
50
+ 'askConsent: interactive mode needs a prompt function; none was supplied'
51
+ );
52
+ }
53
+
54
+ /**
55
+ * `"yes"` or `"no"`, asked at most once in the lifetime of a machine.
56
+ *
57
+ * A non-interactive stdin — CI, a piped installer, an automation — answers
58
+ * `"no"` and says so. Silence is not consent, and the one context where an
59
+ * unattended installer could quietly rewrite a global instruction file is
60
+ * exactly the one where nobody would notice.
61
+ */
62
+ function askConsent(opts) {
63
+ const home = opts.home;
64
+ // A preview must not decide anything. Without this, `--dry-run` down a pipe
65
+ // records a permanent "no" -- the one flag whose whole promise is that it
66
+ // changes nothing.
67
+ const persist = opts.persist !== false;
68
+ const log = opts.log || ((m) => console.log(m));
69
+ const prompt = opts.prompt || defaultPrompt;
70
+
71
+ const recorded = readState(home).routers;
72
+ if (recorded === 'yes' || recorded === 'no') return recorded;
73
+
74
+ if (!opts.interactive) {
75
+ log(
76
+ 'stdin не интерактивен — роутинг-блок не записан (non-interactive: ' +
77
+ 'treating as no). Запусти `npx sshlg-skills install` в терминале, ' +
78
+ 'чтобы решить.'
79
+ );
80
+ if (persist) writeState(home, { routers: 'no' });
81
+ return 'no';
82
+ }
83
+
84
+ const answer = String(
85
+ prompt(
86
+ 'Дописать блок роутинга скилов в глобальные инструкции агента? ' +
87
+ 'Он влияет на все проекты. [y/N] '
88
+ ) || ''
89
+ ).trim().toLowerCase();
90
+
91
+ const decision = answer === 'y' || answer === 'yes' || answer === 'д' ? 'yes' : 'no';
92
+ if (persist) writeState(home, { routers: decision });
93
+ return decision;
94
+ }
95
+
96
+ module.exports = { statePath, readState, writeState, askConsent };
package/lib/migrate.js ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ /**
3
+ * Moving hand-written router rules into the managed block.
4
+ *
5
+ * The rules an operator wrote themselves are better than the ones this
6
+ * package ships — not because the prose is nicer, but because the asides are
7
+ * load-bearing. "Прогонять десять стадий ради одного символа — самый быстрый
8
+ * способ научить обходить пайплайн стороной" is the sentence that makes the
9
+ * rule get followed rather than skimmed, and it is exactly the kind of line a
10
+ * rewrite smooths into nothing.
11
+ *
12
+ * So migration moves text; it never regenerates it. The packaged defaults are
13
+ * used only for a router the operator never wrote.
14
+ */
15
+
16
+ /** Hand-written headings this migration recognises, and what they become. */
17
+ // No \b here. JavaScript's word boundary is ASCII-only, so it never matches
18
+ // after a Cyrillic letter -- `Роутинг работы\b` silently found nothing while
19
+ // `UX scenarios\b` worked, because the latter happens to end in ASCII.
20
+ const KNOWN_HEADINGS = [
21
+ { router: 'super-ux', match: /^##[ \t]+UX scenarios(?=[\s—-]|$).*$/m },
22
+ { router: 'task-pipeline', match: /^##[ \t]+Роутинг работы(?=[\s—-]|$).*$/m },
23
+ ];
24
+
25
+ /** The index where the section starting at `from` ends: the next `## `, or EOF. */
26
+ function sectionEnd(text, from) {
27
+ const next = text.slice(from).search(/^##\s/m);
28
+ return next === -1 ? text.length : from + next;
29
+ }
30
+
31
+ /**
32
+ * Find the hand-written rules without changing anything.
33
+ *
34
+ * Returns the body of each recognised heading verbatim — trimmed only of the
35
+ * blank lines that separate it from its neighbours, never reflowed.
36
+ */
37
+ function extract(text) {
38
+ const routers = {};
39
+ const spans = [];
40
+
41
+ for (const { router, match } of KNOWN_HEADINGS) {
42
+ const hit = match.exec(text);
43
+ if (!hit) continue;
44
+ const start = hit.index;
45
+ const bodyFrom = start + hit[0].length;
46
+ const end = sectionEnd(text, bodyFrom);
47
+ const body = text.slice(bodyFrom, end).replace(/^\n+/, '').replace(/\n+$/, '');
48
+ if (body) {
49
+ routers[router] = body;
50
+ spans.push({ start, end, router });
51
+ }
52
+ }
53
+
54
+ spans.sort((a, b) => a.start - b.start);
55
+ return { routers, spans };
56
+ }
57
+
58
+ /**
59
+ * Remove the recognised headings and hand back their bodies.
60
+ *
61
+ * Spans are cut back to front so earlier indices stay valid, and the cut
62
+ * leaves exactly one blank line where the section was, so the surrounding
63
+ * document keeps its shape instead of collapsing.
64
+ */
65
+ function migrate(text, opts) {
66
+ const { routers, spans } = extract(text);
67
+ const fallbacks = (opts && opts.fallbacks) || {};
68
+
69
+ let out = text;
70
+ for (const span of spans.slice().reverse()) {
71
+ const head = out.slice(0, span.start).replace(/\n+$/, '\n');
72
+ const tail = out.slice(span.end).replace(/^\n+/, '');
73
+ out = head + (head && tail ? '\n' : '') + tail;
74
+ }
75
+
76
+ const merged = Object.assign({}, fallbacks, routers);
77
+ return { text: out, routers: spans.length || Object.keys(fallbacks).length ? merged : {} };
78
+ }
79
+
80
+ module.exports = { extract, migrate, KNOWN_HEADINGS, sectionEnd };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ /**
3
+ * The packaged router texts.
4
+ *
5
+ * These are defaults, not the truth. Where the operator already wrote a rule
6
+ * by hand, migration moves their wording in and these are never used — a rule
7
+ * someone wrote in their own words is followed, and a rule that arrived as
8
+ * boilerplate is skimmed.
9
+ *
10
+ * Each text carries four things, and the fixtures check for all of them: the
11
+ * rule, the boundary in both directions, the refusal phrase, and one sentence
12
+ * placing it against the other routers. A router without a boundary swallows
13
+ * everything and gets routed around within a week.
14
+ */
15
+
16
+ const SUPER_UX = `**Если \`super-ux\` установлен, любая работа с пользовательским интерфейсом
17
+ идёт через цепочку** — сначала сценарии и их валидация, потом интерфейс.
18
+ \`docs/ux/scenarios.md\` — источник правды для user-facing поведения; файла нет
19
+ → предложи \`/ux\` до работы над UI. Изменение user-facing поведения обновляет
20
+ сценарии тем же изменением. Проверка кода против сценариев — \`/ux-audit\`, с
21
+ доказательствами \`file:line\`.
22
+
23
+ **Граница — «у этого есть пользователь».** НЕ через цепочку: внутренние
24
+ скрипты, миграции без интерфейса, работа с данными, инфраструктура. Рисовать
25
+ сценарий для cron-джобы — способ научить обходить цепочку стороной.
26
+
27
+ **Фраза отказа: «без сценариев».**
28
+
29
+ **Место среди роутеров:** super-ux решает, что интерфейс должен делать;
30
+ \`copywriting\` — как это звучит; \`task-pipeline\` — как изменение доедет до
31
+ репозитория.`;
32
+
33
+ const COPYWRITING = `**Если \`super-ux\` установлен, любой текст, который увидит пользователь
34
+ продукта, пишется через \`copywriting\`** — строки интерфейса, ошибки, пустые
35
+ состояния, лендинг, цены, блог, changelog для пользователей, посты, описание в
36
+ сторе, объявления, письма. Первым действием скил читает бренд-пак
37
+ (\`docs/brand/voice.md\`, \`terminology.md\`, \`facts.md\`); пака нет →
38
+ \`/brand-init\` до письма, а не после.
39
+
40
+ **Граница — «отгружается пользователю продукта».** НЕ через скил: коммиты и
41
+ описания PR, комментарии в коде, README для разработчиков, внутренние доки,
42
+ ответы в чате. Прогонять брендбук ради строки в CHANGELOG для разработчиков —
43
+ самый быстрый способ научить меня обходить его стороной.
44
+
45
+ **Фраза отказа: «без бренда» или «черновиком».** Работает на задаче, которая
46
+ по границе прошла бы через скил: пишу напрямую и говорю вслух, что бренд-пак
47
+ пропущен по просьбе, а не молча.
48
+
49
+ **Место среди роутеров:** \`super-ux\` решает, что интерфейс должен делать;
50
+ copywriting — как это звучит; \`task-pipeline\` — как изменение доедет до
51
+ репозитория. Лендинг проходит все три; пост в соцсеть — только copywriting,
52
+ репозиторий он не меняет.`;
53
+
54
+ const TASK_PIPELINE = `**Если \`task-pipeline\` установлен, любая работа, которая МЕНЯЕТ РЕПОЗИТОРИЙ,
55
+ идёт через него** — без отдельной просьбы. Фича, фикс, рефактор, миграция,
56
+ интеграция, переписывание, внедрение, харденинг; на любом языке и любыми
57
+ словами.
58
+
59
+ **Граница — «меняет репозиторий», и она в обе стороны.** НЕ через пайплайн:
60
+ вопрос и ответ на него, объяснение, чтение и разбор кода; опечатка,
61
+ однострочная правка, механическое переименование; разведка и измерение,
62
+ которые ничего не коммитят. Прогонять десять стадий ради одного символа — самый
63
+ быстрый способ научить обходить пайплайн стороной.
64
+
65
+ **Фраза отказа: «без пайплайна» или «quick».** Пограничный случай — называю,
66
+ каким путём иду, одной строкой, а не выбираю молча.
67
+
68
+ **Место среди роутеров:** \`super-ux\` решает, что интерфейс должен делать;
69
+ \`copywriting\` — как это звучит; task-pipeline — как изменение доедет до
70
+ репозитория.`;
71
+
72
+ /** Skill name in `skills.json` → the router it contributes. */
73
+ const BY_MEMBER = {
74
+ 'super-ux': { 'super-ux': SUPER_UX, copywriting: COPYWRITING },
75
+ 'task-pipeline': { 'task-pipeline': TASK_PIPELINE },
76
+ };
77
+
78
+ /** The routers contributed by the members actually installed. */
79
+ function forMembers(names) {
80
+ const out = {};
81
+ for (const name of names || []) {
82
+ Object.assign(out, BY_MEMBER[name] || {});
83
+ }
84
+ return out;
85
+ }
86
+
87
+ module.exports = { SUPER_UX, COPYWRITING, TASK_PIPELINE, BY_MEMBER, forMembers };
package/lib/routers.js ADDED
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+ /**
3
+ * The managed routing block — parsing and rendering only.
4
+ *
5
+ * This module never reads or writes a file. That is deliberate: it edits the
6
+ * operator's global agent instructions, a file governing every project and
7
+ * every session, and the rules that keep it intact should be provable without
8
+ * a HOME, a temp directory, or any code path capable of a write.
9
+ *
10
+ * The block is parsed into a list of segments and rendered by concatenating
11
+ * them, so a round-trip is byte-exact by construction rather than by care.
12
+ * Anything this module does not understand is carried through as chrome.
13
+ */
14
+
15
+ const BEGIN = '<!-- SSHLG:ROUTERS:BEGIN';
16
+ const END = '<!-- SSHLG:ROUTERS:END -->';
17
+ const OPTOUT = '<!-- SSHLG:ROUTERS:OPTOUT -->';
18
+ // Anchored to its own line on purpose: the block's header *names* this marker
19
+ // so the operator can find the way out without reading documentation, and a
20
+ // substring match would then read every managed file as opted out.
21
+ const OPTOUT_RE = /^[ \t]*<!-- SSHLG:ROUTERS:OPTOUT -->[ \t]*$/m;
22
+
23
+ const SECTION_RE =
24
+ /<!-- SSHLG:ROUTER:([a-z0-9-]+):BEGIN -->\n([\s\S]*?)\n<!-- SSHLG:ROUTER:\1:END -->/g;
25
+
26
+ const STATE = {
27
+ ABSENT: 'absent',
28
+ OPTED_OUT: 'opted-out',
29
+ MALFORMED: 'malformed',
30
+ PRESENT: 'present',
31
+ };
32
+
33
+ function countOccurrences(text, needle) {
34
+ let count = 0;
35
+ let from = 0;
36
+ for (;;) {
37
+ const at = text.indexOf(needle, from);
38
+ if (at === -1) return count;
39
+ count += 1;
40
+ from = at + needle.length;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Split a block body into ordered segments.
46
+ *
47
+ * `chrome` is everything the parser does not own — the heading, blank lines,
48
+ * the table, and anything a future version writes that this one has never
49
+ * heard of. It is copied verbatim, which is what lets an older launcher meet
50
+ * a newer block without damaging it.
51
+ */
52
+ function segment(block) {
53
+ const segments = [];
54
+ let cursor = 0;
55
+ SECTION_RE.lastIndex = 0;
56
+ let match;
57
+ while ((match = SECTION_RE.exec(block)) !== null) {
58
+ if (match.index > cursor) {
59
+ segments.push({ type: 'chrome', raw: block.slice(cursor, match.index) });
60
+ }
61
+ segments.push({
62
+ type: 'section',
63
+ name: match[1],
64
+ body: match[2],
65
+ raw: match[0],
66
+ });
67
+ cursor = match.index + match[0].length;
68
+ }
69
+ if (cursor < block.length) {
70
+ segments.push({ type: 'chrome', raw: block.slice(cursor) });
71
+ }
72
+ return segments;
73
+ }
74
+
75
+ /**
76
+ * Classify a file and, when it carries a well-formed block, take it apart.
77
+ *
78
+ * Order matters: the opt-out marker is checked first and outranks a present
79
+ * block, because a user who left that marker has said no in the only place
80
+ * that survives every reinstall.
81
+ */
82
+ function parse(text) {
83
+ const source = typeof text === 'string' ? text : '';
84
+
85
+ if (OPTOUT_RE.test(source)) {
86
+ return { state: STATE.OPTED_OUT, before: source, block: '', after: '', segments: [], sections: [] };
87
+ }
88
+
89
+ const begins = countOccurrences(source, BEGIN);
90
+ const ends = countOccurrences(source, END);
91
+
92
+ if (begins === 0 && ends === 0) {
93
+ return { state: STATE.ABSENT, before: source, block: '', after: '', segments: [], sections: [] };
94
+ }
95
+ if (begins !== 1 || ends !== 1) {
96
+ return { state: STATE.MALFORMED, before: source, block: '', after: '', segments: [], sections: [] };
97
+ }
98
+
99
+ const beginAt = source.indexOf(BEGIN);
100
+ const endAt = source.indexOf(END);
101
+ if (endAt < beginAt) {
102
+ return { state: STATE.MALFORMED, before: source, block: '', after: '', segments: [], sections: [] };
103
+ }
104
+
105
+ let blockEnd = endAt + END.length;
106
+ if (source[blockEnd] === '\n') blockEnd += 1;
107
+
108
+ const block = source.slice(beginAt, blockEnd);
109
+ const segments = segment(block);
110
+ return {
111
+ state: STATE.PRESENT,
112
+ before: source.slice(0, beginAt),
113
+ block,
114
+ after: source.slice(blockEnd),
115
+ segments,
116
+ sections: segments.filter((s) => s.type === 'section'),
117
+ };
118
+ }
119
+
120
+ /** The block, rebuilt from its segments. Exact by construction. */
121
+ function render(parsed) {
122
+ if (!parsed || !parsed.segments || !parsed.segments.length) return parsed && parsed.block ? parsed.block : '';
123
+ return parsed.segments.map((s) => s.raw).join('');
124
+ }
125
+
126
+ /**
127
+ * The precedence rows, in the only order they are ever rendered.
128
+ *
129
+ * The three routers are different axes rather than competing priorities: the
130
+ * first two decide what the change contains, the third how it reaches the
131
+ * repository. A landing page passes all three; a social post passes only
132
+ * copywriting, because it changes no repository.
133
+ */
134
+ const ROUTER_ROWS = [
135
+ ['super-ux', 'что интерфейс должен делать', 'есть user-facing поведение'],
136
+ ['copywriting', 'как это звучит', 'есть текст, который увидит пользователь продукта'],
137
+ ['task-pipeline', 'как изменение доедет до репозитория', 'изменение касается репозитория'],
138
+ ];
139
+
140
+ const TABLE_BEGIN = '<!-- SSHLG:ROUTERS:TABLE:BEGIN -->';
141
+ const TABLE_END = '<!-- SSHLG:ROUTERS:TABLE:END -->';
142
+
143
+ /** The table for exactly the routers present -- never a hand-kept list. */
144
+ function renderTable(names) {
145
+ const present = ROUTER_ROWS.filter((row) => names.includes(row[0]));
146
+ if (!present.length) return `${TABLE_BEGIN}\n${TABLE_END}`;
147
+ const lines = [
148
+ TABLE_BEGIN,
149
+ '| Скил | Отвечает на | Когда |',
150
+ '|---|---|---|',
151
+ ...present.map(([name, answers, when]) => `| \`${name}\` | ${answers} | ${when} |`),
152
+ TABLE_END,
153
+ ];
154
+ return lines.join('\n');
155
+ }
156
+
157
+ function sectionRaw(name, body) {
158
+ return `<!-- SSHLG:ROUTER:${name}:BEGIN -->\n${body}\n<!-- SSHLG:ROUTER:${name}:END -->`;
159
+ }
160
+
161
+ /**
162
+ * Replace the bodies of the named sections, and nothing else.
163
+ *
164
+ * Sections not named are not rewritten -- they are not even re-rendered, they
165
+ * are the same segment objects carrying the same raw bytes. That is what lets
166
+ * the bundle installer and a single member's installer both write here
167
+ * without one silently reformatting the other's work.
168
+ *
169
+ * A file that is opted out, malformed, or has no block is returned untouched
170
+ * with its state, because none of those are conditions this module is allowed
171
+ * to resolve on its own.
172
+ */
173
+ function upsert(text, routers) {
174
+ const parsed = parse(text);
175
+ const names = Object.keys(routers || {});
176
+
177
+ if (parsed.state !== STATE.PRESENT || !names.length) {
178
+ return { state: parsed.state, changed: false, text: typeof text === 'string' ? text : '' };
179
+ }
180
+
181
+ const segments = parsed.segments.slice();
182
+ for (const name of names) {
183
+ const raw = sectionRaw(name, routers[name]);
184
+ const at = segments.findIndex((s) => s.type === 'section' && s.name === name);
185
+ if (at !== -1) {
186
+ segments[at] = { type: 'section', name, body: routers[name], raw };
187
+ continue;
188
+ }
189
+ // New section: inside the chrome that holds the table, immediately before
190
+ // the table marker.
191
+ //
192
+ // Splitting the chrome matters. An empty block is a single chrome segment
193
+ // spanning BEGIN..END, so inserting *before that segment* puts the section
194
+ // outside the fence entirely — the file still looks plausible, the block
195
+ // still parses, and the section is silently no longer managed. Nothing is
196
+ // destroyed, which is why byte-preservation tests cannot see it.
197
+ const chromeAt = segments.findIndex(
198
+ (s) => s.type === 'chrome' && s.raw.includes(TABLE_BEGIN)
199
+ );
200
+ const newSection = { type: 'section', name, body: routers[name], raw };
201
+ if (chromeAt === -1) {
202
+ segments.push({ type: 'chrome', raw: '\n' }, newSection);
203
+ continue;
204
+ }
205
+ const chrome = segments[chromeAt];
206
+ const cut = chrome.raw.indexOf(TABLE_BEGIN);
207
+ segments.splice(
208
+ chromeAt,
209
+ 1,
210
+ { type: 'chrome', raw: chrome.raw.slice(0, cut) },
211
+ newSection,
212
+ { type: 'chrome', raw: '\n\n' + chrome.raw.slice(cut) }
213
+ );
214
+ }
215
+
216
+ // The table is regenerated from the sections that survived, not from what
217
+ // the caller asked for: a section removed by hand must lose its row on the
218
+ // next write, or the table starts describing a router nobody has.
219
+ const present = segments.filter((s) => s.type === 'section').map((s) => s.name);
220
+ const tableAt = segments.findIndex(
221
+ (s) => s.type === 'chrome' && s.raw.includes(TABLE_BEGIN)
222
+ );
223
+ if (tableAt !== -1) {
224
+ const chrome = segments[tableAt];
225
+ const from = chrome.raw.indexOf(TABLE_BEGIN);
226
+ const to = chrome.raw.indexOf(TABLE_END);
227
+ if (from !== -1 && to !== -1 && to > from) {
228
+ segments[tableAt] = {
229
+ type: 'chrome',
230
+ raw:
231
+ chrome.raw.slice(0, from) +
232
+ renderTable(present) +
233
+ chrome.raw.slice(to + TABLE_END.length),
234
+ };
235
+ }
236
+ }
237
+
238
+ const block = segments.map((s) => s.raw).join('');
239
+ const out = parsed.before + block + parsed.after;
240
+ return { state: parsed.state, changed: out !== text, text: out };
241
+ }
242
+
243
+ /**
244
+ * A line diff for the dry run, so `--dry-run` shows the operator the change
245
+ * in the file's own words rather than a promise about it.
246
+ *
247
+ * Line-based and dependency-free: the launcher has no dependencies and this
248
+ * is not the feature that should give it one.
249
+ */
250
+ function diff(before, after) {
251
+ if (before === after) return '';
252
+ const a = String(before).split('\n');
253
+ const b = String(after).split('\n');
254
+
255
+ // Longest common subsequence over lines, so unchanged context is not
256
+ // reported as a removal followed by an identical addition.
257
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
258
+ for (let i = a.length - 1; i >= 0; i -= 1) {
259
+ for (let j = b.length - 1; j >= 0; j -= 1) {
260
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
261
+ }
262
+ }
263
+
264
+ const out = [];
265
+ let i = 0;
266
+ let j = 0;
267
+ while (i < a.length && j < b.length) {
268
+ if (a[i] === b[j]) {
269
+ i += 1;
270
+ j += 1;
271
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
272
+ out.push(`-${a[i]}`);
273
+ i += 1;
274
+ } else {
275
+ out.push(`+${b[j]}`);
276
+ j += 1;
277
+ }
278
+ }
279
+ while (i < a.length) { out.push(`-${a[i]}`); i += 1; }
280
+ while (j < b.length) { out.push(`+${b[j]}`); j += 1; }
281
+ return out.join('\n');
282
+ }
283
+
284
+ module.exports = {
285
+ parse, render, segment, upsert, sectionRaw, renderTable, diff,
286
+ STATE, BEGIN, END, OPTOUT, OPTOUT_RE, SECTION_RE, ROUTER_ROWS,
287
+ };
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "sshlg-skills",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "One launcher/updater for the ssheleg skill family (super-ux, task-pipeline, agent-sync, make-skill, sheleg-design, seo-aeo-audit) across every agent \u2014 Claude Code, Cursor, OpenCode, Kilo, Kimi, Hermes, OpenClaw, Codex, and more.",
5
5
  "bin": {
6
6
  "sshlg-skills": "bin/sshlg-skills.js"
7
7
  },
8
8
  "files": [
9
9
  "bin",
10
+ "lib",
10
11
  "skills.json",
11
12
  "README.md",
12
13
  "LICENSE",