sshlg-skills 1.74.2 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## v1.75.0 (2026-10-23): milestone M3 wave 1, two guards that let defects through
2
+
3
+ - **B-223 closed:** on 2026-10-17 a review agent's backticked `pi update` inside a double-quoted
4
+ argument ran an in-place `npm install -g` that hit ENOSPC and removed the `pi` link. The
5
+ `PreToolUse` hook now refuses a host self-update (`pi update`, `codex update`, `brew upgrade
6
+ <host>`, `npm i -g <host>` and the bare global upgrades) found inside `$(…)` or backticks: at
7
+ top level, in double quotes, nested, in an unquoted heredoc body and in a `bash -c` script. The
8
+ refusal names the remedy (single quotes, escaped backticks, `-F`/`--body-file`, or the update on
9
+ its own line). Single-quoted text, escaped substitutions and quoted-delimiter heredocs run
10
+ nothing and pass; a top-level `pi update` stays the operator's call. `hostSelfUpdate()` in
11
+ `lib/hygiene.js`; `test/hygiene_test.js` three new cases and `test/hooks_e2e_test.js` one, all
12
+ red on 1.74.2.
13
+ - **B-250 closed:** the labelled-secret detector no longer drops a long token that happens to
14
+ spell a placeholder word. From 100 characters a word (`dummy`, `fake`, `xxxx`, ...) counts only
15
+ with a boundary on both sides, and an ascending run must reach 10; shorter values keep the old
16
+ rules. 36 of 20,000 random 600-character JWTs were missed before, 0 of 130,000 after.
17
+ - **Plan:** `docs/runs/2026-10-23-milestone-m3.md`, gates that cannot lie, with the operator's
18
+ decisions of 2026-10-22 (B-247 closed by decision; B-248, B-180, B-249 scheduled).
19
+ - **Board:** B-253, `executablePart()` drops an unquoted heredoc body and in-string `#` lines
20
+ the shell would still run.
21
+
1
22
  ## v1.74.2 (2026-10-22): an unread Codex is not a green step
2
23
 
3
24
  - **B-252 closed:** the 1.74.1 `update` printed "11 of 11 steps, all green" beside "not read:
package/README.md CHANGED
@@ -775,7 +775,7 @@ npx sshlg-skills humanizers # anti-AI-writing skills here, with th
775
775
  |---|---|---|
776
776
  | `SessionStart` | one note saying the block is not advisory; the session's title and the ledger to watch; **and, since v0.48.0, one line naming any other enabled plugin that also injects here** — silent when there is none, and since v1.56.0 printed only when that set differs from the one recorded in `state.json` — a machine-wide record, so a second session with the same set is not told again | ~90 tokens per session |
777
777
  | `UserPromptSubmit` | names the route this prompt asks for — **once per route per session** since v1.56.0 (a route already named is not repeated; `clear` and `compact` reset the record, `resume` keeps it, a fork is a new session id and starts without one; a payload with no session id keeps no turn record at all — its note always prints and it is never escalated). One exception: a facet route printed for the first time on a prompt that writes brings the `task-pipeline` line with it, even when delivery was named earlier | nothing on the turns that don't need it |
