skillscript-runtime 0.16.3 → 0.16.5

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/dist/lint.js CHANGED
@@ -4,6 +4,9 @@ import { KNOWN_FILTERS } from "./filters.js";
4
4
  // ─── lint() entry point ────────────────────────────────────────────────────
5
5
  export async function lint(source, options) {
6
6
  const parsed = parse(source);
7
+ const localModelInfo = options?.localModelAliases !== undefined
8
+ ? { aliases: options.localModelAliases, modelsAvailable: options.localModelsAvailable ?? [] }
9
+ : await collectLocalModelInfoFromRegistry(options?.registry);
7
10
  const ctx = {
8
11
  parsed,
9
12
  source,
@@ -16,6 +19,8 @@ export async function lint(source, options) {
16
19
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
17
20
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
18
21
  agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
22
+ localModelAliases: localModelInfo?.aliases,
23
+ localModelsAvailable: localModelInfo?.modelsAvailable,
19
24
  mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
20
25
  };
21
26
  const findings = [];
@@ -55,6 +60,8 @@ export function lintSync(source, options) {
55
60
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
56
61
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
57
62
  agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
63
+ localModelAliases: options?.localModelAliases ?? collectLocalModelAliasesFromRegistry(options?.registry),
64
+ localModelsAvailable: options?.localModelsAvailable,
58
65
  mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
59
66
  };
60
67
  const findings = [];
@@ -468,6 +475,203 @@ const UNKNOWN_CONNECTOR = {
468
475
  return findings;
469
476
  },
470
477
  };
478
+ // v0.16.4 — `$ llm prompt="..." model="X"` where X matches neither any
479
+ // registered LocalModel alias name NOR any `models_available` entry from
480
+ // any registered LocalModel's `manifest()` payload. Tier-2 warning.
481
+ //
482
+ // Closes the documented-but-unenforced surface from v0.16.2 (the `model=`
483
+ // kwarg shipped working at runtime but a typo like `model="qwen2.5"` —
484
+ // intending the upstream Ollama tag `qwen2.5:7b` or the alias `qwen` —
485
+ // would silently fall through to the default model). With v0.16.3
486
+ // putting `manifest()` in `runtime_capabilities`, the lint now has
487
+ // substrate-aware source of truth: both alias names AND each registered
488
+ // LocalModel's underlying model surface participate in the typo-catch.
489
+ //
490
+ // Silent unless context carries the data — `lint()` without a `registry`
491
+ // or explicit `localModelAliases` cannot validate. Variable-substituted
492
+ // values (`model="${TAG}"`) are skipped — the literal isn't a model
493
+ // identifier at compile time.
494
+ const UNKNOWN_LLM_MODEL = {
495
+ id: "unknown-llm-model",
496
+ severity: "warning",
497
+ description: "A `$ llm` op carries `model=\"X\"` where X is neither a registered LocalModel alias nor a model in any registered LocalModel's `models_available` manifest field.",
498
+ remediation: "Check the model name against the runtime's registered LocalModels — `runtime_capabilities()` lists every alias plus each instance's `manifest.models_available`. Either fix the typo, register the model under a runtime alias (e.g., `registry.registerLocalModel(\"<alias>\", <instance>)`), or wrap the value in a substitution if it's resolved at runtime.",
499
+ check: (ctx) => {
500
+ if (ctx.localModelAliases === undefined)
501
+ return [];
502
+ const known = new Set(ctx.localModelAliases);
503
+ if (ctx.localModelsAvailable !== undefined) {
504
+ for (const m of ctx.localModelsAvailable)
505
+ known.add(m);
506
+ }
507
+ const findings = [];
508
+ const reported = new Set();
509
+ for (const [targetName, target] of ctx.parsed.targets) {
510
+ walkOps(target.ops, (op) => {
511
+ if (op.kind !== "$")
512
+ return;
513
+ // Bare-form `$ llm ...` puts `llm` as the first token of op.body
514
+ // with op.mcpConnector === undefined. Named-form `$ llm.run ...`
515
+ // would set op.mcpConnector === "llm" — match either.
516
+ const tokens = tokenizeKeywordArgs(op.body);
517
+ const head = tokens[0];
518
+ const isLlmDispatch = op.mcpConnector === "llm" || (head === "llm" && op.mcpConnector === undefined);
519
+ if (!isLlmDispatch)
520
+ return;
521
+ for (const tok of tokens) {
522
+ const eq = tok.indexOf("=");
523
+ if (eq === -1)
524
+ continue;
525
+ const key = tok.slice(0, eq).trim();
526
+ if (key !== "model")
527
+ continue;
528
+ let value = tok.slice(eq + 1).trim();
529
+ // Strip outer quotes if present.
530
+ if ((value.startsWith('"') && value.endsWith('"')) ||
531
+ (value.startsWith("'") && value.endsWith("'"))) {
532
+ value = value.slice(1, -1);
533
+ }
534
+ if (value === "")
535
+ continue;
536
+ // Substitutions — `${TAG}` / `$(TAG)` — are runtime-resolved.
537
+ if (value.includes("$(") || value.includes("${"))
538
+ continue;
539
+ if (known.has(value))
540
+ continue;
541
+ const dedupKey = `${targetName}:${value}`;
542
+ if (reported.has(dedupKey))
543
+ continue;
544
+ reported.add(dedupKey);
545
+ const knownDescr = known.size === 0
546
+ ? "(no LocalModels registered)"
547
+ : [...known].sort().join(", ");
548
+ findings.push({
549
+ rule: "unknown-llm-model",
550
+ severity: "warning",
551
+ message: `\`$ llm model="${value}"\` in target '${targetName}': '${value}' matches no registered LocalModel alias and no \`models_available\` entry from any registered LocalModel. Known: ${knownDescr}.`,
552
+ block: targetName,
553
+ extras: { referenced_model: value },
554
+ });
555
+ }
556
+ });
557
+ }
558
+ return findings;
559
+ },
560
+ };
561
+ // v0.16.5 — `$ llm` op with a kwarg outside the canonical closed set.
562
+ // Catches authors leaking provider-API kwargs (e.g., `temperature=0.7`)
563
+ // which the LocalModelMcpConnector bridge silently drops — LocalModel.run()
564
+ // only consumes `{maxTokens, model}`. Tier-2 warning.
565
+ //
566
+ // Sibling to `unknown-llm-model` (v0.16.4) — same shape, different axis.
567
+ // The `model=` axis validates a value against registered substrate state;
568
+ // this rule validates a kwarg KEY against the documented closed surface.
569
+ //
570
+ // Per memory `9254a648`. Closed set lives in `LLM_KWARG_SURFACE`.
571
+ const LLM_KWARG_SURFACE = new Set([
572
+ "prompt",
573
+ "maxTokens",
574
+ "model",
575
+ "timeout",
576
+ "approved",
577
+ "fallback",
578
+ ]);
579
+ const UNKNOWN_LLM_ARG = {
580
+ id: "unknown-llm-arg",
581
+ severity: "warning",
582
+ description: "A `$ llm` op carries a kwarg outside the canonical surface (`prompt`/`maxTokens`/`model`/`timeout`/`approved`/`fallback`). Provider-API kwargs (e.g., `temperature=`) silently dropped by the bridge.",
583
+ remediation: "Drop the kwarg, or substitute the canonical equivalent. The closed surface is documented under `$ llm` in the language reference. If your substrate accepts additional kwargs, configure them at the LocalModel adapter layer (e.g., construct an OpenAILocalModel with `defaultTemperature: 0.7`) — kwargs from the skill body must match the substrate-neutral contract.",
584
+ check: (ctx) => {
585
+ const findings = [];
586
+ const reported = new Set();
587
+ for (const [targetName, target] of ctx.parsed.targets) {
588
+ walkOps(target.ops, (op) => {
589
+ if (op.kind !== "$")
590
+ return;
591
+ const tokens = tokenizeKeywordArgs(op.body);
592
+ const head = tokens[0];
593
+ const isLlmDispatch = op.mcpConnector === "llm" || (head === "llm" && op.mcpConnector === undefined);
594
+ if (!isLlmDispatch)
595
+ return;
596
+ for (const tok of tokens) {
597
+ const eq = tok.indexOf("=");
598
+ if (eq === -1)
599
+ continue;
600
+ const key = tok.slice(0, eq).trim();
601
+ if (LLM_KWARG_SURFACE.has(key))
602
+ continue;
603
+ const dedupKey = `${targetName}:${key}`;
604
+ if (reported.has(dedupKey))
605
+ continue;
606
+ reported.add(dedupKey);
607
+ findings.push({
608
+ rule: "unknown-llm-arg",
609
+ severity: "warning",
610
+ message: `\`$ llm\` in target '${targetName}' carries unknown kwarg '${key}'. Canonical surface: ${[...LLM_KWARG_SURFACE].sort().join(", ")}.`,
611
+ block: targetName,
612
+ extras: { kwarg: key },
613
+ });
614
+ }
615
+ });
616
+ }
617
+ return findings;
618
+ },
619
+ };
620
+ // v0.16.5 — `$ data_read` op with a kwarg outside the canonical closed
621
+ // set. Replaces the deleted `unknown-retrieval-arg` rule which targeted
622
+ // the v0.7.0-removed `>` symbol op surface. Same shape as `unknown-llm-arg`.
623
+ // Per memory `9254a648`.
624
+ const DATA_READ_KWARG_SURFACE = new Set([
625
+ "mode",
626
+ "query",
627
+ "limit",
628
+ "connector",
629
+ "fallback",
630
+ "domain_tags",
631
+ "filters",
632
+ "min_confidence",
633
+ ]);
634
+ const UNKNOWN_DATA_READ_ARG = {
635
+ id: "unknown-data-read-arg",
636
+ severity: "warning",
637
+ description: "A `$ data_read` op carries a kwarg outside the canonical surface (`mode`/`query`/`limit`/`connector`/`fallback`/`domain_tags`/`filters`/`min_confidence`).",
638
+ remediation: "Drop the kwarg, or substitute the canonical equivalent. The closed surface is documented under `$ data_read` in the language reference. Substrate-specific filters belong inside the `filters={...}` object literal, not as top-level kwargs.",
639
+ check: (ctx) => {
640
+ const findings = [];
641
+ const reported = new Set();
642
+ for (const [targetName, target] of ctx.parsed.targets) {
643
+ walkOps(target.ops, (op) => {
644
+ if (op.kind !== "$")
645
+ return;
646
+ const tokens = tokenizeKeywordArgs(op.body);
647
+ const head = tokens[0];
648
+ const isDataReadDispatch = op.mcpConnector === "data_read" || (head === "data_read" && op.mcpConnector === undefined);
649
+ if (!isDataReadDispatch)
650
+ return;
651
+ for (const tok of tokens) {
652
+ const eq = tok.indexOf("=");
653
+ if (eq === -1)
654
+ continue;
655
+ const key = tok.slice(0, eq).trim();
656
+ if (DATA_READ_KWARG_SURFACE.has(key))
657
+ continue;
658
+ const dedupKey = `${targetName}:${key}`;
659
+ if (reported.has(dedupKey))
660
+ continue;
661
+ reported.add(dedupKey);
662
+ findings.push({
663
+ rule: "unknown-data-read-arg",
664
+ severity: "warning",
665
+ message: `\`$ data_read\` in target '${targetName}' carries unknown kwarg '${key}'. Canonical surface: ${[...DATA_READ_KWARG_SURFACE].sort().join(", ")}.`,
666
+ block: targetName,
667
+ extras: { kwarg: key },
668
+ });
669
+ }
670
+ });
671
+ }
672
+ return findings;
673
+ },
674
+ };
471
675
  // v0.4.1 — `$ name.tool` where `name` is configured with an
472
676
  // `allowed_tools` list that doesn't include `tool`. Tier-1 lint error
473
677
  // at compile time. Closes the "minion-safe by default" framing from
@@ -2362,6 +2566,9 @@ const RULES = [
2362
2566
  TRANSCRIPT_FOOTGUN,
2363
2567
  SET_JSON_LITERAL_ADVISORY,
2364
2568
  SKILL_NAME_COLLISION,
2569
+ UNKNOWN_LLM_MODEL,
2570
+ UNKNOWN_LLM_ARG,
2571
+ UNKNOWN_DATA_READ_ARG,
2365
2572
  // v0.9.2 — promoted from tier-3 info to tier-1 error (P0.9 in c9c667d2)
2366
2573
  NO_DEFAULT_TARGET,
2367
2574
  COLON_KWARG_SYNTAX,
@@ -2533,6 +2740,43 @@ function collectAgentConnectorNamesFromRegistry(registry) {
2533
2740
  // registry.ts) — empty array means "no real AgentConnector wired."
2534
2741
  return registry.listAgentConnectors().map((e) => e.name);
2535
2742
  }
2743
+ function collectLocalModelAliasesFromRegistry(registry) {
2744
+ if (registry === undefined)
2745
+ return undefined;
2746
+ return registry.listLocalModels().map((e) => e.name);
2747
+ }
2748
+ // v0.16.4 — async sibling to collectLocalModelAliasesFromRegistry: probes each
2749
+ // registered LocalModel's `manifest()` to harvest `models_available`. Used by
2750
+ // the async `lint()` entry point so `unknown-llm-model` can validate against
2751
+ // both registry aliases AND the underlying substrate's model surface (sharpened
2752
+ // by Perry's `bfd776a9` audit insight — with manifest in capabilities, the lint
2753
+ // has substrate-aware source of truth, not just alias names).
2754
+ //
2755
+ // Per-instance try/catch — a throwing `manifest()` (e.g., substrate unreachable
2756
+ // at lint time) silently degrades to alias-only validation rather than failing
2757
+ // the lint. Adopters whose LocalModel `manifest()` omits `models_available`
2758
+ // (the contract permits absence) also gracefully fall through.
2759
+ async function collectLocalModelInfoFromRegistry(registry) {
2760
+ if (registry === undefined)
2761
+ return undefined;
2762
+ const entries = registry.listLocalModels();
2763
+ const aliases = entries.map((e) => e.name);
2764
+ const available = new Set();
2765
+ for (const entry of entries) {
2766
+ try {
2767
+ const m = await entry.instance.manifest();
2768
+ const inner = m.manifest;
2769
+ if (Array.isArray(inner.models_available)) {
2770
+ for (const tag of inner.models_available)
2771
+ available.add(tag);
2772
+ }
2773
+ }
2774
+ catch {
2775
+ // Probe failure — degrade to alias-only for this instance.
2776
+ }
2777
+ }
2778
+ return { aliases, modelsAvailable: [...available] };
2779
+ }
2536
2780
  function collectMcpConnectorAllowedToolsFromRegistry(registry) {
2537
2781
  const out = new Map();
2538
2782
  if (registry === undefined)