skillscript-runtime 0.7.3 → 0.9.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.
Files changed (89) 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 +25 -6
  8. package/dist/bootstrap.js.map +1 -1
  9. package/dist/compile.d.ts.map +1 -1
  10. package/dist/compile.js +25 -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 +52 -1
  15. package/dist/composition.js.map +1 -1
  16. package/dist/connectors/agent-noop.d.ts +1 -1
  17. package/dist/connectors/agent-noop.js +1 -1
  18. package/dist/connectors/agent.d.ts +1 -1
  19. package/dist/connectors/agent.js +1 -1
  20. package/dist/connectors/agent.js.map +1 -1
  21. package/dist/connectors/local-model-mcp.d.ts +8 -0
  22. package/dist/connectors/local-model-mcp.d.ts.map +1 -1
  23. package/dist/connectors/local-model-mcp.js +10 -0
  24. package/dist/connectors/local-model-mcp.js.map +1 -1
  25. package/dist/connectors/memory-store-mcp.d.ts +12 -1
  26. package/dist/connectors/memory-store-mcp.d.ts.map +1 -1
  27. package/dist/connectors/memory-store-mcp.js +46 -1
  28. package/dist/connectors/memory-store-mcp.js.map +1 -1
  29. package/dist/connectors/memory-store.d.ts +9 -1
  30. package/dist/connectors/memory-store.d.ts.map +1 -1
  31. package/dist/connectors/memory-store.js +33 -0
  32. package/dist/connectors/memory-store.js.map +1 -1
  33. package/dist/connectors/registry.d.ts +8 -1
  34. package/dist/connectors/registry.d.ts.map +1 -1
  35. package/dist/connectors/registry.js +10 -1
  36. package/dist/connectors/registry.js.map +1 -1
  37. package/dist/connectors/skill-store.d.ts.map +1 -1
  38. package/dist/connectors/skill-store.js +43 -13
  39. package/dist/connectors/skill-store.js.map +1 -1
  40. package/dist/connectors/types.d.ts +43 -0
  41. package/dist/connectors/types.d.ts.map +1 -1
  42. package/dist/connectors/types.js.map +1 -1
  43. package/dist/dashboard/spa/app.js +37 -9
  44. package/dist/dispatch-validate.d.ts +64 -0
  45. package/dist/dispatch-validate.d.ts.map +1 -0
  46. package/dist/dispatch-validate.js +81 -0
  47. package/dist/dispatch-validate.js.map +1 -0
  48. package/dist/errors.d.ts +11 -0
  49. package/dist/errors.d.ts.map +1 -1
  50. package/dist/errors.js +16 -0
  51. package/dist/errors.js.map +1 -1
  52. package/dist/help-content.d.ts.map +1 -1
  53. package/dist/help-content.js +95 -18
  54. package/dist/help-content.js.map +1 -1
  55. package/dist/index.d.ts +3 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/lint.d.ts +14 -0
  60. package/dist/lint.d.ts.map +1 -1
  61. package/dist/lint.js +591 -98
  62. package/dist/lint.js.map +1 -1
  63. package/dist/mcp-server.d.ts.map +1 -1
  64. package/dist/mcp-server.js +53 -11
  65. package/dist/mcp-server.js.map +1 -1
  66. package/dist/parser.d.ts +30 -3
  67. package/dist/parser.d.ts.map +1 -1
  68. package/dist/parser.js +143 -8
  69. package/dist/parser.js.map +1 -1
  70. package/dist/runtime.d.ts +32 -2
  71. package/dist/runtime.d.ts.map +1 -1
  72. package/dist/runtime.js +128 -26
  73. package/dist/runtime.js.map +1 -1
  74. package/dist/scheduler.d.ts +20 -1
  75. package/dist/scheduler.d.ts.map +1 -1
  76. package/dist/scheduler.js +34 -1
  77. package/dist/scheduler.js.map +1 -1
  78. package/examples/classify-support-ticket.skill.md +2 -2
  79. package/examples/cut-release-tag.skill.md +1 -1
  80. package/examples/doc-qa-with-citations.skill.md +1 -1
  81. package/examples/feedback-sentiment-scan.skill.md +2 -2
  82. package/examples/hello.skill.provenance.json +1 -1
  83. package/examples/morning-brief.skill.md +3 -3
  84. package/examples/onboarding-scaffold/file-memory-store.ts +32 -1
  85. package/examples/queue-length-monitor.skill.md +1 -1
  86. package/examples/service-health-watch.skill.md +1 -1
  87. package/examples/youtrack-morning-sweep.skill.md +1 -1
  88. package/package.json +1 -1
  89. 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,
