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 +22 -1
- package/bin/sshlg-skills.js +8 -1
- package/lib/apply.js +118 -0
- package/lib/consent.js +96 -0
- package/lib/migrate.js +80 -0
- package/lib/router-texts.js +87 -0
- package/lib/routers.js +287 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## v0.22.
|
|
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
|
package/bin/sshlg-skills.js
CHANGED
|
@@ -225,9 +225,16 @@ function cmdRouters(f) {
|
|
|
225
225
|
|
|
226
226
|
const mode = f.mode || 'install';
|
|
227
227
|
let decision = 'yes';
|
|
228
|
-
if (
|
|
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.
|
|
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",
|