skillscript-runtime 0.8.0 → 0.9.6

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.
Files changed (90) hide show
  1. package/dist/approval.d.ts +99 -0
  2. package/dist/approval.d.ts.map +1 -0
  3. package/dist/approval.js +224 -0
  4. package/dist/approval.js.map +1 -0
  5. package/dist/bootstrap.d.ts +21 -1
  6. package/dist/bootstrap.d.ts.map +1 -1
  7. package/dist/bootstrap.js +19 -5
  8. package/dist/bootstrap.js.map +1 -1
  9. package/dist/compile.d.ts.map +1 -1
  10. package/dist/compile.js +9 -1
  11. package/dist/compile.js.map +1 -1
  12. package/dist/composition.d.ts +29 -0
  13. package/dist/composition.d.ts.map +1 -1
  14. package/dist/composition.js +62 -1
  15. package/dist/composition.js.map +1 -1
  16. package/dist/connectors/agent-noop.d.ts +12 -7
  17. package/dist/connectors/agent-noop.d.ts.map +1 -1
  18. package/dist/connectors/agent-noop.js +31 -12
  19. package/dist/connectors/agent-noop.js.map +1 -1
  20. package/dist/connectors/agent.d.ts +150 -28
  21. package/dist/connectors/agent.d.ts.map +1 -1
  22. package/dist/connectors/agent.js +13 -10
  23. package/dist/connectors/agent.js.map +1 -1
  24. package/dist/connectors/local-model-mcp.d.ts +8 -0
  25. package/dist/connectors/local-model-mcp.d.ts.map +1 -1
  26. package/dist/connectors/local-model-mcp.js +10 -0
  27. package/dist/connectors/local-model-mcp.js.map +1 -1
  28. package/dist/connectors/memory-store-mcp.d.ts +9 -0
  29. package/dist/connectors/memory-store-mcp.d.ts.map +1 -1
  30. package/dist/connectors/memory-store-mcp.js +11 -0
  31. package/dist/connectors/memory-store-mcp.js.map +1 -1
  32. package/dist/connectors/registry.d.ts +15 -1
  33. package/dist/connectors/registry.d.ts.map +1 -1
  34. package/dist/connectors/registry.js +23 -1
  35. package/dist/connectors/registry.js.map +1 -1
  36. package/dist/connectors/skill-store.d.ts.map +1 -1
  37. package/dist/connectors/skill-store.js +43 -13
  38. package/dist/connectors/skill-store.js.map +1 -1
  39. package/dist/connectors/types.d.ts +12 -0
  40. package/dist/connectors/types.d.ts.map +1 -1
  41. package/dist/connectors/types.js.map +1 -1
  42. package/dist/dashboard/spa/app.js +37 -9
  43. package/dist/dispatch-validate.d.ts +64 -0
  44. package/dist/dispatch-validate.d.ts.map +1 -0
  45. package/dist/dispatch-validate.js +81 -0
  46. package/dist/dispatch-validate.js.map +1 -0
  47. package/dist/errors.d.ts +11 -0
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +16 -0
  50. package/dist/errors.js.map +1 -1
  51. package/dist/help-content.d.ts.map +1 -1
  52. package/dist/help-content.js +92 -12
  53. package/dist/help-content.js.map +1 -1
  54. package/dist/index.d.ts +3 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/lint.d.ts +11 -0
  59. package/dist/lint.d.ts.map +1 -1
  60. package/dist/lint.js +560 -104
  61. package/dist/lint.js.map +1 -1
  62. package/dist/mcp-server.d.ts.map +1 -1
  63. package/dist/mcp-server.js +53 -11
  64. package/dist/mcp-server.js.map +1 -1
  65. package/dist/parser.d.ts +27 -6
  66. package/dist/parser.d.ts.map +1 -1
  67. package/dist/parser.js +115 -11
  68. package/dist/parser.js.map +1 -1
  69. package/dist/runtime.d.ts +51 -2
  70. package/dist/runtime.d.ts.map +1 -1
  71. package/dist/runtime.js +126 -35
  72. package/dist/runtime.js.map +1 -1
  73. package/dist/scheduler.d.ts +20 -1
  74. package/dist/scheduler.d.ts.map +1 -1
  75. package/dist/scheduler.js +40 -3
  76. package/dist/scheduler.js.map +1 -1
  77. package/dist/testing/conformance.d.ts.map +1 -1
  78. package/dist/testing/conformance.js +16 -6
  79. package/dist/testing/conformance.js.map +1 -1
  80. package/examples/classify-support-ticket.skill.md +1 -1
  81. package/examples/cut-release-tag.skill.md +1 -1
  82. package/examples/doc-qa-with-citations.skill.md +1 -1
  83. package/examples/feedback-sentiment-scan.skill.md +1 -1
  84. package/examples/hello.skill.provenance.json +1 -1
  85. package/examples/morning-brief.skill.md +1 -1
  86. package/examples/queue-length-monitor.skill.md +1 -1
  87. package/examples/service-health-watch.skill.md +1 -1
  88. package/examples/youtrack-morning-sweep.skill.md +1 -1
  89. package/package.json +1 -1
  90. package/scaffold/examples/hello.skill.md +3 -2