778
- | `PreToolUse` | **copies before any write** to `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.pi/agent/AGENTS.md`, `~/.cursor/rules/sshlg-routing.mdc` and `~/.claude/settings.json` — and **refuses the write** if the copy cannot be taken. Also refuses a bare `npx skills update <member>` | nothing until one of those files is touched |
778
+ | `PreToolUse` | **copies before any write** to `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.pi/agent/AGENTS.md`, `~/.cursor/rules/sshlg-routing.mdc` and `~/.claude/settings.json` — and **refuses the write** if the copy cannot be taken. Also refuses a bare `npx skills update <member>`, a gate piped into a filter and chained into `git`, and a host self-update (`pi update`, `brew upgrade codex`, `npm i -g @openai/codex`) inside `$(…)` or backticks, even in double quotes or nested, with the remedy (single quotes, escaped backticks or a file); single-quoted text runs nothing and passes | nothing until one of those files is touched |
779
779
  | `PostToolUse` | on `Bash`, `Read` and `Grep`: names a credential the tool printed — a vendor-shaped key, or a generated-looking value under a label that names one (`x-api-key: …`, `FOO_API_KEY=…`, `Authorization: Bearer …`, `.netrc`, a URL's `user:password@`) — by its label, the tool and the file or program, **never the value**, with the rotation remedy; hashes, ids and fingerprints under neutral names, placeholders, paths and code stay silent. After any skills-CLI run, names the plain copies shadowing a plugin; after `obsidian-wiki setup`, puts back the config keys it truncates | one scan of the output (each field whole up to 1 MiB, else its first 768 KiB and last 256 KiB; it says when a scan was partial), silent when it finds nothing; a local proxy's token (an active loopback `*_BASE_URL` of the same prefix, in the same file, with no other) is not reported; MCP results are not read |
780
780
  | `Notification` | a desktop ping when a long run goes idle or a background agent finishes | nothing — it emits a terminal sequence |
781
781
  | `ConfigChange` | notices when something else rewrites these entries (a field this pack writes changed; a field you added is not a change, and an entry that is gone looks like one you removed, so `hooks` lists it on demand instead) | nothing — it records; `SessionStart` reports |
@@ -15,6 +15,10 @@
15
15
  * truncate. Nothing is denied here; `hooks/post-tool-use.js` puts back what
16
16
  * the tool drops.
17
17
  *
18
+ * Between 2 and 3, two shell-shape refusals from the same pure module: a gate piped
19
+ * into a filter and chained into `git`, and a host self-update (`pi update`,
20
+ * `brew upgrade codex`, `npm i -g @openai/codex`) inside `$(…)` or backticks (B-223).
21
+ *
18
22
  * All deciding is in `lib/guard.js` and `lib/hygiene.js`, both pure and fixtured.
19
23
  * This file moves bytes and touches the filesystem exactly where a backup happens.
20
24
  *
@@ -233,6 +237,22 @@ process.stdin.on('end', () => {
233
237
  return process.exit(0);
234
238
  }
235
239
 
240
+ // 1c. A host self-update inside `$(…)` or backticks (hygiene.js #5, B-223): prose the
241
+ // shell reads as code, and an in-place upgrade that can remove the host it updates.
242
+ const selfUpdate = command && hygiene.hostSelfUpdate(command);
243
+ if (selfUpdate) {
244
+ say('deny',
245
+ `\`${selfUpdate.command}\` sits inside a command substitution (${selfUpdate.form}), so the shell ` +
246
+ 'runs it before the command around it starts. A host that updates itself in place can fail ' +
247
+ 'halfway (ENOSPC, a dropped download) and remove its own link: on 2026-10-17 a backticked ' +
248
+ '`pi update` in a double-quoted argument did exactly that.\n' +
249
+ 'If this is text (a commit message, a PR body, a comment), put it in single quotes, escape the ' +
250
+ 'backticks as \\`, or pass it through a file (`git commit -F <file>`, `gh ... --body-file <file>`).\n' +
251
+ 'If you mean to update the host, run that command on its own, outside `$(...)` and backticks, ' +
252
+ 'after checking free disk space (`df -h`).');
253
+ return process.exit(0);
254
+ }
255
+
236
256
  // 2. The config `setup` is about to truncate.
237
257
  if (command && hygiene.isObsidianSetup(command)) {
238
258
  try {
package/lib/hygiene.js CHANGED
@@ -347,8 +347,276 @@ function maskedGate(command) {
347
347
  return { gate: m[1].trim(), filter: m[2], git: m[3] };
348
348
  }
349
349
 
350
+ /**
351
+ * 5. **A host self-update inside a command substitution (B-223).** On 2026-10-17 a review
352
+ * agent wrote `pi update` in backticks inside a double-quoted argument. The shell ran it
353
+ * before the command around it started; the in-place `npm install -g` hit ENOSPC and
354
+ * removed the `pi` link. Nobody typed `pi update` as a command: it was prose that the
355
+ * shell read as code.
356
+ *
357
+ * `executablePart` cannot see this and is not meant to: it keeps quoted strings whole and
358
+ * the other rules read them as words, so `"run \`pi update\`"` is one argument to them. This
359
+ * rule reads the payload the way the shell does, with a small lexer of its own:
360
+ *
361
+ * - `$(…)` and backticks are code, at top level, inside double quotes, nested, inside an
362
+ * unquoted heredoc body (the shell expands those for `cat` too) and inside the script of
363
+ * `bash -c '…'` (literal to the outer shell, code to the inner one).
364
+ * - Single quotes, `$'…'`, an escaped `\$(` or `` \` ``, a quoted-delimiter heredoc fed to a
365
+ * non-shell, and a comment are literal: none of them runs.
366
+ * - Inside a substitution, only the COMMAND position counts: `$(echo pi update)` runs `echo`.
367
+ *
368
+ * Only a substitution is refused. `pi update` typed as its own command is a deliberate act
369
+ * and stays the operator's call.
370
+ *
371
+ * Returns `{command, host, form}` (`form` is `$(…)` or `backticks`) or `null`. Pure, and it
372
+ * never throws: a payload the lexer cannot follow ends at end of input.
373
+ */
374
+ const HOSTS = new Set([
375
+ 'pi', 'pi-coding-agent', 'codex', 'claude', 'claude-code', 'gemini', 'gemini-cli',
376
+ 'opencode', 'opencode-ai', 'cursor-agent', 'amp', 'goose', 'crush', 'droid', 'qwen',
377
+ 'qwen-code', 'kimi', 'kimi-cli', 'copilot', 'hermes', 'hermes-agent',
378
+ ]);
379
+ const HOST_VERBS = new Set(['update', 'upgrade', 'self-update']);
380
+ const SCRIPT_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
381
+ const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin',
382
+ '{', '!', 'if', 'then', 'elif', 'else', 'while', 'until', 'do']);
383
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
384
+ const SUB = '\u0000';
385
+
386
+ /** `@openai/codex@1.2` -> `codex`; `openai/tap/codex` -> `codex`. */
387
+ function packageName(spec) {
388
+ return String(spec || '').replace(/(.)@.*$/, '$1').replace(/^.*\//, '');
389
+ }
390
+
391
+ /** Text the shell expands (`"…"`, an unquoted heredoc body): `$(` and backticks run. */
392
+ function expandable(src, i, stop, subs, add) {
393
+ const n = src.length;
394
+ while (i < n) {
395
+ const c = src[i];
396
+ if (stop !== null && c === stop) return i + 1;
397
+ if (c === '\\') {
398
+ const d = src[i + 1];
399
+ if (d === '\n') { i += 2; continue; }
400
+ if (d === '$' || d === '`' || d === '\\' || (stop !== null && d === stop)) { add(d); i += 2; continue; }
401
+ add(c); i += 1; continue;
402
+ }
403
+ if (c === '$' && src[i + 1] === '(') { i = substitution(src, i + 2, subs); add(SUB); continue; }
404
+ if (c === '`') { i = backtick(src, i + 1, subs); add(SUB); continue; }
405
+ add(c); i += 1;
406
+ }
407
+ return n;
408
+ }
409
+
410
+ function substitution(src, i, subs) {
411
+ const r = lex(src, i, ')', subs);
412
+ subs.push({ form: '$(…)', commands: r.commands });
413
+ return r.end;
414
+ }
415
+
416
+ function backtick(src, i, subs) {
417
+ // Inside backticks a backslash escapes only `$`, a backtick and itself; the body is
418
+ // unescaped once and read as a script, which is how nested backticks are written.
419
+ let body = '';
420
+ let j = i;
421
+ while (j < src.length && src[j] !== '`') {
422
+ if (src[j] === '\\' && ['`', '\\', '$'].includes(src[j + 1])) { body += src[j + 1]; j += 2; continue; }
423
+ body += src[j]; j += 1;
424
+ }
425
+ const r = lex(body, 0, null, subs);
426
+ subs.push({ form: 'backticks', commands: r.commands });
427
+ return j + 1;
428
+ }
429
+
430
+ function heredocBodies(src, i, list, subs) {
431
+ for (const h of list) {
432
+ let body = '';
433
+ while (i < src.length) {
434
+ let e = src.indexOf('\n', i);
435
+ if (e < 0) e = src.length;
436
+ const line = src.slice(i, e);
437
+ i = e + 1;
438
+ if (line.trim() === h.delim) break;
439
+ body += line + '\n';
440
+ }
441
+ if (!h.quoted) expandable(body, 0, null, subs, () => {});
442
+ if (h.shell) lex(body, 0, null, subs);
443
+ }
444
+ return i;
445
+ }
446
+
447
+ /**
448
+ * Simple commands as word lists, quotes removed, a substitution left as a marker. With
449
+ * `close === ')'` it reads the body of `$(…)` and returns where it ended.
450
+ */
451
+ function lex(src, start, close, subs) {
452
+ const commands = [];
453
+ const pending = [];
454
+ let words = [];
455
+ let word = null;
456
+ let depth = 0;
457
+ let i = start;
458
+ const n = src.length;
459
+ const add = (s) => { word = (word === null ? '' : word) + s; };
460
+ const endWord = () => { if (word !== null) { words.push(word); word = null; } };
461
+ const endCommand = () => { endWord(); if (words.length) commands.push(words); words = []; };
462
+ while (i < n) {
463
+ const c = src[i];
464
+ if (c === ')' && close === ')' && depth === 0) { endCommand(); return { end: i + 1, commands }; }
465
+ if (c === ' ' || c === '\t' || c === '\r') { endWord(); i += 1; continue; }
466
+ if (c === '\n') {
467
+ endCommand(); i += 1;
468
+ if (pending.length) i = heredocBodies(src, i, pending.splice(0), subs);
469
+ continue;
470
+ }
471
+ if (c === '&' && src[i + 1] === '>') { endWord(); i += 2; continue; }
472
+ if (c === ';' || c === '&' || c === '|') { endCommand(); i += 1; continue; }
473
+ if (c === '(') { endCommand(); depth += 1; i += 1; continue; }
474
+ if (c === ')') { endCommand(); if (depth > 0) depth -= 1; i += 1; continue; }
475
+ if (c === '#' && word === null) { while (i < n && src[i] !== '\n') i += 1; continue; }
476
+ if (c === '\\') {
477
+ if (src[i + 1] !== '\n') add(src[i + 1] || '');
478
+ i += 2; continue;
479
+ }
480
+ if (c === "'") {
481
+ const j = src.indexOf("'", i + 1);
482
+ const stop = j < 0 ? n : j;
483
+ add(src.slice(i + 1, stop)); i = stop + 1; continue;
484
+ }
485
+ if (c === '$' && src[i + 1] === "'") {
486
+ let j = i + 2; let s = '';
487
+ while (j < n && src[j] !== "'") {
488
+ if (src[j] === '\\') { s += src[j + 1] || ''; j += 2; } else { s += src[j]; j += 1; }
489
+ }
490
+ add(s); i = j + 1; continue;
491
+ }
492
+ if (c === '"') { add(''); i = expandable(src, i + 1, '"', subs, add); continue; }
493
+ if (c === '$' && src[i + 1] === '(') { i = substitution(src, i + 2, subs); add(SUB); continue; }
494
+ if (c === '`') { i = backtick(src, i + 1, subs); add(SUB); continue; }
495
+ if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') { endWord(); i += 3; continue; }
496
+ if (c === '<' && src[i + 1] === '<') {
497
+ endWord();
498
+ let j = i + 2;
499
+ if (src[j] === '-') j += 1;
500
+ while (src[j] === ' ' || src[j] === '\t') j += 1;
501
+ let delim = '';
502
+ let quoted = false;
503
+ while (j < n && !/[\s;&|<>()]/.test(src[j])) {
504
+ const d = src[j];
505
+ if (d === "'" || d === '"') {
506
+ quoted = true;
507
+ const k = src.indexOf(d, j + 1);
508
+ const stop = k < 0 ? n : k;
509
+ delim += src.slice(j + 1, stop); j = stop + 1; continue;
510
+ }
511
+ if (d === '\\') { quoted = true; delim += src[j + 1] || ''; j += 2; continue; }
512
+ delim += d; j += 1;
513
+ }
514
+ const cmd = words.find((w) => !ASSIGNMENT.test(w)) || '';
515
+ if (delim) pending.push({ delim, quoted, shell: HEREDOC_SHELLS.has(bareName(cmd)) });
516
+ i = j; continue;
517
+ }
518
+ if (c === '<' || c === '>') {
519
+ endWord(); i += 1;
520
+ if (src[i] === '&' || src[i] === '|' || src[i] === '>') i += 1;
521
+ continue;
522
+ }
523
+ add(c); i += 1;
524
+ }
525
+ endCommand();
526
+ return { end: n, commands };
527
+ }
528
+
529
+ /** The words from the command name on, past `sudo`, `env`, assignments and keywords. */
530
+ function commandWords(words) {
531
+ let k = 0;
532
+ while (k < words.length) {
533
+ const w = words[k];
534
+ if (ASSIGNMENT.test(w)) { k += 1; continue; }
535
+ if (PREFIXES.has(bareName(w))) {
536
+ k += 1;
537
+ while (k < words.length && /^-/.test(words[k])) k += 1;
538
+ continue;
539
+ }
540
+ break;
541
+ }
542
+ return words.slice(k);
543
+ }
544
+
545
+ /** Is this simple command a coding-agent host updating itself? `'pi update'` or null. */
546
+ function hostCall(words) {
547
+ const cw = commandWords(words);
548
+ if (!cw.length) return null;
549
+ const tool = bareName(cw[0]);
550
+ const rest = cw.slice(1);
551
+ const pos = rest.filter((w) => !/^-/.test(w));
552
+ const said = () => cw.join(' ');
553
+ if (HOSTS.has(tool)) {
554
+ return pos.length && HOST_VERBS.has(pos[0]) ? { command: `${tool} ${pos[0]}`, host: tool } : null;
555
+ }
556
+ if (['npm', 'pnpm', 'bun', 'yarn'].includes(tool)) {
557
+ let global = rest.some((w) => w === '-g' || w === '--global' || w === '--location=global');
558
+ let args = pos;
559
+ if (rest.includes('--location') && pos.includes('global')) {
560
+ global = true; args = pos.filter((w, k) => !(w === 'global' && k === pos.indexOf('global')));
561
+ }
562
+ if (tool === 'yarn' && args[0] === 'global') { global = true; args = args.slice(1); }
563
+ if (!global) return null;
564
+ const verbs = tool === 'yarn' ? ['add', 'upgrade', 'up'] : ['i', 'install', 'add', 'update', 'up', 'upgrade'];
565
+ if (!verbs.includes(args[0])) return null;
566
+ const pkgs = args.slice(1);
567
+ const host = pkgs.map(packageName).find((p) => HOSTS.has(p));
568
+ if (host) return { command: said(), host };
569
+ if (!pkgs.length && ['update', 'up', 'upgrade'].includes(args[0])) return { command: said(), host: 'every global package' };
570
+ return null;
571
+ }
572
+ if (tool === 'brew') {
573
+ if (!['upgrade', 'reinstall', 'install'].includes(pos[0])) return null;
574
+ const formulae = pos.slice(1);
575
+ const host = formulae.map(packageName).find((p) => HOSTS.has(p));
576
+ if (host) return { command: said(), host };
577
+ if (!formulae.length && pos[0] === 'upgrade') return { command: said(), host: 'every outdated formula' };
578
+ return null;
579
+ }
580
+ return null;
581
+ }
582
+
583
+ /** `bash -c '<script>'` / `sh -lc "<script>"`: the script, or null. */
584
+ function shellScript(words) {
585
+ const cw = commandWords(words);
586
+ if (!cw.length || !SCRIPT_SHELLS.has(bareName(cw[0]))) return null;
587
+ const at = cw.findIndex((w, k) => k > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(w));
588
+ return at > 0 && at + 1 < cw.length ? cw[at + 1] : null;
589
+ }
590
+
591
+ function hostSelfUpdate(command) {
592
+ try {
593
+ const text = command == null ? '' : String(command);
594
+ const all = [];
595
+ const top = lex(text, 0, null, all);
596
+ all.push({ form: null, commands: top.commands });
597
+ // Bounded, so a pathological payload costs a fixed amount of work and nothing else.
598
+ for (let p = 0; p < all.length && p < 512; p += 1) {
599
+ const { form, commands } = all[p];
600
+ for (const words of commands) {
601
+ if (form) {
602
+ const hit = hostCall(words);
603
+ if (hit) return Object.assign(hit, { form });
604
+ }
605
+ const script = shellScript(words);
606
+ if (script !== null) {
607
+ const r = lex(script, 0, null, all);
608
+ all.push({ form, commands: r.commands });
609
+ }
610
+ }
611
+ }
612
+ } catch (e) {
613
+ /* Pure and silent: an input this lexer cannot follow is not a reason to break a turn. */
614
+ }
615
+ return null;
616
+ }
617
+
350
618
  module.exports = {
351
- maskedGate,
619
+ maskedGate, hostSelfUpdate,
352
620
  skillsCli, familyIds, bareFamilyInstall, launcherRunning, executablePart,
353
621
  isObsidianSetup, parseConfig, reapply, bareName, REMEDY,
354
622
  };
@@ -258,8 +258,17 @@ function labelIsSecret(label) {
258
258
 
259
259
  const PLACEHOLDER = /redacted|placeholder|example|changeme|change_me|your[_-]|xxxx|dummy|fake|sample|replace|todo|\*\*\*|\.\.\./i;
260
260
 
261
+ // From this length a value is a generated token (a JWT, a session token), and random
262
+ // base64 spells a placeholder word often enough to drop a real one: 36 of 20,000 random
263
+ // 600-character JWTs (B-250). `-` and `_` are base64url letters too, so there a word counts
264
+ // only with a boundary on both sides (`….REDACTED`, `<YOUR_TOKEN>`), and an ascending run
265
+ // must reach 10. Shorter values keep the substring rule (`...EXAMPLEKEY` in docs) and 6.
266
+ const LONG_VALUE = 100;
267
+ const PLACEHOLDER_WORD = /(?:^|[^A-Za-z0-9])(?:redacted|placeholder|example|changeme|change_me|xxxx|dummy|fake|sample|replace|todo)(?=$|[^A-Za-z0-9])|(?:^|[^A-Za-z0-9])your[_-]|\*\*\*|\.\.\./i;
268
+ const ascendingLimit = (v) => (v.length >= LONG_VALUE ? 10 : 6);
261
269
  function isPlaceholder(v) {
262
- return PLACEHOLDER.test(String(v));
270
+ const s = String(v);
271
+ return s.length >= LONG_VALUE ? PLACEHOLDER_WORD.test(s) : PLACEHOLDER.test(s);
263
272
  }
264
273
 
265
274
  /** The longest run of consecutive ascending characters (`3456`, `cdef`): an example, typed. */
@@ -337,7 +346,7 @@ function userinfoLooksSecret(raw) {
337
346
  if (!/[A-Za-z]/.test(v) || !/\d/.test(v)) return false;
338
347
  if (/^[$%{<*]/.test(v) || /\$\{|\{\{/.test(v)) return false;
339
348
  if (isPlaceholder(v) || /^(?:pass(?:word)?|secret|token)\d*$/i.test(v)) return false;
340
- if (ascendingRun(v) >= 6) return false;
349
+ if (ascendingRun(v) >= ascendingLimit(v)) return false;
341
350
  if (/^(?:[a-z]+|[A-Z][a-z]*|[A-Z]+)(?:[_-](?:[a-z]+|[A-Z][a-z]*|[A-Z]+))*[_-]?\d{0,4}$/.test(v)) {
342
351
  return false; // WORDS_LIKE_THIS_2026
343
352
  }
@@ -380,7 +389,7 @@ function valueLooksSecret(v) {
380
389
  if (/^(?:pk|pub|public)[_.]/.test(v)) return false;
381
390
  if (/^[a-z][a-z0-9]{0,15}(?:_[a-z][a-z0-9]{0,15})+$/.test(v)
382
391
  && (v.match(/\d/g) || []).length <= 3) return false; // a snake_case name
383
- if (ascendingRun(v) >= 6) return false; // `…123456`, `abcdef…`
392
+ if (ascendingRun(v) >= ascendingLimit(v)) return false; // `…123456`, `abcdef…`
384
393
  if (looksLikeSlug(v)) return false; // a k8s object name
385
394
  if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(v)) return false; // an ENV_VAR_NAME_2
386
395
  if (v.includes('/') && v.split('/').every((seg) => /^[A-Za-z][A-Za-z_-]*\d{0,3}$/.test(seg))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sshlg-skills",
3
- "version": "1.74.2",
3
+ "version": "1.75.0",
4
4
  "skillsCli": "skills@1.7.2",
5
5
  "description": "The ssheleg harness skill layer: routing, delivery gates, specialist skills and durable handoffs for coding agents.",
6
6
  "bin": {