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.
- 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 +25 -6
- package/dist/bootstrap.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +25 -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 +52 -1
- package/dist/composition.js.map +1 -1
- package/dist/connectors/agent-noop.d.ts +1 -1
- package/dist/connectors/agent-noop.js +1 -1
- package/dist/connectors/agent.d.ts +1 -1
- package/dist/connectors/agent.js +1 -1
- 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 +12 -1
- package/dist/connectors/memory-store-mcp.d.ts.map +1 -1
- package/dist/connectors/memory-store-mcp.js +46 -1
- package/dist/connectors/memory-store-mcp.js.map +1 -1
- package/dist/connectors/memory-store.d.ts +9 -1
- package/dist/connectors/memory-store.d.ts.map +1 -1
- package/dist/connectors/memory-store.js +33 -0
- package/dist/connectors/memory-store.js.map +1 -1
- package/dist/connectors/registry.d.ts +8 -1
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +10 -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 +43 -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 +95 -18
- 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 +14 -0
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +591 -98
- 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 +30 -3
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +143 -8
- package/dist/parser.js.map +1 -1
- package/dist/runtime.d.ts +32 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +128 -26
- 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 +34 -1
- package/dist/scheduler.js.map +1 -1
- package/examples/classify-support-ticket.skill.md +2 -2
- 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 +2 -2
- package/examples/hello.skill.provenance.json +1 -1
- package/examples/morning-brief.skill.md +3 -3
- package/examples/onboarding-scaffold/file-memory-store.ts +32 -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,
|
|
@@ -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.
|
|
381
|
-
//
|
|
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
|
-
|
|
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
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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: ${
|
|
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:
|
|
615
|
-
// unknown-skill-reference
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
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: "
|
|
1234
|
-
severity: "
|
|
1235
|
-
description: "
|
|
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
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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 `
|
|
1285
|
-
remediation: "Either add an agent-bound output (`# Output:
|
|
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 === "
|
|
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 `
|
|
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 \`
|
|
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
|
-
|
|
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) {
|