package/dist/lint.js CHANGED
@@ -5,6 +5,7 @@ export async function lint(source, options) {
5
5
  const parsed = parse(source);
6
6
  const ctx = {
7
7
  parsed,
8
+ source,
8
9
  capabilityClasses: options?.classes ?? collectClassesFromRegistry(options?.registry),
9
10
  skillStore: options?.skillStore,
10
11
  hasSkillStore: options?.skillStore !== undefined,
@@ -14,6 +15,7 @@ export async function lint(source, options) {
14
15
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
15
16
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
16
17
  agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
18
+ mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
17
19
  };
18
20
  const findings = [];
19
21
  for (const rule of RULES) {
@@ -42,6 +44,7 @@ export function lintSync(source, options) {
42
44
  const parsed = parse(source);
43
45
  const ctx = {
44
46
  parsed,
47
+ source,
45
48
  capabilityClasses: options?.classes ?? collectClassesFromRegistry(options?.registry),
46
49
  skillStore: options?.skillStore,
47
50
  hasSkillStore: options?.skillStore !== undefined,
@@ -51,6 +54,7 @@ export function lintSync(source, options) {
51
54
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
52
55
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
53
56
  agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
57
+ mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
54
58
  };
55
59
  const findings = [];
56
60
  for (const rule of RULES) {
@@ -379,36 +383,47 @@ const INDENTATION = {
379
383
  };
380
384
  // v0.3.1: demoted tier-1 → tier-2. Forward-references are allowed; the
381
385
  // runtime throws `SkillNotFoundError` if the ref still can't resolve at
382
- // execute time. The tier-3 `deferred-skill-reference` advisory below
383
- // confirms the deferred-resolution path is engaged.
386
+ // execute time. v0.9.4.1: paired `deferred-skill-reference` advisory
387
+ // removed the warning's remediation already explains the forward-ref
388
+ // path; the second finding was just noise (per Perry's `77ed6c65`
389
+ // "4 diagnostics for 2 missing skills" cold-author finding).
384
390
  const UNKNOWN_SKILL_REFERENCE = {
385
391
  id: "unknown-skill-reference",
386
392
  severity: "warning",
387
- description: "An `&` or `$ execute_skill` op references a skill that's not present in the configured SkillStore. Lint warning (not error) since v0.3.1 — runtime throws `SkillNotFoundError` if still missing at execute time.",
393
+ description: "An `&` or `$ execute_skill` op references a skill that's not present in the configured SkillStore. Lint warning (not error) since v0.3.1 — runtime throws `SkillNotFoundError` if still missing at execute time. Dedup is per-missing-skill, not per-call-site (v0.9.4.1).",
388
394
  remediation: "If this is a typo, fix the spelling against your declarations. If it's a forward reference to a skill you'll author next, this warning clears once the skill is stored. The runtime will throw `SkillNotFoundError` at execute time if the skill is still missing.",
389
395
  check: async (ctx) => {
390
396
  if (ctx.skillStore === undefined)
391
397
  return [];
392
398
  const findings = [];
393
- const seen = new Set();
399
+ // v0.9.4.1 dedup by skill name (not via:name) so one missing skill
400
+ // referenced via both `&` and `$ execute_skill` (or from multiple
401
+ // targets) produces one diagnostic, not N. Per Perry's `77ed6c65`
402
+ // next-ring finding: "4 diagnostics for 2 missing skills" — the via
403
+ // and target list now folds into a single message.
404
+ const byName = new Map();
394
405
  for (const [targetName, target] of ctx.parsed.targets) {
395
406
  for (const ref of collectAmpRefsFromOps(target.ops)) {
396
- const key = `${ref.via}:${ref.name}`;
397
- if (seen.has(key))
398
- continue;
399
- seen.add(key);
400
- try {
401
- await ctx.skillStore.metadata(ref.name);
402
- }
403
- catch {
404
- findings.push({
405
- rule: "unknown-skill-reference",
406
- severity: "warning",
407
- message: `Skill '${targetName}' references skill '${ref.name}' via \`${ref.via}\`, but the SkillStore has no skill by that name.`,
408
- block: targetName,
409
- extras: { referenced_skill: ref.name, via: ref.via },
410
- });
411
- }
407
+ const entry = byName.get(ref.name);
408
+ if (entry === undefined)
409
+ byName.set(ref.name, { vias: new Set([ref.via]), firstTarget: targetName });
410
+ else
411
+ entry.vias.add(ref.via);
412
+ }
413
+ }
414
+ for (const [name, { vias, firstTarget }] of byName) {
415
+ try {
416
+ await ctx.skillStore.metadata(name);
417
+ }
418
+ catch {
419
+ const viaList = Array.from(vias).map((v) => `\`${v}\``).join(" / ");
420
+ findings.push({
421
+ rule: "unknown-skill-reference",
422
+ severity: "warning",
423
+ message: `Skill '${name}' is referenced via ${viaList} (first seen in target '${firstTarget}'), but the SkillStore has no skill by that name.`,
424
+ block: firstTarget,
425
+ extras: { referenced_skill: name, vias: Array.from(vias) },
426
+ });
412
427
  }
413
428
  }
414
429
  return findings;
@@ -531,6 +546,117 @@ const DISALLOWED_TOOL = {
531
546
  return findings;
532
547
  },
533
548
  };
549
+ // v0.9.1 — `$ ref.tool` where `ref` is wired AND `allowed_tools` doesn't
550
+ // exclude `tool` AND the connector class declares its static tool surface
551
+ // AND `tool` is NOT in that declared surface. Tier-1 error.
552
+ //
553
+ // Closes the v0.9.0 multi-layer-promise recurrence (third in the
554
+ // v0.7.2→v0.7.3→v0.9.0 series). Before v0.9.1, `disallowed-tool` only
555
+ // fired when an explicit allow-list was configured; connectors with
556
+ // `allowed_tools: undefined` (allow-all) green-lit any qualified tool
557
+ // name. Runtime then failed downstream with misleading kwarg errors.
558
+ //
559
+ // The fix: connectors that ship with a closed static tool surface
560
+ // (LocalModelMcpConnector → ["prompt"], MemoryStoreMcpConnector →
561
+ // ["query", "memory_write"]) declare it via `staticTools()`; lint
562
+ // validates qualified dispatches against that surface.
563
+ //
564
+ // Connectors WITHOUT a declared static surface (RemoteMcpConnector,
565
+ // adopter classes) emit the tier-3 `unverified-qualified-tool`
566
+ // advisory instead — see UNVERIFIED_QUALIFIED_TOOL below.
567
+ const UNKNOWN_TOOL_ON_CONNECTOR = {
568
+ id: "unknown-tool-on-connector",
569
+ severity: "error",
570
+ description: "A qualified `$ ref.tool` op references a tool not declared on the connector class's static surface.",
571
+ remediation: "Use a tool from the connector's declared list (see `runtime_capabilities()` for the wired connector and its class). If the tool genuinely exists on the connector but isn't in the static list, that's a connector-class bug — file as such; for now use bare-form `$ tool ...` if the name-match dispatch reaches the right connector.",
572
+ check: (ctx) => {
573
+ if (ctx.mcpConnectorStaticTools.size === 0)
574
+ return [];
575
+ const findings = [];
576
+ const reported = new Set();
577
+ for (const [targetName, target] of ctx.parsed.targets) {
578
+ walkOps(target.ops, (op) => {
579
+ if (op.kind !== "$" || op.mcpConnector === undefined)
580
+ return;
581
+ const ref = op.mcpConnector;
582
+ const declared = ctx.mcpConnectorStaticTools.get(ref);
583
+ if (declared === undefined || declared === null)
584
+ return; // no info; UNVERIFIED rule handles
585
+ const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
586
+ if (m === null)
587
+ return;
588
+ const toolName = m[1];
589
+ if (declared.includes(toolName))
590
+ return;
591
+ // If an `allowed_tools` allowlist excludes the tool, `disallowed-tool`
592
+ // already fires — avoid double-reporting.
593
+ const allowed = ctx.mcpConnectorAllowedTools.get(ref);
594
+ if (allowed !== undefined && !allowed.includes(toolName))
595
+ return;
596
+ const key = `${targetName}:${ref}:${toolName}`;
597
+ if (reported.has(key))
598
+ return;
599
+ reported.add(key);
600
+ findings.push({
601
+ rule: "unknown-tool-on-connector",
602
+ severity: "error",
603
+ message: `\`$ ${ref}.${toolName}\` in target '${targetName}' — tool '${toolName}' is not declared on connector '${ref}'. Declared tools: ${declared.length === 0 ? "(none)" : declared.join(", ")}. Use a declared tool, or wire a different connector that supports '${toolName}'.`,
604
+ block: targetName,
605
+ extras: { connector: ref, tool: toolName, declared_tools: declared },
606
+ });
607
+ });
608
+ }
609
+ return findings;
610
+ },
611
+ };
612
+ // v0.9.1 — tier-3 advisory for qualified dispatches against connectors
613
+ // whose class doesn't declare a static tool surface. RemoteMcpConnector
614
+ // is the canonical case: it wraps an arbitrary upstream MCP server, so
615
+ // the tool list is only knowable at runtime via `tools/list`. Adopter
616
+ // classes that don't implement `staticTools()` land here too.
617
+ //
618
+ // Surfaces as `info` (advisory) — author sees the hint, can proceed if
619
+ // they know the tool exists. Pairs with the structural validateDispatch
620
+ // extraction; the runtime still dispatches, and if the tool is missing
621
+ // the connector-specific error surfaces at execute time.
622
+ const UNVERIFIED_QUALIFIED_TOOL = {
623
+ id: "unverified-qualified-tool",
624
+ severity: "info",
625
+ description: "A qualified `$ ref.tool` op against a connector class without a static tool surface — can't validate at compile time.",
626
+ remediation: "Verify the tool exists on the connector before relying on this. RemoteMcpConnector adopters can use `runtime_capabilities()` to inspect the upstream `tools/list`; class authors can implement `staticTools()` to lift this validation into lint.",
627
+ check: (ctx) => {
628
+ if (ctx.mcpConnectorStaticTools.size === 0)
629
+ return [];
630
+ const findings = [];
631
+ const reported = new Set();
632
+ for (const [targetName, target] of ctx.parsed.targets) {
633
+ walkOps(target.ops, (op) => {
634
+ if (op.kind !== "$" || op.mcpConnector === undefined)
635
+ return;
636
+ const ref = op.mcpConnector;
637
+ // null = wired but class doesn't expose; undefined = not wired (different rule)
638
+ if (ctx.mcpConnectorStaticTools.get(ref) !== null)
639
+ return;
640
+ const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
641
+ if (m === null)
642
+ return;
643
+ const toolName = m[1];
644
+ const key = `${targetName}:${ref}:${toolName}`;
645
+ if (reported.has(key))
646
+ return;
647
+ reported.add(key);
648
+ findings.push({
649
+ rule: "unverified-qualified-tool",
650
+ severity: "info",
651
+ message: `\`$ ${ref}.${toolName}\` in target '${targetName}' — connector '${ref}' doesn't declare its tool surface statically; can't validate at compile time. Verify the tool exists on the connector; runtime will fail with a connector-specific error if it doesn't.`,
652
+ block: targetName,
653
+ extras: { connector: ref, tool: toolName },
654
+ });
655
+ });
656
+ }
657
+ return findings;
658
+ },
659
+ };
534
660
  // v0.5.0 item 5 — bare `$ TOOL` op (no connector prefix) when no
535
661
  // `primary` connector is wired. Runtime now throws ConnectorNotFoundError
536
662
  // instead of silent-stub (was: emitted "Would call tool X" + bound null,
@@ -549,16 +675,26 @@ const DISALLOWED_TOOL = {
549
675
  // (lint context has real registry info) — embedder contexts that don't
550
676
  // expose the registry stay silent rather than risk noise on legitimate
551
677
  // toolDispatch-only setups.
678
+ // v0.9.4.1 — fallback-aware demotion. When every call site for a (target, tool)
679
+ // pair carries an op-level `(fallback: ...)` trailer, demote the tier-1 error
680
+ // to advisory (info). Lint runs at authoring-time; `(fallback:)` is honored at
681
+ // dispatch-time, so the runtime is guarded even though the connector isn't
682
+ // wired here. Per Perry's `77ed6c65` next-ring finding: cold authors expect
683
+ // `(fallback:)` to suppress the tier-1 error (it didn't pre-v0.9.4.1), and
684
+ // the lint message didn't explain the layering. Now: if all sites have
685
+ // fallback → info with layering explanation; if any site lacks it → error
686
+ // (unchanged).
552
687
  const UNWIRED_PRIMARY_CONNECTOR = {
553
688
  id: "unwired-primary-connector",
554
689
  severity: "error",
555
- description: "A bare `$ TOOL` op (no connector prefix) routes to either (a) a wired connector matching the op name, or (b) the `primary` connector's tool dispatch. Neither resolves.",
556
- remediation: "Either wire a connector whose name matches the bare op (the v0.7.2 canonical pattern — e.g., `llm` / `memory` auto-wire by default), add a `primary` entry to connectors.json that handles the tool, or qualify the op as `$ named_connector.TOOL` against a wired connector.",
690
+ description: "A bare `$ TOOL` op (no connector prefix) routes to either (a) a wired connector matching the op name, or (b) the `primary` connector's tool dispatch. Neither resolves. Demoted to advisory when every call site declares `(fallback: ...)` (v0.9.4.1).",
691
+ remediation: "For skill authors (in-skill fix): qualify the op as `$ <connector>.<tool>` against a wired connector, OR pick a `tool` name that matches a wired connector name (e.g., `$ llm prompt=...` if `llm` is wired), OR add `(fallback: \"...\")` to every call site to acknowledge dispatch-time guarding. For runtime operators (config fix): wire a connector whose name matches the bare op (the v0.7.2 canonical pattern — e.g., `llm` / `memory` auto-wire by default in bundled deployments), or add a `primary` entry to connectors.json that handles the tool.",
557
692
  check: (ctx) => {
558
693
  if (ctx.mcpConnectorNames === undefined)
559
694
  return [];
560
695
  const findings = [];
561
- const reported = new Set();
696
+ // Collect per (target, tool); track whether ALL call sites carry an op-level fallback.
697
+ const groups = new Map();
562
698
  for (const [targetName, target] of ctx.parsed.targets) {
563
699
  walkOps(target.ops, (op) => {
564
700
  if (op.kind !== "$" || op.mcpConnector !== undefined)
@@ -580,17 +716,36 @@ const UNWIRED_PRIMARY_CONNECTOR = {
580
716
  if (ctx.mcpConnectorNames.includes("primary"))
581
717
  return;
582
718
  const key = `${targetName}:${toolName}`;
583
- if (reported.has(key))
584
- return;
585
- reported.add(key);
719
+ const hasFallback = op.fallback !== undefined;
720
+ const existing = groups.get(key);
721
+ if (existing === undefined) {
722
+ groups.set(key, { targetName, toolName, allFallback: hasFallback });
723
+ }
724
+ else if (!hasFallback) {
725
+ existing.allFallback = false;
726
+ }
727
+ });
728
+ }
729
+ for (const { targetName, toolName, allFallback } of groups.values()) {
730
+ const wired = ctx.mcpConnectorNames.length === 0 ? "(none)" : ctx.mcpConnectorNames.join(", ");
731
+ if (allFallback) {
732
+ findings.push({
733
+ rule: "unwired-primary-connector",
734
+ severity: "info",
735
+ message: `\`$ ${toolName}\` in target '${targetName}': connector isn't wired (wired: ${wired}), but every call site declares \`(fallback: ...)\`. Tier-1 lint demoted — at runtime, the fallback value binds when the dispatch errors, so the skill keeps working without the real connector.`,
736
+ block: targetName,
737
+ extras: { tool: toolName, hasFallback: true },
738
+ });
739
+ }
740
+ else {
586
741
  findings.push({
587
742
  rule: "unwired-primary-connector",
588
743
  severity: "error",
589
- message: `\`$ ${toolName}\` in target '${targetName}' is a bare tool op — no connector named '${toolName}' wired and no \`primary\` fallback. Wired connectors: ${ctx.mcpConnectorNames.length === 0 ? "(none)" : ctx.mcpConnectorNames.join(", ")}.`,
744
+ message: `\`$ ${toolName}\` in target '${targetName}' is a bare tool op — no connector named '${toolName}' wired and no \`primary\` fallback. Wired connectors: ${wired}.`,
590
745
  block: targetName,
591
746
  extras: { tool: toolName },
592
747
  });
593
- });
748
+ }
594
749
  }
595
750
  return findings;
596
751
  },
@@ -613,56 +768,12 @@ const UNKNOWN_CONNECTOR_CLASS = {
613
768
  message: msg,
614
769
  })),
615
770
  };
616
- // v0.3.1: tier-3 advisory that fires alongside the demoted tier-2
617
- // unknown-skill-reference / unknown-template-reference. Confirms the
618
- // deferred-resolution path is engaged so cold authors can distinguish
619
- // "intentional forward-ref" from "typo I should fix now."
620
- const DEFERRED_SKILL_REFERENCE = {
621
- id: "deferred-skill-reference",
622
- severity: "info",
623
- description: "An `&` / `$ execute_skill` / `# Templates:` reference targets a skill not currently in the SkillStore; resolution is deferred to execute time (v0.3.1+).",
624
- remediation: "If this is a forward reference, this advisory will clear once the referenced skill is stored. If it's a typo, fix the spelling — the runtime will throw `SkillNotFoundError` at execute time if still missing.",
625
- check: async (ctx) => {
626
- if (ctx.skillStore === undefined)
627
- return [];
628
- const findings = [];
629
- const seenComposition = new Set();
630
- for (const [targetName, target] of ctx.parsed.targets) {
631
- for (const ref of collectAmpRefsFromOps(target.ops)) {
632
- const key = `${ref.via}:${ref.name}`;
633
- if (seenComposition.has(key))
634
- continue;
635
- seenComposition.add(key);
636
- try {
637
- await ctx.skillStore.metadata(ref.name);
638
- }
639
- catch {
640
- findings.push({
641
- rule: "deferred-skill-reference",
642
- severity: "info",
643
- message: `Skill '${ref.name}' referenced via \`${ref.via}\` is not currently in the SkillStore. Lint demoted in v0.3.1 — will resolve at execute time if the skill exists by then, or throw SkillNotFoundError if not. If this is a typo, fix it now; if it's a forward reference, this advisory will clear once you store '${ref.name}'.`,
644
- block: targetName,
645
- extras: { referenced_skill: ref.name, via: ref.via },
646
- });
647
- }
648
- }
649
- }
650
- for (const name of ctx.parsed.templates) {
651
- try {
652
- await ctx.skillStore.metadata(name);
653
- }
654
- catch {
655
- findings.push({
656
- rule: "deferred-skill-reference",
657
- severity: "info",
658
- message: `Skill '${name}' referenced via \`# Templates:\` is not currently in the SkillStore. Lint demoted in v0.3.1 — will resolve at execute time if the skill exists by then, or throw SkillNotFoundError if not. If this is a typo, fix it now; if it's a forward reference, this advisory will clear once you store '${name}'.`,
659
- extras: { referenced_skill: name, via: "# Templates" },
660
- });
661
- }
662
- }
663
- return findings;
664
- },
665
- };
771
+ // v0.3.1 → v0.9.4.1: `deferred-skill-reference` advisory removed. The
772
+ // paired unknown-skill-reference warning's remediation already covers the
773
+ // "this advisory clears once you store the skill" guidance; the second
774
+ // finding was just noise (Perry's `77ed6c65` "4 diagnostics for 2
775
+ // missing skills"). Cold authors now see one warning per missing skill,
776
+ // not two.
666
777
  // v0.3.3 — `|json_parse` filter removed; the shape `$(VAR|json_parse).field`
667
778
  // is statically detectable. Fire a tier-3 advisory pointing at the new
668
779
  // `$ json_parse $(VAR) -> P` op so authors who carried the v0.3.2 pattern
@@ -1232,21 +1343,83 @@ const REFERENCE_TO_DISABLED_SKILL = {
1232
1343
  };
1233
1344
  // ─── Tier-3 rules (info) ────────────────────────────────────────────────────
1234
1345
  const NO_DEFAULT_TARGET = {
1235
- id: "no-default-target",
1236
- severity: "info",
1237
- description: "Multi-target skill resolves entry via fallback (last target) instead of an explicit `default:` declaration. Authors lose intent visibility.",
1238
- remediation: "Add `default: <target-name>` to make the entry point explicit.",
1346
+ id: "missing-default-target",
1347
+ severity: "error",
1348
+ description: "Skill has no explicit `default:` declaration. The parser falls back to the last declared target as the entry point, but the implicit shape is a footgun — the entry point is invisible without reading the bottom of the source.",
1349
+ remediation: "Add `default: <target-name>` at the bottom of the skill to make the entry point explicit. The fallback is preserved for back-compat but the implicit form is no longer supported.",
1239
1350
  check: (ctx) => {
1240
- if (ctx.parsed.targets.size <= 1)
1351
+ // v0.9.2 — P0.9 lift to tier-1. Per qwen single-shot Test A: missing
1352
+ // `default:` silently accepts; runtime picks the last target. Cold
1353
+ // authors lose intent visibility. The parser's `entryTargetExplicit`
1354
+ // field distinguishes explicit-vs-implicit.
1355
+ if (ctx.parsed.targets.size === 0)
1356
+ return []; // no targets → nothing to enter
1357
+ if (ctx.parsed.entryTargetExplicit)
1241
1358
  return [];
1242
- // The parser sets entryTarget to the last declared target when no `default:`
1243
- // line was present. Re-derive that condition from the source.
1244
- // Simpler: ParsedSkill doesn't distinguish explicit vs fallback. The
1245
- // parser's behavior is `entryTarget === null` only when no targets at
1246
- // all; with targets it picks the last. So we can't distinguish at
1247
- // this layer without a parser change. For v1.0-dev, skip the check
1248
- // (parser change deferred to v1.x).
1249
- return [];
1359
+ return [{
1360
+ rule: "missing-default-target",
1361
+ severity: "error",
1362
+ message: "Skill has no explicit `default:` declaration. Entry point resolves via fallback (last declared target). Add `default: <target-name>` to make the entry point explicit.",
1363
+ }];
1364
+ },
1365
+ };
1366
+ // v0.9.2 — P0.6 colon-style kwarg syntax (`limit:20`) silently parses as
1367
+ // part of an adjacent token, then either gets dropped or passed as a
1368
+ // malformed kwarg the connector won't understand. Per qwen Test A
1369
+ // finding (a3a20593). Canonical kwarg form is `key=value` (equals sign).
1370
+ //
1371
+ // Detect: pattern `\w+:\w+` (or `\w+:"..."` or `\w+:[...]`) appearing in
1372
+ // op-body kwarg position. Exclude legitimate uses: quoted strings, the
1373
+ // `(fallback:...)` clause, ratio/time expressions inside string values.
1374
+ const COLON_KWARG_SYNTAX = {
1375
+ id: "colon-kwarg-syntax",
1376
+ severity: "error",
1377
+ description: "Op body uses `key:value` colon syntax for a kwarg. The canonical kwarg form is `key=value`.",
1378
+ remediation: "Rewrite as `key=value` (equals sign). Colon-style is reserved for `(fallback: ...)` trailers and frontmatter keys; it's not valid in kwarg position.",
1379
+ check: (ctx) => {
1380
+ const findings = [];
1381
+ const reported = new Set();
1382
+ for (const [targetName, target] of ctx.parsed.targets) {
1383
+ walkOps(target.ops, (op) => {
1384
+ // Only check `$` ops — function-call ops are tokenized by the parser
1385
+ // already, so colon-style would either fail tokenization or be silently
1386
+ // absorbed.
1387
+ if (op.kind !== "$")
1388
+ return;
1389
+ // Strip quoted strings + bracket/brace literals before scanning so
1390
+ // quotation contents (`"3:30 PM"`), array literals
1391
+ // (`[a, foo:bar, b]`), and JSON object values don't trip the rule.
1392
+ // The lint is targeting colon-in-kwarg-position only.
1393
+ const stripped = op.body
1394
+ .replace(/"[^"]*"/g, '""')
1395
+ .replace(/'[^']*'/g, "''")
1396
+ .replace(/\[[^\]]*\]/g, "[]")
1397
+ .replace(/\{[^}]*\}/g, "{}");
1398
+ // Pattern: identifier followed by `:` followed by a non-space non-colon
1399
+ // char — that's kwarg-position colon. Skip `(fallback: ...)` which
1400
+ // already gets parsed out of the body by the time we see it, but
1401
+ // belt-and-suspenders skip explicit `fallback:` matches too.
1402
+ const re = /(?:^|\s)([A-Za-z_]\w*)\s*:\s*[^\s:][^\s]*/g;
1403
+ let m;
1404
+ while ((m = re.exec(stripped)) !== null) {
1405
+ const key = m[1];
1406
+ if (key === "fallback")
1407
+ continue;
1408
+ const findingKey = `${targetName}:${op.body}:${key}`;
1409
+ if (reported.has(findingKey))
1410
+ continue;
1411
+ reported.add(findingKey);
1412
+ findings.push({
1413
+ rule: "colon-kwarg-syntax",
1414
+ severity: "error",
1415
+ message: `\`${op.body.slice(0, 40)}${op.body.length > 40 ? "..." : ""}\` in target '${targetName}' — kwarg \`${key}:\` uses colon syntax. Rewrite as \`${key}=...\` (the canonical kwarg form is \`key=value\`).`,
1416
+ block: targetName,
1417
+ extras: { kwarg: key },
1418
+ });
1419
+ }
1420
+ });
1421
+ }
1422
+ return findings;
1250
1423
  },
1251
1424
  };
1252
1425
  const DUPLICATE_SKILL_NAME = {
@@ -1283,27 +1456,23 @@ const PLUGIN_COLLISION = {
1283
1456
  const UNUSED_AUGMENTING_HEADER = {
1284
1457
  id: "unused-augmenting-header",
1285
1458
  severity: "warning",
1286
- description: "`# Delivery-context:` or `# Templates:` set on a skill that has no `agent:` or `template:` output declaration. The fields route through `DeliveryPayload`; without an agent-bound output they don't reach a substrate.",
1287
- remediation: "Either add an agent-bound output (`# Output: agent: <name>` or `# Output: template: <name>`) so the augmenting fields fire, or remove `# Delivery-context:` / `# Templates:` from the frontmatter if the skill is genuinely Headless.",
1459
+ description: "`# Event-type:` set on a skill that has no `agent:` or `template:` output declaration. The field flows to `DeliveryMeta.event_type`; without an agent-bound output it doesn't reach a substrate.",
1460
+ remediation: "Either add an agent-bound output (`# Output: agent: <name>` or `# Output: template: <name>`) so the event_type fires, or remove `# Event-type:` from the frontmatter if the skill is genuinely Headless.",
1288
1461
  check: (ctx) => {
1289
1462
  const hasAgentBoundOutput = ctx.parsed.outputs.some((o) => o.kind === "agent" || o.kind === "template");
1290
1463
  if (hasAgentBoundOutput)
1291
1464
  return [];
1292
1465
  const findings = [];
1293
- if (ctx.parsed.deliveryContext !== null) {
1466
+ if (ctx.parsed.eventType !== null) {
1294
1467
  findings.push({
1295
1468
  rule: "unused-augmenting-header",
1296
1469
  severity: "warning",
1297
- message: "`# Delivery-context:` is set but this skill has no `agent:` or `template:` output — the value won't reach any agent.",
1298
- });
1299
- }
1300
- if (ctx.parsed.templates.length > 0) {
1301
- findings.push({
1302
- rule: "unused-augmenting-header",
1303
- severity: "warning",
1304
- message: `\`# Templates:\` lists ${ctx.parsed.templates.length} skill(s) but this skill has no \`agent:\` or \`template:\` output — the field won't reach any agent.`,
1470
+ message: "`# Event-type:` is set but this skill has no `agent:` or `template:` output — the value won't reach any agent.",
1305
1471
  });
1306
1472
  }
1473
+ // v0.9.6 — `# Templates:` no longer flows through DeliveryPayload (Q10);
1474
+ // the list is consulted by `unknown-template-reference` lint for skill-
1475
+ // existence validation, but it's not delivery-bound metadata anymore.
1307
1476
  return findings;
1308
1477
  },
1309
1478
  };
@@ -1337,6 +1506,269 @@ const OUTPUT_AGENT_TARGET_NO_EMIT = {
1337
1506
  return findings;
1338
1507
  },
1339
1508
  };
1509
+ // v0.9.3 — P1.2 numeric-subscript dotted-ref like `${ARRAY.0}` or
1510
+ // `${LATEST.items.0}`. The substitution machinery's resolveRef does
1511
+ // string-keyed property access; arrays handle string keys ("0" coerces
1512
+ // to index 0) at runtime, so single-step `${ARR.0}` may resolve when
1513
+ // `ARR` is bound to an array — but multi-step `${LATEST.items.0.field}`
1514
+ // or chained subscripts are fragile and surface as silent failures.
1515
+ // Per R8 minion #5: cold author wrote `${LATEST.items.0}` against a
1516
+ // query result; got UnresolvedVariableError. Foreach iteration is the
1517
+ // canonical pattern for indexed access.
1518
+ //
1519
+ // Tier-2 warning: cold authors get a clear nudge toward `foreach`
1520
+ // instead of guessing at numeric subscripts.
1521
+ // v0.9.4 — N8 tier-3 advisory: skill with this name already exists in
1522
+ // the SkillStore. `skill_write` will reject with "already exists. Pass
1523
+ // overwrite=true to replace." Cold authors iterating on a new skill
1524
+ // otherwise discover the collision only at write time — round-trip cost
1525
+ // per R8 minion #6 finding. The lint surfaces it earlier so authors
1526
+ // can either rename or pass overwrite=true intentionally.
1527
+ const SKILL_NAME_COLLISION = {
1528
+ id: "skill-name-collision",
1529
+ severity: "info",
1530
+ description: "A skill with this name already exists in the SkillStore. `skill_write` will reject unless `overwrite=true` is passed.",
1531
+ remediation: "If you intended to replace the existing skill, pass `overwrite=true` to `skill_write`. If not, rename the skill (change the `# Skill:` header value).",
1532
+ check: async (ctx) => {
1533
+ if (ctx.skillStore === undefined)
1534
+ return [];
1535
+ if (ctx.parsed.name === null)
1536
+ return [];
1537
+ // CLI lint is the "I'm just checking a file" surface; it shouldn't
1538
+ // surface write-preflight warnings. Cold-author write-preflight goes
1539
+ // through MCP lint_skill (callSite "api") or compile-preflight.
1540
+ if (ctx.callSite === "cli")
1541
+ return [];
1542
+ try {
1543
+ // Compare the stored body to the source being linted. If identical,
1544
+ // the user is re-linting their already-stored skill — no collision
1545
+ // worth surfacing. Only fire when the stored body materially differs.
1546
+ const stored = await ctx.skillStore.load(ctx.parsed.name);
1547
+ if (stored.source === ctx.source)
1548
+ return [];
1549
+ return [{
1550
+ rule: "skill-name-collision",
1551
+ severity: "info",
1552
+ message: `Skill '${ctx.parsed.name}' already exists in the SkillStore with a different body. \`skill_write\` will reject without \`overwrite=true\`. Rename, or pass \`overwrite=true\` intentionally.`,
1553
+ }];
1554
+ }
1555
+ catch {
1556
+ // Not found → no collision
1557
+ return [];
1558
+ }
1559
+ },
1560
+ };
1561
+ // v0.9.4 — N5 tier-3 advisory on `$set VAR = [{...}]` JSON-object-array
1562
+ // literals. The parser's processSetValue doesn't JSON-parse the value —
1563
+ // it strips outer quotes and otherwise treats it as a literal. So
1564
+ // `$set ISSUES = [{"id":"X","status":"open"}]` binds the STRING
1565
+ // `[{"id":"X","status":"open"}]` rather than a structured array.
1566
+ // Cold authors expect JS-class literal parsing; the gap is silent.
1567
+ // Per haiku/qwen finding N5 in 9086b3f8.
1568
+ const SET_JSON_LITERAL_ADVISORY = {
1569
+ id: "set-json-literal-advisory",
1570
+ severity: "info",
1571
+ description: "`$set VAR = [{...}]` binds the literal string form, not a parsed JSON structure. Skillscript's `$set` is literal-only — JS-class object/array literals don't auto-parse.",
1572
+ remediation: "If you need a parsed structure, use `$ json_parse '[{...}]' -> VAR` (parses the JSON and binds the structured value). If literal string accumulation is the intent, the current `$set` behavior is fine — this advisory just clarifies the semantic.",
1573
+ check: (ctx) => {
1574
+ const findings = [];
1575
+ const reported = new Set();
1576
+ for (const [targetName, target] of ctx.parsed.targets) {
1577
+ walkOps(target.ops, (op) => {
1578
+ if (op.kind !== "$set")
1579
+ return;
1580
+ const value = op.setValue ?? "";
1581
+ // Match the START of the value being an array-of-objects literal.
1582
+ if (!/^\s*\[\s*\{/.test(value))
1583
+ return;
1584
+ const key = `${targetName}:${op.setName}`;
1585
+ if (reported.has(key))
1586
+ return;
1587
+ reported.add(key);
1588
+ findings.push({
1589
+ rule: "set-json-literal-advisory",
1590
+ severity: "info",
1591
+ message: `\`$set ${op.setName} = [{...}]\` in target '${targetName}' binds the literal string form, not parsed JSON. If you need a structured array of objects, use \`$ json_parse '[{...}]' -> ${op.setName}\` instead.`,
1592
+ block: targetName,
1593
+ });
1594
+ });
1595
+ }
1596
+ return findings;
1597
+ },
1598
+ };
1599
+ // v0.9.4 — N3 tier-2 advisory on `${R.transcript}` in composition. The
1600
+ // composition result shape exposes `transcript` as the emissions array
1601
+ // (per docs); cold authors interpolate it as "the human-readable text"
1602
+ // and get JSON-ish array stringification. The semantic mismatch is
1603
+ // silent — lint and runtime accept the ref, but the rendered output is
1604
+ // wrong-shape. Per R8 minion #6 finding in 9086b3f8.
1605
+ //
1606
+ // Detect: `${VAR.transcript}` references where VAR was bound by a
1607
+ // `$ execute_skill ... -> VAR` op (i.e., the child-skill result envelope).
1608
+ // The conservative-but-actionable form: match any `${VAR.transcript}`
1609
+ // substitution and advise. False positives possible (if a user binds a
1610
+ // non-composition var named with `.transcript`), but the suggestion
1611
+ // reads as a helpful nudge either way.
1612
+ const TRANSCRIPT_FOOTGUN = {
1613
+ id: "transcript-footgun",
1614
+ severity: "warning",
1615
+ description: "Substitution ref `${VAR.transcript}` against a composition-result var renders as a JSON-ish array string, not the human-readable text the field name suggests. The child skill's emissions are an array.",
1616
+ remediation: "Bind the value you need explicitly in the child skill (e.g., `$set RESULT_TEXT = ...` followed by access via `${R.final_vars.RESULT_TEXT}`). For joined text, use `${R.outputs.text}` (single-string output) or iterate `foreach LINE in ${R.transcript}:` to consume per-line.",
1617
+ check: (ctx) => {
1618
+ const findings = [];
1619
+ const reported = new Set();
1620
+ // Pattern: ${ANY.transcript} or $(ANY.transcript) — both legacy and canonical
1621
+ const re = /\$[({][A-Za-z_]\w*\.transcript[)}]/g;
1622
+ const scanString = (s, targetName) => {
1623
+ let m;
1624
+ while ((m = re.exec(s)) !== null) {
1625
+ const key = `${targetName}:${m[0]}`;
1626
+ if (reported.has(key))
1627
+ continue;
1628
+ reported.add(key);
1629
+ findings.push({
1630
+ rule: "transcript-footgun",
1631
+ severity: "warning",
1632
+ message: `Substitution ref \`${m[0]}\` in target '${targetName}' renders as a JSON-ish array, not human-readable text. \`transcript\` is the child skill's emissions array. Use \`final_vars.NAMED_VAR\` (bind explicitly in child), \`outputs.text\` (joined string), or iterate via \`foreach\`.`,
1633
+ block: targetName,
1634
+ });
1635
+ }
1636
+ };
1637
+ for (const [targetName, target] of ctx.parsed.targets) {
1638
+ walkOps(target.ops, (op) => {
1639
+ if (op.body !== undefined)
1640
+ scanString(op.body, targetName);
1641
+ if (op.setValue !== undefined)
1642
+ scanString(op.setValue, targetName);
1643
+ });
1644
+ }
1645
+ return findings;
1646
+ },
1647
+ };
1648
+ const NUMERIC_SUBSCRIPT = {
1649
+ id: "numeric-subscript",
1650
+ severity: "warning",
1651
+ description: "A `${VAR.N}` substitution ref uses a numeric segment (e.g. `${ARR.0}` or `${LATEST.items.0}`). Numeric subscripts are not a first-class language feature — `foreach IT in ${VAR}` is the canonical iteration pattern.",
1652
+ remediation: "Replace with `foreach IT in ${VAR}:` to iterate, or with `$set FIRST = ${VAR|first}` (when first-only is the intent). If a specific JSON-array element is unavoidable, bind it via an intermediary `$ json_parse` op + dotted descent against the parsed structure.",
1653
+ check: (ctx) => {
1654
+ const findings = [];
1655
+ const reported = new Set();
1656
+ // Pattern: `${X.0...}` or `${X.items.5}` etc — any segment that's
1657
+ // all-digits inside a brace-form substitution. Skip $(...) legacy
1658
+ // form since it's already tier-2 deprecated.
1659
+ const re = /\$\{([A-Za-z_]\w*(?:\.\w+)+)/g;
1660
+ const scanString = (s, targetName) => {
1661
+ let m;
1662
+ while ((m = re.exec(s)) !== null) {
1663
+ const ref = m[1];
1664
+ const segments = ref.split(".");
1665
+ // First segment is var name (can't be numeric); look at the rest
1666
+ const hasNumeric = segments.slice(1).some((seg) => /^\d+$/.test(seg));
1667
+ if (!hasNumeric)
1668
+ continue;
1669
+ const key = `${targetName}:${ref}`;
1670
+ if (reported.has(key))
1671
+ continue;
1672
+ reported.add(key);
1673
+ findings.push({
1674
+ rule: "numeric-subscript",
1675
+ severity: "warning",
1676
+ message: `Substitution ref \`\${${ref}}\` in target '${targetName}' uses a numeric segment. Numeric subscripts aren't first-class; use \`foreach\` iteration or bind via \`$ json_parse\` for indexed access against parsed JSON.`,
1677
+ block: targetName,
1678
+ extras: { ref },
1679
+ });
1680
+ }
1681
+ };
1682
+ for (const [targetName, target] of ctx.parsed.targets) {
1683
+ walkOps(target.ops, (op) => {
1684
+ // Scan body + kwargs (which live in body for $ ops; in setValue for $set/$append)
1685
+ if (op.body !== undefined)
1686
+ scanString(op.body, targetName);
1687
+ if (op.setValue !== undefined)
1688
+ scanString(op.setValue, targetName);
1689
+ });
1690
+ }
1691
+ return findings;
1692
+ },
1693
+ };
1694
+ // v0.9.3 — P1.3 canonicalize `recipients=[...]` over `addressed_to="..."`
1695
+ // for `$ memory_write` dispatch. The bundled MemoryStoreMcpConnector only
1696
+ // reads `args["recipients"]` (line 132 of memory-store-mcp.ts), so
1697
+ // `addressed_to=...` was always a doc-bug: it parsed but silently
1698
+ // dropped. Help docs had it pre-v0.9.3 (`help({topic:"connectors"})`
1699
+ // line 318) — fixed in this same ship. Lint catches any cold author
1700
+ // who picked the wrong shape from older docs / muscle memory.
1701
+ //
1702
+ // Tier-2 warning, not tier-1 — adopter substrates may genuinely accept
1703
+ // `addressed_to` if they wire a custom MemoryStoreMcpConnector. The
1704
+ // lint nudges toward the bundled-canonical shape without breaking
1705
+ // adopter freedom.
1706
+ const DEPRECATED_ADDRESSED_TO = {
1707
+ id: "deprecated-addressed-to",
1708
+ severity: "warning",
1709
+ description: "`$ memory_write addressed_to=...` is not the canonical kwarg for the bundled MemoryStoreMcpConnector. The bundled bridge reads `recipients=[...]` (array). `addressed_to` may parse but silently drops in default deployments.",
1710
+ remediation: "Rewrite as `$ memory_write content=\"...\" recipients=[<agent_id>, ...] -> R`. The bracket-array form is the canonical shape that the bundled `MemoryStoreMcpConnector` reads. Adopters with a custom memory bridge that genuinely accepts `addressed_to` can wire it; this lint is a nudge toward the default contract.",
1711
+ check: (ctx) => {
1712
+ const findings = [];
1713
+ const reported = new Set();
1714
+ for (const [targetName, target] of ctx.parsed.targets) {
1715
+ walkOps(target.ops, (op) => {
1716
+ if (op.kind !== "$")
1717
+ return;
1718
+ const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
1719
+ if (m === null)
1720
+ return;
1721
+ const toolName = m[1];
1722
+ // Only fire on memory_write — adopters may have other tools that
1723
+ // legitimately accept addressed_to.
1724
+ if (toolName !== "memory_write")
1725
+ return;
1726
+ if (!/\baddressed_to\s*=/.test(op.body))
1727
+ return;
1728
+ const key = `${targetName}:${op.body}`;
1729
+ if (reported.has(key))
1730
+ return;
1731
+ reported.add(key);
1732
+ findings.push({
1733
+ rule: "deprecated-addressed-to",
1734
+ severity: "warning",
1735
+ message: `\`$ memory_write ... addressed_to=...\` in target '${targetName}' — the bundled MemoryStoreMcpConnector reads \`recipients=[...]\`, not \`addressed_to=\`. Use \`recipients=[<agent_id>, ...]\` (bracket-array form).`,
1736
+ block: targetName,
1737
+ });
1738
+ });
1739
+ }
1740
+ return findings;
1741
+ },
1742
+ };
1743
+ // v0.9.6 — legacy frontmatter header advisory per Perry's `ce41bd4d` signoff
1744
+ // probe #4. `# Delivery-context:` was renamed to `# Event-type:` in v0.9.6
1745
+ // (audit Q9). The parser silently ignores unknown headers; without this lint
1746
+ // cold authors migrating from stale docs hit silent semantic drift — write
1747
+ // `# Delivery-context:`, runtime drops it, then debug-loop on why
1748
+ // `meta.event_type` is empty. Same silent-permissiveness anti-pattern that
1749
+ // v0.9.5's fallback-demotion advisory landed against.
1750
+ const LEGACY_FRONTMATTER_HEADER = {
1751
+ id: "legacy-frontmatter-header",
1752
+ severity: "warning",
1753
+ description: "A frontmatter header was renamed in a previous version; the legacy name is silently ignored at runtime. Migrate to the current name.",
1754
+ remediation: "Rename the header per the message. The parser silently drops unknown headers, so this advisory is the only signal that the field isn't reaching its destination.",
1755
+ check: (ctx) => {
1756
+ const findings = [];
1757
+ const lines = ctx.source.split("\n");
1758
+ for (let i = 0; i < lines.length; i++) {
1759
+ const line = lines[i];
1760
+ if (/^#\s*Delivery-context:/i.test(line)) {
1761
+ findings.push({
1762
+ rule: "legacy-frontmatter-header",
1763
+ severity: "warning",
1764
+ message: "Header `# Delivery-context:` was renamed to `# Event-type:` in v0.9.6. The legacy header is silently ignored at runtime — migrate to `# Event-type:` so the value flows to `meta.event_type` on delivery.",
1765
+ extras: { legacy_header: "# Delivery-context:", new_header: "# Event-type:", line: i + 1 },
1766
+ });
1767
+ }
1768
+ }
1769
+ return findings;
1770
+ },
1771
+ };
1340
1772
  const OUTPUT_AGENT_TARGET_NO_CONNECTOR = {
1341
1773
  id: "output-agent-target-no-connector",
1342
1774
  severity: "warning",
@@ -1915,12 +2347,13 @@ const RULES = [
1915
2347
  RESERVED_KEYWORD,
1916
2348
  UNKNOWN_SKILL_REFERENCE,
1917
2349
  UNKNOWN_TEMPLATE_REFERENCE,
1918
- DEFERRED_SKILL_REFERENCE,
1919
2350
  UNKNOWN_RETRIEVAL_ARG,
1920
2351
  UNKNOWN_CONNECTOR,
1921
2352
  UNKNOWN_CONNECTOR_CLASS,
1922
2353
  UNWIRED_PRIMARY_CONNECTOR,
1923
2354
  DISALLOWED_TOOL,
2355
+ UNKNOWN_TOOL_ON_CONNECTOR,
2356
+ UNVERIFIED_QUALIFIED_TOOL,
1924
2357
  UNINITIALIZED_APPEND,
1925
2358
  FOREACH_LOCAL_ACCUMULATOR_TARGET,
1926
2359
  APPEND_TO_NON_LIST,
@@ -1945,8 +2378,16 @@ const RULES = [
1945
2378
  UNUSED_AUGMENTING_HEADER,
1946
2379
  OUTPUT_AGENT_TARGET_NO_EMIT,
1947
2380
  OUTPUT_AGENT_TARGET_NO_CONNECTOR,
1948
- // Tier-3 (info)
2381
+ NUMERIC_SUBSCRIPT,
2382
+ DEPRECATED_ADDRESSED_TO,
2383
+ LEGACY_FRONTMATTER_HEADER,
2384
+ TRANSCRIPT_FOOTGUN,
2385
+ SET_JSON_LITERAL_ADVISORY,
2386
+ SKILL_NAME_COLLISION,
2387
+ // v0.9.2 — promoted from tier-3 info to tier-1 error (P0.9 in c9c667d2)
1949
2388
  NO_DEFAULT_TARGET,
2389
+ COLON_KWARG_SYNTAX,
2390
+ // Tier-3 (info)
1950
2391
  DUPLICATE_SKILL_NAME,
1951
2392
  PLUGIN_COLLISION,
1952
2393
  UNPARSED_JSON_FIELD_ACCESS,
@@ -2127,6 +2568,21 @@ function collectMcpConnectorAllowedToolsFromRegistry(registry) {
2127
2568
  }
2128
2569
  return out;
2129
2570
  }
2571
+ function collectMcpConnectorStaticToolsFromRegistry(registry) {
2572
+ const out = new Map();
2573
+ if (registry === undefined)
2574
+ return out;
2575
+ for (const e of registry.listMcpConnectors()) {
2576
+ const ctor = e.ctor;
2577
+ if (ctor.staticTools !== undefined) {
2578
+ out.set(e.name, ctor.staticTools());
2579
+ }
2580
+ else {
2581
+ out.set(e.name, null);
2582
+ }
2583
+ }
2584
+ return out;
2585
+ }
2130
2586
  function buildFeatureSet(classes) {
2131
2587
  const provided = new Set();
2132
2588
  for (const Ctor of classes) {