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.
- package/dist/approval.d.ts +99 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +224 -0
- package/dist/approval.js.map +1 -0
- package/dist/bootstrap.d.ts +21 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +19 -5
- package/dist/bootstrap.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +9 -1
- package/dist/compile.js.map +1 -1
- package/dist/composition.d.ts +29 -0
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +62 -1
- package/dist/composition.js.map +1 -1
- package/dist/connectors/agent-noop.d.ts +12 -7
- package/dist/connectors/agent-noop.d.ts.map +1 -1
- package/dist/connectors/agent-noop.js +31 -12
- package/dist/connectors/agent-noop.js.map +1 -1
- package/dist/connectors/agent.d.ts +150 -28
- package/dist/connectors/agent.d.ts.map +1 -1
- package/dist/connectors/agent.js +13 -10
- package/dist/connectors/agent.js.map +1 -1
- package/dist/connectors/local-model-mcp.d.ts +8 -0
- package/dist/connectors/local-model-mcp.d.ts.map +1 -1
- package/dist/connectors/local-model-mcp.js +10 -0
- package/dist/connectors/local-model-mcp.js.map +1 -1
- package/dist/connectors/memory-store-mcp.d.ts +9 -0
- package/dist/connectors/memory-store-mcp.d.ts.map +1 -1
- package/dist/connectors/memory-store-mcp.js +11 -0
- package/dist/connectors/memory-store-mcp.js.map +1 -1
- package/dist/connectors/registry.d.ts +15 -1
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +23 -1
- package/dist/connectors/registry.js.map +1 -1
- package/dist/connectors/skill-store.d.ts.map +1 -1
- package/dist/connectors/skill-store.js +43 -13
- package/dist/connectors/skill-store.js.map +1 -1
- package/dist/connectors/types.d.ts +12 -0
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/connectors/types.js.map +1 -1
- package/dist/dashboard/spa/app.js +37 -9
- package/dist/dispatch-validate.d.ts +64 -0
- package/dist/dispatch-validate.d.ts.map +1 -0
- package/dist/dispatch-validate.js +81 -0
- package/dist/dispatch-validate.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -1
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +92 -12
- package/dist/help-content.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lint.d.ts +11 -0
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +560 -104
- package/dist/lint.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +53 -11
- package/dist/mcp-server.js.map +1 -1
- package/dist/parser.d.ts +27 -6
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +115 -11
- package/dist/parser.js.map +1 -1
- package/dist/runtime.d.ts +51 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +126 -35
- package/dist/runtime.js.map +1 -1
- package/dist/scheduler.d.ts +20 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +40 -3
- package/dist/scheduler.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +16 -6
- package/dist/testing/conformance.js.map +1 -1
- package/examples/classify-support-ticket.skill.md +1 -1
- package/examples/cut-release-tag.skill.md +1 -1
- package/examples/doc-qa-with-citations.skill.md +1 -1
- package/examples/feedback-sentiment-scan.skill.md +1 -1
- package/examples/hello.skill.provenance.json +1 -1
- package/examples/morning-brief.skill.md +1 -1
- package/examples/queue-length-monitor.skill.md +1 -1
- package/examples/service-health-watch.skill.md +1 -1
- package/examples/youtrack-morning-sweep.skill.md +1 -1
- package/package.json +1 -1
- 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.
|
|
383
|
-
//
|
|
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
|
-
|
|
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
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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: ${
|
|
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:
|
|
617
|
-
// unknown-skill-reference
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
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: "
|
|
1236
|
-
severity: "
|
|
1237
|
-
description: "
|
|
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
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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: "`#
|
|
1287
|
-
remediation: "Either add an agent-bound output (`# Output: agent: <name>` or `# Output: template: <name>`) so the
|
|
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.
|
|
1466
|
+
if (ctx.parsed.eventType !== null) {
|
|
1294
1467
|
findings.push({
|
|
1295
1468
|
rule: "unused-augmenting-header",
|
|
1296
1469
|
severity: "warning",
|
|
1297
|
-
message: "`#
|
|
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
|
-
|
|
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) {
|