@@ -13,6 +14,8 @@ export async function lint(source, options) {
13
14
  mcpConnectorNames: options?.mcpConnectorNames ?? collectMcpConnectorNamesFromRegistry(options?.registry),
14
15
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
15
16
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
17
+ agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
18
+ mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
16
19
  };
17
20
  const findings = [];
18
21
  for (const rule of RULES) {
@@ -41,6 +44,7 @@ export function lintSync(source, options) {
41
44
  const parsed = parse(source);
42
45
  const ctx = {
43
46
  parsed,
47
+ source,
44
48
  capabilityClasses: options?.classes ?? collectClassesFromRegistry(options?.registry),
45
49
  skillStore: options?.skillStore,
46
50
  hasSkillStore: options?.skillStore !== undefined,
@@ -49,6 +53,8 @@ export function lintSync(source, options) {
49
53
  mcpConnectorNames: options?.mcpConnectorNames ?? collectMcpConnectorNamesFromRegistry(options?.registry),
50
54
  connectorConfigErrors: options?.connectorConfigErrors ?? [],
51
55
  mcpConnectorAllowedTools: options?.mcpConnectorAllowedTools ?? collectMcpConnectorAllowedToolsFromRegistry(options?.registry),
56
+ agentConnectorNames: options?.agentConnectorNames ?? collectAgentConnectorNamesFromRegistry(options?.registry),
57
+ mcpConnectorStaticTools: collectMcpConnectorStaticToolsFromRegistry(options?.registry),
52
58
  };
53
59
  const findings = [];
54
60
  for (const rule of RULES) {
@@ -377,36 +383,47 @@ const INDENTATION = {
377
383
  };
378
384
  // v0.3.1: demoted tier-1 → tier-2. Forward-references are allowed; the
379
385
  // runtime throws `SkillNotFoundError` if the ref still can't resolve at
380
- // execute time. The tier-3 `deferred-skill-reference` advisory below
381
- // 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).
382
390
  const UNKNOWN_SKILL_REFERENCE = {
383
391
  id: "unknown-skill-reference",
384
392
  severity: "warning",
385
- 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).",
386
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.",
387
395
  check: async (ctx) => {
388
396
  if (ctx.skillStore === undefined)
389
397
  return [];
390
398
  const findings = [];
391
- 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();
392
405
  for (const [targetName, target] of ctx.parsed.targets) {
393
406
  for (const ref of collectAmpRefsFromOps(target.ops)) {
394
- const key = `${ref.via}:${ref.name}`;
395
- if (seen.has(key))
396
- continue;
397
- seen.add(key);
398
- try {
399
- await ctx.skillStore.metadata(ref.name);
400
- }
401
- catch {
402
- findings.push({
403
- rule: "unknown-skill-reference",
404
- severity: "warning",
405
- message: `Skill '${targetName}' references skill '${ref.name}' via \`${ref.via}\`, but the SkillStore has no skill by that name.`,
406
- block: targetName,
407
- extras: { referenced_skill: ref.name, via: ref.via },
408
- });
409
- }
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
+ });
410
427
  }
411
428
  }
412
429
  return findings;
@@ -529,6 +546,117 @@ const DISALLOWED_TOOL = {
529
546
  return findings;
530
547
  },
531
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
+ };
532
660
  // v0.5.0 item 5 — bare `$ TOOL` op (no connector prefix) when no
533
661
  // `primary` connector is wired. Runtime now throws ConnectorNotFoundError
534
662
  // instead of silent-stub (was: emitted "Would call tool X" + bound null,
@@ -547,16 +675,26 @@ const DISALLOWED_TOOL = {
547
675
  // (lint context has real registry info) — embedder contexts that don't
548
676
  // expose the registry stay silent rather than risk noise on legitimate
549
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).
550
687
  const UNWIRED_PRIMARY_CONNECTOR = {
551
688
  id: "unwired-primary-connector",
552
689
  severity: "error",
553
- 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.",
554
- 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.",
555
692
  check: (ctx) => {
556
693
  if (ctx.mcpConnectorNames === undefined)
557
694
  return [];
558
695
  const findings = [];
559
- const reported = new Set();
696
+ // Collect per (target, tool); track whether ALL call sites carry an op-level fallback.
697
+ const groups = new Map();
560
698
  for (const [targetName, target] of ctx.parsed.targets) {
561
699
  walkOps(target.ops, (op) => {
562
700
  if (op.kind !== "$" || op.mcpConnector !== undefined)
@@ -578,17 +716,36 @@ const UNWIRED_PRIMARY_CONNECTOR = {
578
716
  if (ctx.mcpConnectorNames.includes("primary"))
579
717
  return;
580
718
  const key = `${targetName}:${toolName}`;
581
- if (reported.has(key))
582
- return;
583
- 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 {
584
741
  findings.push({
585
742
  rule: "unwired-primary-connector",
586
743
  severity: "error",
587
- 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}.`,
588
745
  block: targetName,
589
746
  extras: { tool: toolName },
590
747
  });
591
- });
748
+ }
592
749
  }
593
750
  return findings;
594
751
  },
@@ -611,56 +768,12 @@ const UNKNOWN_CONNECTOR_CLASS = {
611
768
  message: msg,
612
769
  })),
613
770
  };
614
- // v0.3.1: tier-3 advisory that fires alongside the demoted tier-2
615
- // unknown-skill-reference / unknown-template-reference. Confirms the
616
- // deferred-resolution path is engaged so cold authors can distinguish
617
- // "intentional forward-ref" from "typo I should fix now."
618
- const DEFERRED_SKILL_REFERENCE = {
619
- id: "deferred-skill-reference",
620
- severity: "info",
621
- description: "An `&` / `$ execute_skill` / `# Templates:` reference targets a skill not currently in the SkillStore; resolution is deferred to execute time (v0.3.1+).",
622
- 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.",
623
- check: async (ctx) => {
624
- if (ctx.skillStore === undefined)
625
- return [];
626
- const findings = [];
627
- const seenComposition = new Set();
628
- for (const [targetName, target] of ctx.parsed.targets) {
629
- for (const ref of collectAmpRefsFromOps(target.ops)) {
630
- const key = `${ref.via}:${ref.name}`;
631
- if (seenComposition.has(key))
632
- continue;
633
- seenComposition.add(key);
634
- try {
635
- await ctx.skillStore.metadata(ref.name);
636
- }
637
- catch {
638
- findings.push({
639
- rule: "deferred-skill-reference",
640
- severity: "info",
641
- 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}'.`,
642
- block: targetName,
643
- extras: { referenced_skill: ref.name, via: ref.via },
644
- });
645
- }
646
- }
647
- }
648
- for (const name of ctx.parsed.templates) {
649
- try {
650
- await ctx.skillStore.metadata(name);
651
- }
652
- catch {
653
- findings.push({
654
- rule: "deferred-skill-reference",
655
- severity: "info",
656
- 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}'.`,
657
- extras: { referenced_skill: name, via: "# Templates" },
658
- });
659
- }
660
- }
661
- return findings;
662
- },
663
- };
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.
664
777
  // v0.3.3 — `|json_parse` filter removed; the shape `$(VAR|json_parse).field`
665
778
  // is statically detectable. Fire a tier-3 advisory pointing at the new
666
779
  // `$ json_parse $(VAR) -> P` op so authors who carried the v0.3.2 pattern
@@ -1230,21 +1343,83 @@ const REFERENCE_TO_DISABLED_SKILL = {
1230
1343
  };
1231
1344
  // ─── Tier-3 rules (info) ────────────────────────────────────────────────────
1232
1345
  const NO_DEFAULT_TARGET = {
1233
- id: "no-default-target",
1234
- severity: "info",
1235
- description: "Multi-target skill resolves entry via fallback (last target) instead of an explicit `default:` declaration. Authors lose intent visibility.",
1236
- 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.",
1237
1350
  check: (ctx) => {
1238
- 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)
1239
1358
  return [];
1240
- // The parser sets entryTarget to the last declared target when no `default:`
1241
- // line was present. Re-derive that condition from the source.
1242
- // Simpler: ParsedSkill doesn't distinguish explicit vs fallback. The
1243
- // parser's behavior is `entryTarget === null` only when no targets at
1244
- // all; with targets it picks the last. So we can't distinguish at
1245
- // this layer without a parser change. For v1.0-dev, skip the check
1246
- // (parser change deferred to v1.x).
1247
- 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;
1248
1423
  },
1249
1424
  };
1250
1425
  const DUPLICATE_SKILL_NAME = {
@@ -1281,10 +1456,10 @@ const PLUGIN_COLLISION = {
1281
1456
  const UNUSED_AUGMENTING_HEADER = {
1282
1457
  id: "unused-augmenting-header",
1283
1458
  severity: "warning",
1284
- description: "`# Delivery-context:` or `# Templates:` set on a skill that has no `prompt-context:` or `template:` output declaration. The fields route through `DeliveryPayload`; without an agent-bound output they don't reach a substrate.",
1285
- remediation: "Either add an agent-bound output (`# Output: prompt-context: <agent>` or `# Output: template: <agent>`) so the augmenting fields fire, or remove `# Delivery-context:` / `# Templates:` from the frontmatter if the skill is genuinely Headless.",
1459
+ 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.",
1460
+ 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.",
1286
1461
  check: (ctx) => {
1287
- const hasAgentBoundOutput = ctx.parsed.outputs.some((o) => o.kind === "prompt-context" || o.kind === "template");
1462
+ const hasAgentBoundOutput = ctx.parsed.outputs.some((o) => o.kind === "agent" || o.kind === "template");
1288
1463
  if (hasAgentBoundOutput)
1289
1464
  return [];
1290
1465
  const findings = [];
@@ -1292,14 +1467,300 @@ const UNUSED_AUGMENTING_HEADER = {
1292
1467
  findings.push({
1293
1468
  rule: "unused-augmenting-header",
1294
1469
  severity: "warning",
1295
- message: "`# Delivery-context:` is set but this skill has no `prompt-context:` or `template:` output — the value won't reach any agent.",
1470
+ message: "`# Delivery-context:` is set but this skill has no `agent:` or `template:` output — the value won't reach any agent.",
1296
1471
  });
1297
1472
  }
1298
1473
  if (ctx.parsed.templates.length > 0) {
1299
1474
  findings.push({
1300
1475
  rule: "unused-augmenting-header",
1301
1476
  severity: "warning",
1302
- message: `\`# Templates:\` lists ${ctx.parsed.templates.length} skill(s) but this skill has no \`prompt-context:\` or \`template:\` output — the field won't reach any agent.`,
1477
+ 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.`,
1478
+ });
1479
+ }
1480
+ return findings;
1481
+ },
1482
+ };
1483
+ // v0.8.0 — tier-2 lint warns per the delivery-model lockdown (`bb34de4e`).
1484
+ const OUTPUT_AGENT_TARGET_NO_EMIT = {
1485
+ id: "output-agent-target-no-emit",
1486
+ severity: "warning",
1487
+ description: "`# Output: agent: <name>` or `# Output: template: <name>` declared but skill has no `emit()` ops; delivery fires with empty content.",
1488
+ remediation: "Add at least one `emit(text=\"...\")` op so the skill produces content for the lifecycle hook delivery, or remove the `# Output:` header if the skill produces no agent-targeted output.",
1489
+ check: (ctx) => {
1490
+ const findings = [];
1491
+ const agentBoundOutputs = ctx.parsed.outputs.filter((o) => (o.kind === "agent" || o.kind === "template") && o.target !== undefined);
1492
+ if (agentBoundOutputs.length === 0)
1493
+ return findings;
1494
+ let hasEmit = false;
1495
+ for (const [, target] of ctx.parsed.targets) {
1496
+ walkOps(target.ops, (op) => { if (op.kind === "!")
1497
+ hasEmit = true; });
1498
+ if (hasEmit)
1499
+ break;
1500
+ }
1501
+ if (hasEmit)
1502
+ return findings;
1503
+ for (const decl of agentBoundOutputs) {
1504
+ findings.push({
1505
+ rule: "output-agent-target-no-emit",
1506
+ severity: "warning",
1507
+ message: `\`# Output: ${decl.kind}: ${decl.target}\` declared but skill has no \`emit()\` ops; delivery fires with empty content.`,
1508
+ });
1509
+ }
1510
+ return findings;
1511
+ },
1512
+ };
1513
+ // v0.9.3 — P1.2 numeric-subscript dotted-ref like `${ARRAY.0}` or
1514
+ // `${LATEST.items.0}`. The substitution machinery's resolveRef does
1515
+ // string-keyed property access; arrays handle string keys ("0" coerces
1516
+ // to index 0) at runtime, so single-step `${ARR.0}` may resolve when
1517
+ // `ARR` is bound to an array — but multi-step `${LATEST.items.0.field}`
1518
+ // or chained subscripts are fragile and surface as silent failures.
1519
+ // Per R8 minion #5: cold author wrote `${LATEST.items.0}` against a
1520
+ // query result; got UnresolvedVariableError. Foreach iteration is the
1521
+ // canonical pattern for indexed access.
1522
+ //
1523
+ // Tier-2 warning: cold authors get a clear nudge toward `foreach`
1524
+ // instead of guessing at numeric subscripts.
1525
+ // v0.9.4 — N8 tier-3 advisory: skill with this name already exists in
1526
+ // the SkillStore. `skill_write` will reject with "already exists. Pass
1527
+ // overwrite=true to replace." Cold authors iterating on a new skill
1528
+ // otherwise discover the collision only at write time — round-trip cost
1529
+ // per R8 minion #6 finding. The lint surfaces it earlier so authors
1530
+ // can either rename or pass overwrite=true intentionally.
1531
+ const SKILL_NAME_COLLISION = {
1532
+ id: "skill-name-collision",
1533
+ severity: "info",
1534
+ description: "A skill with this name already exists in the SkillStore. `skill_write` will reject unless `overwrite=true` is passed.",
1535
+ 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).",
1536
+ check: async (ctx) => {
1537
+ if (ctx.skillStore === undefined)
1538
+ return [];
1539
+ if (ctx.parsed.name === null)
1540
+ return [];
1541
+ // CLI lint is the "I'm just checking a file" surface; it shouldn't
1542
+ // surface write-preflight warnings. Cold-author write-preflight goes
1543
+ // through MCP lint_skill (callSite "api") or compile-preflight.
1544
+ if (ctx.callSite === "cli")
1545
+ return [];
1546
+ try {
1547
+ // Compare the stored body to the source being linted. If identical,
1548
+ // the user is re-linting their already-stored skill — no collision
1549
+ // worth surfacing. Only fire when the stored body materially differs.
1550
+ const stored = await ctx.skillStore.load(ctx.parsed.name);
1551
+ if (stored.source === ctx.source)
1552
+ return [];
1553
+ return [{
1554
+ rule: "skill-name-collision",
1555
+ severity: "info",
1556
+ 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.`,
1557
+ }];
1558
+ }
1559
+ catch {
1560
+ // Not found → no collision
1561
+ return [];
1562
+ }
1563
+ },
1564
+ };
1565
+ // v0.9.4 — N5 tier-3 advisory on `$set VAR = [{...}]` JSON-object-array
1566
+ // literals. The parser's processSetValue doesn't JSON-parse the value —
1567
+ // it strips outer quotes and otherwise treats it as a literal. So
1568
+ // `$set ISSUES = [{"id":"X","status":"open"}]` binds the STRING
1569
+ // `[{"id":"X","status":"open"}]` rather than a structured array.
1570
+ // Cold authors expect JS-class literal parsing; the gap is silent.
1571
+ // Per haiku/qwen finding N5 in 9086b3f8.
1572
+ const SET_JSON_LITERAL_ADVISORY = {
1573
+ id: "set-json-literal-advisory",
1574
+ severity: "info",
1575
+ 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.",
1576
+ 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.",
1577
+ check: (ctx) => {
1578
+ const findings = [];
1579
+ const reported = new Set();
1580
+ for (const [targetName, target] of ctx.parsed.targets) {
1581
+ walkOps(target.ops, (op) => {
1582
+ if (op.kind !== "$set")
1583
+ return;
1584
+ const value = op.setValue ?? "";
1585
+ // Match the START of the value being an array-of-objects literal.
1586
+ if (!/^\s*\[\s*\{/.test(value))
1587
+ return;
1588
+ const key = `${targetName}:${op.setName}`;
1589
+ if (reported.has(key))
1590
+ return;
1591
+ reported.add(key);
1592
+ findings.push({
1593
+ rule: "set-json-literal-advisory",
1594
+ severity: "info",
1595
+ 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.`,
1596
+ block: targetName,
1597
+ });
1598
+ });
1599
+ }
1600
+ return findings;
1601
+ },
1602
+ };
1603
+ // v0.9.4 — N3 tier-2 advisory on `${R.transcript}` in composition. The
1604
+ // composition result shape exposes `transcript` as the emissions array
1605
+ // (per docs); cold authors interpolate it as "the human-readable text"
1606
+ // and get JSON-ish array stringification. The semantic mismatch is
1607
+ // silent — lint and runtime accept the ref, but the rendered output is
1608
+ // wrong-shape. Per R8 minion #6 finding in 9086b3f8.
1609
+ //
1610
+ // Detect: `${VAR.transcript}` references where VAR was bound by a
1611
+ // `$ execute_skill ... -> VAR` op (i.e., the child-skill result envelope).
1612
+ // The conservative-but-actionable form: match any `${VAR.transcript}`
1613
+ // substitution and advise. False positives possible (if a user binds a
1614
+ // non-composition var named with `.transcript`), but the suggestion
1615
+ // reads as a helpful nudge either way.
1616
+ const TRANSCRIPT_FOOTGUN = {
1617
+ id: "transcript-footgun",
1618
+ severity: "warning",
1619
+ 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.",
1620
+ 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.",
1621
+ check: (ctx) => {
1622
+ const findings = [];
1623
+ const reported = new Set();
1624
+ // Pattern: ${ANY.transcript} or $(ANY.transcript) — both legacy and canonical
1625
+ const re = /\$[({][A-Za-z_]\w*\.transcript[)}]/g;
1626
+ const scanString = (s, targetName) => {
1627
+ let m;
1628
+ while ((m = re.exec(s)) !== null) {
1629
+ const key = `${targetName}:${m[0]}`;
1630
+ if (reported.has(key))
1631
+ continue;
1632
+ reported.add(key);
1633
+ findings.push({
1634
+ rule: "transcript-footgun",
1635
+ severity: "warning",
1636
+ 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\`.`,
1637
+ block: targetName,
1638
+ });
1639
+ }
1640
+ };
1641
+ for (const [targetName, target] of ctx.parsed.targets) {
1642
+ walkOps(target.ops, (op) => {
1643
+ if (op.body !== undefined)
1644
+ scanString(op.body, targetName);
1645
+ if (op.setValue !== undefined)
1646
+ scanString(op.setValue, targetName);
1647
+ });
1648
+ }
1649
+ return findings;
1650
+ },
1651
+ };
1652
+ const NUMERIC_SUBSCRIPT = {
1653
+ id: "numeric-subscript",
1654
+ severity: "warning",
1655
+ 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.",
1656
+ 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.",
1657
+ check: (ctx) => {
1658
+ const findings = [];
1659
+ const reported = new Set();
1660
+ // Pattern: `${X.0...}` or `${X.items.5}` etc — any segment that's
1661
+ // all-digits inside a brace-form substitution. Skip $(...) legacy
1662
+ // form since it's already tier-2 deprecated.
1663
+ const re = /\$\{([A-Za-z_]\w*(?:\.\w+)+)/g;
1664
+ const scanString = (s, targetName) => {
1665
+ let m;
1666
+ while ((m = re.exec(s)) !== null) {
1667
+ const ref = m[1];
1668
+ const segments = ref.split(".");
1669
+ // First segment is var name (can't be numeric); look at the rest
1670
+ const hasNumeric = segments.slice(1).some((seg) => /^\d+$/.test(seg));
1671
+ if (!hasNumeric)
1672
+ continue;
1673
+ const key = `${targetName}:${ref}`;
1674
+ if (reported.has(key))
1675
+ continue;
1676
+ reported.add(key);
1677
+ findings.push({
1678
+ rule: "numeric-subscript",
1679
+ severity: "warning",
1680
+ 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.`,
1681
+ block: targetName,
1682
+ extras: { ref },
1683
+ });
1684
+ }
1685
+ };
1686
+ for (const [targetName, target] of ctx.parsed.targets) {
1687
+ walkOps(target.ops, (op) => {
1688
+ // Scan body + kwargs (which live in body for $ ops; in setValue for $set/$append)
1689
+ if (op.body !== undefined)
1690
+ scanString(op.body, targetName);
1691
+ if (op.setValue !== undefined)
1692
+ scanString(op.setValue, targetName);
1693
+ });
1694
+ }
1695
+ return findings;
1696
+ },
1697
+ };
1698
+ // v0.9.3 — P1.3 canonicalize `recipients=[...]` over `addressed_to="..."`
1699
+ // for `$ memory_write` dispatch. The bundled MemoryStoreMcpConnector only
1700
+ // reads `args["recipients"]` (line 132 of memory-store-mcp.ts), so
1701
+ // `addressed_to=...` was always a doc-bug: it parsed but silently
1702
+ // dropped. Help docs had it pre-v0.9.3 (`help({topic:"connectors"})`
1703
+ // line 318) — fixed in this same ship. Lint catches any cold author
1704
+ // who picked the wrong shape from older docs / muscle memory.
1705
+ //
1706
+ // Tier-2 warning, not tier-1 — adopter substrates may genuinely accept
1707
+ // `addressed_to` if they wire a custom MemoryStoreMcpConnector. The
1708
+ // lint nudges toward the bundled-canonical shape without breaking
1709
+ // adopter freedom.
1710
+ const DEPRECATED_ADDRESSED_TO = {
1711
+ id: "deprecated-addressed-to",
1712
+ severity: "warning",
1713
+ 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.",
1714
+ 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.",
1715
+ check: (ctx) => {
1716
+ const findings = [];
1717
+ const reported = new Set();
1718
+ for (const [targetName, target] of ctx.parsed.targets) {
1719
+ walkOps(target.ops, (op) => {
1720
+ if (op.kind !== "$")
1721
+ return;
1722
+ const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
1723
+ if (m === null)
1724
+ return;
1725
+ const toolName = m[1];
1726
+ // Only fire on memory_write — adopters may have other tools that
1727
+ // legitimately accept addressed_to.
1728
+ if (toolName !== "memory_write")
1729
+ return;
1730
+ if (!/\baddressed_to\s*=/.test(op.body))
1731
+ return;
1732
+ const key = `${targetName}:${op.body}`;
1733
+ if (reported.has(key))
1734
+ return;
1735
+ reported.add(key);
1736
+ findings.push({
1737
+ rule: "deprecated-addressed-to",
1738
+ severity: "warning",
1739
+ message: `\`$ memory_write ... addressed_to=...\` in target '${targetName}' — the bundled MemoryStoreMcpConnector reads \`recipients=[...]\`, not \`addressed_to=\`. Use \`recipients=[<agent_id>, ...]\` (bracket-array form).`,
1740
+ block: targetName,
1741
+ });
1742
+ });
1743
+ }
1744
+ return findings;
1745
+ },
1746
+ };
1747
+ const OUTPUT_AGENT_TARGET_NO_CONNECTOR = {
1748
+ id: "output-agent-target-no-connector",
1749
+ severity: "warning",
1750
+ description: "`# Output: agent: <name>` or `# Output: template: <name>` declared but no `AgentConnector` is wired; delivery silently no-ops via the NoOp default.",
1751
+ remediation: "Wire an AgentConnector implementation in your bootstrap (`registry.registerAgentConnector(name, instance)`). See `docs/adopter-playbook.md` for the contract.",
1752
+ check: (ctx) => {
1753
+ if (ctx.agentConnectorNames === undefined)
1754
+ return [];
1755
+ if (ctx.agentConnectorNames.length > 0)
1756
+ return [];
1757
+ const findings = [];
1758
+ const agentBoundOutputs = ctx.parsed.outputs.filter((o) => (o.kind === "agent" || o.kind === "template") && o.target !== undefined);
1759
+ for (const decl of agentBoundOutputs) {
1760
+ findings.push({
1761
+ rule: "output-agent-target-no-connector",
1762
+ severity: "warning",
1763
+ message: `\`# Output: ${decl.kind}: ${decl.target}\` declared but no AgentConnector is wired; delivery silently no-ops via the NoOp default.`,
1303
1764
  });
1304
1765
  }
1305
1766
  return findings;
@@ -1861,12 +2322,13 @@ const RULES = [
1861
2322
  RESERVED_KEYWORD,
1862
2323
  UNKNOWN_SKILL_REFERENCE,
1863
2324
  UNKNOWN_TEMPLATE_REFERENCE,
1864
- DEFERRED_SKILL_REFERENCE,
1865
2325
  UNKNOWN_RETRIEVAL_ARG,
1866
2326
  UNKNOWN_CONNECTOR,
1867
2327
  UNKNOWN_CONNECTOR_CLASS,
1868
2328
  UNWIRED_PRIMARY_CONNECTOR,
1869
2329
  DISALLOWED_TOOL,
2330
+ UNKNOWN_TOOL_ON_CONNECTOR,
2331
+ UNVERIFIED_QUALIFIED_TOOL,
1870
2332
  UNINITIALIZED_APPEND,
1871
2333
  FOREACH_LOCAL_ACCUMULATOR_TARGET,
1872
2334
  APPEND_TO_NON_LIST,
@@ -1889,8 +2351,17 @@ const RULES = [
1889
2351
  DRAFT_WITH_TRIGGER,
1890
2352
  REFERENCE_TO_DISABLED_SKILL,
1891
2353
  UNUSED_AUGMENTING_HEADER,
1892
- // Tier-3 (info)
2354
+ OUTPUT_AGENT_TARGET_NO_EMIT,
2355
+ OUTPUT_AGENT_TARGET_NO_CONNECTOR,
2356
+ NUMERIC_SUBSCRIPT,
2357
+ DEPRECATED_ADDRESSED_TO,
2358
+ TRANSCRIPT_FOOTGUN,
2359
+ SET_JSON_LITERAL_ADVISORY,
2360
+ SKILL_NAME_COLLISION,
2361
+ // v0.9.2 — promoted from tier-3 info to tier-1 error (P0.9 in c9c667d2)
1893
2362
  NO_DEFAULT_TARGET,
2363
+ COLON_KWARG_SYNTAX,
2364
+ // Tier-3 (info)
1894
2365
  DUPLICATE_SKILL_NAME,
1895
2366
  PLUGIN_COLLISION,
1896
2367
  UNPARSED_JSON_FIELD_ACCESS,
@@ -2054,6 +2525,13 @@ function collectMcpConnectorNamesFromRegistry(registry) {
2054
2525
  return undefined;
2055
2526
  return registry.listMcpConnectors().map((e) => e.name);
2056
2527
  }
2528
+ function collectAgentConnectorNamesFromRegistry(registry) {
2529
+ if (registry === undefined)
2530
+ return undefined;
2531
+ // listAgentConnectors() excludes the implicit NoOp fallback (per
2532
+ // registry.ts) — empty array means "no real AgentConnector wired."
2533
+ return registry.listAgentConnectors().map((e) => e.name);
2534
+ }
2057
2535
  function collectMcpConnectorAllowedToolsFromRegistry(registry) {
2058
2536
  const out = new Map();
2059
2537
  if (registry === undefined)
@@ -2064,6 +2542,21 @@ function collectMcpConnectorAllowedToolsFromRegistry(registry) {
2064
2542
  }
2065
2543
  return out;
2066
2544
  }
2545
+ function collectMcpConnectorStaticToolsFromRegistry(registry) {
2546
+ const out = new Map();
2547
+ if (registry === undefined)
2548
+ return out;
2549
+ for (const e of registry.listMcpConnectors()) {
2550
+ const ctor = e.ctor;
2551
+ if (ctor.staticTools !== undefined) {
2552
+ out.set(e.name, ctor.staticTools());
2553
+ }
2554
+ else {
2555
+ out.set(e.name, null);
2556
+ }
2557
+ }
2558
+ return out;
2559
+ }
2067
2560
  function buildFeatureSet(classes) {
2068
2561
  const provided = new Set();
2069
2562
  for (const Ctor of classes) {