skillscript-runtime 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lint.js CHANGED
@@ -9,6 +9,7 @@ export async function lint(source, options) {
9
9
  skillStore: options?.skillStore,
10
10
  hasSkillStore: options?.skillStore !== undefined,
11
11
  callSite: options?.callSite ?? "api",
12
+ enableUnsafeShell: options?.enableUnsafeShell,
12
13
  };
13
14
  const findings = [];
14
15
  for (const rule of RULES) {
@@ -41,6 +42,7 @@ export function lintSync(source, options) {
41
42
  skillStore: options?.skillStore,
42
43
  hasSkillStore: options?.skillStore !== undefined,
43
44
  callSite: options?.callSite ?? "api",
45
+ enableUnsafeShell: options?.enableUnsafeShell,
44
46
  };
45
47
  const findings = [];
46
48
  for (const rule of RULES) {
@@ -364,20 +366,21 @@ const UNKNOWN_SKILL_REFERENCE = {
364
366
  const findings = [];
365
367
  const seen = new Set();
366
368
  for (const [targetName, target] of ctx.parsed.targets) {
367
- for (const refName of collectAmpRefsFromOps(target.ops)) {
368
- if (seen.has(refName))
369
+ for (const ref of collectAmpRefsFromOps(target.ops)) {
370
+ const key = `${ref.via}:${ref.name}`;
371
+ if (seen.has(key))
369
372
  continue;
370
- seen.add(refName);
373
+ seen.add(key);
371
374
  try {
372
- await ctx.skillStore.metadata(refName);
375
+ await ctx.skillStore.metadata(ref.name);
373
376
  }
374
377
  catch {
375
378
  findings.push({
376
379
  rule: "unknown-skill-reference",
377
380
  severity: "error",
378
- message: `Skill '${targetName}' references skill '${refName}' via \`&\`, but the SkillStore has no skill by that name.`,
381
+ message: `Skill '${targetName}' references skill '${ref.name}' via \`${ref.via}\`, but the SkillStore has no skill by that name.`,
379
382
  block: targetName,
380
- extras: { referenced_skill: refName },
383
+ extras: { referenced_skill: ref.name, via: ref.via },
381
384
  });
382
385
  }
383
386
  }
@@ -385,6 +388,70 @@ const UNKNOWN_SKILL_REFERENCE = {
385
388
  return findings;
386
389
  },
387
390
  };
391
+ // v0.2.12 Bug 26. Tier-2 warning when a `>` retrieval op carries kwargs
392
+ // outside the documented set. Cold author wrote `since=1h` (hallucinated
393
+ // time-window predicate) and the kwarg passed silently. The documented
394
+ // kwarg set is mode/query/limit/connector/fallback plus filter shapes
395
+ // the connector advertises via staticCapabilities (out of band here).
396
+ const KNOWN_RETRIEVAL_KWARGS = new Set(["mode", "query", "limit", "connector", "fallback"]);
397
+ const UNKNOWN_RETRIEVAL_ARG = {
398
+ id: "unknown-retrieval-arg",
399
+ severity: "warning",
400
+ description: "A `>` retrieval op carries a kwarg outside the documented set (mode/query/limit/connector/fallback).",
401
+ remediation: "Remove the kwarg, or check it against your MemoryStore connector's documentation — extras pass through to the connector but unrecognized ones often indicate hallucinated syntax.",
402
+ check: (ctx) => {
403
+ const findings = [];
404
+ for (const [targetName, target] of ctx.parsed.targets) {
405
+ walkOps(target.ops, (op) => {
406
+ if (op.kind !== ">")
407
+ return;
408
+ const extras = op.retrievalParams?.extra ?? {};
409
+ for (const k of Object.keys(extras)) {
410
+ if (KNOWN_RETRIEVAL_KWARGS.has(k))
411
+ continue;
412
+ findings.push({
413
+ rule: "unknown-retrieval-arg",
414
+ severity: "warning",
415
+ message: `\`>\` op in target '${targetName}' carries unknown kwarg '${k}'. Documented kwargs: ${Array.from(KNOWN_RETRIEVAL_KWARGS).join(", ")}. If '${k}' is a connector-specific filter, confirm it against the connector's docs.`,
416
+ block: targetName,
417
+ extras: { unknown_kwarg: k },
418
+ });
419
+ }
420
+ });
421
+ }
422
+ return findings;
423
+ },
424
+ };
425
+ // v0.2.12 Bug 17. `# Templates:` refs were not lint-validated despite
426
+ // `# OnError:` having compile-time validation (since v0.2.10). Tier-1
427
+ // because a missing template fails delivery at runtime.
428
+ const UNKNOWN_TEMPLATE_REFERENCE = {
429
+ id: "unknown-template-reference",
430
+ severity: "error",
431
+ description: "`# Templates: <name>` references a skill that's not present in the configured SkillStore.",
432
+ remediation: "Check the template name spelling, or store the missing template skill before referencing it. Templates resolve at delivery time; missing ones fail the agent delivery.",
433
+ check: async (ctx) => {
434
+ if (ctx.skillStore === undefined)
435
+ return [];
436
+ if (ctx.parsed.templates.length === 0)
437
+ return [];
438
+ const findings = [];
439
+ for (const name of ctx.parsed.templates) {
440
+ try {
441
+ await ctx.skillStore.metadata(name);
442
+ }
443
+ catch {
444
+ findings.push({
445
+ rule: "unknown-template-reference",
446
+ severity: "error",
447
+ message: `Skill references template '${name}' via \`# Templates:\`, but the SkillStore has no skill by that name.`,
448
+ extras: { referenced_skill: name },
449
+ });
450
+ }
451
+ }
452
+ return findings;
453
+ },
454
+ };
388
455
  const DISABLED_SKILL_REFERENCE = {
389
456
  id: "disabled-skill-reference",
390
457
  severity: "error",
@@ -396,19 +463,19 @@ const DISABLED_SKILL_REFERENCE = {
396
463
  const findings = [];
397
464
  const checked = new Set();
398
465
  for (const [targetName, target] of ctx.parsed.targets) {
399
- for (const refName of collectAmpRefsFromOps(target.ops)) {
400
- if (checked.has(refName))
466
+ for (const ref of collectAmpRefsFromOps(target.ops)) {
467
+ if (checked.has(ref.name))
401
468
  continue;
402
- checked.add(refName);
469
+ checked.add(ref.name);
403
470
  try {
404
- const meta = await ctx.skillStore.metadata(refName);
471
+ const meta = await ctx.skillStore.metadata(ref.name);
405
472
  if (meta.status === "Disabled") {
406
473
  findings.push({
407
474
  rule: "disabled-skill-reference",
408
475
  severity: "error",
409
- message: `Skill '${targetName}' references '${refName}' which is disabled.`,
476
+ message: `Skill '${targetName}' references '${ref.name}' via \`${ref.via}\` which is disabled.`,
410
477
  block: targetName,
411
- extras: { referenced_skill: refName, target_status: meta.status },
478
+ extras: { referenced_skill: ref.name, via: ref.via, target_status: meta.status },
412
479
  });
413
480
  }
414
481
  }
@@ -627,6 +694,19 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
627
694
  const trimmed = inner.trim();
628
695
  if (/^[A-Za-z_]\w*$/.test(trimmed) && declared.has(trimmed))
629
696
  continue;
697
+ // v0.2.11 Bug 4: dotted refs (EVENT.fired_at_unix, MEMORY.x,
698
+ // <target>.output) are runtime ambient/output families — same
699
+ // dotted-passthrough heuristic as `undeclared-var`. The
700
+ // unsafe-shell warning was telling cold authors to rewrite
701
+ // `$(EVENT.fired_at_unix)` as `$$(EVENT.fired_at_unix)` (bash
702
+ // command-sub), which would just try to execute "EVENT...".
703
+ if (trimmed.includes("."))
704
+ continue;
705
+ // v0.2.11 Bug 4: bare ambient refs (NOW, USER, ERROR_CONTEXT,
706
+ // SESSION_CONTEXT, TRIGGER_TYPE, TRIGGER_PAYLOAD) also pass —
707
+ // runtime injects them, author doesn't declare.
708
+ if (AMBIENT_VARS.includes(trimmed))
709
+ continue;
630
710
  if (reported.has(inner))
631
711
  continue;
632
712
  reported.add(inner);
@@ -665,8 +745,55 @@ const UNSAFE_SHELL_OP = {
665
745
  return findings;
666
746
  },
667
747
  };
668
- /** Tool-name patterns that strongly suggest mutating operations. Conservative — false positives are tolerable for warnings; false negatives are dangerous. */
669
- const MUTATING_TOOL_PATTERN = /^(?:write_|update_|delete_|remove_|set_|create_|insert_|put_|patch_|destroy_).*/;
748
+ /**
749
+ * v0.2.11 Bug 5. Tier-1 escalation of the unsafe-shell signal — only
750
+ * fires when the caller passed `enableUnsafeShell: false` explicitly.
751
+ * Without that knowledge (the field is undefined), this rule stays
752
+ * silent and the tier-2 `unsafe-shell-op` warning is the only signal.
753
+ *
754
+ * When the runtime is known-disabled, every `@ unsafe` op is a guaranteed
755
+ * runtime refusal (`UnsafeShellDisabledError`). Surfacing that at compile
756
+ * time instead of letting the skill compile clean and then fail at first
757
+ * fire avoids the "compiles clean but won't run" gap Perry's harness
758
+ * surfaced (memory `b6176e02`).
759
+ */
760
+ const UNSAFE_SHELL_DISABLED = {
761
+ id: "unsafe-shell-disabled",
762
+ severity: "error",
763
+ description: "Skill uses `@ unsafe`, but the runtime was configured with `enableUnsafeShell: false`. The op will refuse at first fire.",
764
+ remediation: "Either set `enableUnsafeShell: true` on the runtime (after reviewing the shell content), or refactor the `@ unsafe` op to use the structured `@ <binary> <args>` form (sandboxed, no bash).",
765
+ check: (ctx) => {
766
+ if (ctx.enableUnsafeShell !== false)
767
+ return [];
768
+ const findings = [];
769
+ for (const [targetName, target] of ctx.parsed.targets) {
770
+ walkOps(target.ops, (op) => {
771
+ if (op.kind === "@" && op.policy === "unsafe") {
772
+ findings.push({
773
+ rule: "unsafe-shell-disabled",
774
+ severity: "error",
775
+ message: `\`@ unsafe\` op in target '${targetName}' would refuse at runtime: \`enableUnsafeShell\` is false. Command: '${op.body.slice(0, 60)}${op.body.length > 60 ? "..." : ""}'`,
776
+ block: targetName,
777
+ });
778
+ }
779
+ });
780
+ }
781
+ return findings;
782
+ },
783
+ };
784
+ /**
785
+ * Tool-name patterns that strongly suggest mutating operations.
786
+ * Conservative — false positives are tolerable for warnings; false
787
+ * negatives are dangerous.
788
+ *
789
+ * v0.2.11 Bug 6: extended with archive_/prune_/deploy_/expire_/
790
+ * consolidate_/purge_/reset_/rotate_/move_/rename_/drop_/truncate_/
791
+ * upsert_/overwrite_/clear_/wipe_/finalize_ — Perry's wild-and-crazy
792
+ * harness surfaced a cluster of mutating tools that the original
793
+ * write/update/delete/etc. set didn't catch (`archive_old_threads`,
794
+ * `prune_threads`, `deploy_release`, `dangerous-cleanup`'s `expire_*`).
795
+ */
796
+ const MUTATING_TOOL_PATTERN = /^(?:write_|update_|delete_|remove_|set_|create_|insert_|put_|patch_|destroy_|archive_|prune_|deploy_|expire_|consolidate_|purge_|reset_|rotate_|move_|rename_|drop_|truncate_|upsert_|overwrite_|clear_|wipe_|finalize_).*/;
670
797
  const UNCONFIRMED_MUTATION = {
671
798
  id: "unconfirmed-mutation",
672
799
  severity: "warning",
@@ -756,19 +883,19 @@ const REFERENCE_TO_DISABLED_SKILL = {
756
883
  const findings = [];
757
884
  const checked = new Set();
758
885
  for (const [targetName, target] of ctx.parsed.targets) {
759
- for (const refName of collectAmpRefsFromOps(target.ops)) {
760
- if (checked.has(refName))
886
+ for (const ref of collectAmpRefsFromOps(target.ops)) {
887
+ if (checked.has(ref.name))
761
888
  continue;
762
- checked.add(refName);
889
+ checked.add(ref.name);
763
890
  try {
764
- const meta = await ctx.skillStore.metadata(refName);
891
+ const meta = await ctx.skillStore.metadata(ref.name);
765
892
  if (meta.status === "Disabled") {
766
893
  findings.push({
767
894
  rule: "reference-to-disabled-skill",
768
895
  severity: "warning",
769
- message: `Target '${targetName}' references '${refName}' which is disabled.`,
896
+ message: `Target '${targetName}' references '${ref.name}' via \`${ref.via}\` which is disabled.`,
770
897
  block: targetName,
771
- extras: { referenced_skill: refName },
898
+ extras: { referenced_skill: ref.name, via: ref.via },
772
899
  });
773
900
  }
774
901
  }
@@ -857,6 +984,193 @@ const UNUSED_AUGMENTING_HEADER = {
857
984
  return findings;
858
985
  },
859
986
  };
987
+ function isAncestorScope(initPath, appendPath) {
988
+ if (initPath.length >= appendPath.length)
989
+ return false;
990
+ for (let i = 0; i < initPath.length; i++) {
991
+ if (initPath[i].id !== appendPath[i].id)
992
+ return false;
993
+ }
994
+ return true;
995
+ }
996
+ function isSameScope(a, b) {
997
+ if (a.length !== b.length)
998
+ return false;
999
+ for (let i = 0; i < a.length; i++)
1000
+ if (a[i].id !== b[i].id)
1001
+ return false;
1002
+ return true;
1003
+ }
1004
+ function pathContainsForeach(p) {
1005
+ for (const n of p)
1006
+ if (n.kind === "foreach")
1007
+ return true;
1008
+ return false;
1009
+ }
1010
+ function walkOpsWithScope(ops, visit, nextScopeId, path = []) {
1011
+ for (const op of ops) {
1012
+ visit(op, path);
1013
+ if (op.foreachBody !== undefined) {
1014
+ const child = { id: nextScopeId.n++, kind: "foreach" };
1015
+ walkOpsWithScope(op.foreachBody, visit, nextScopeId, [...path, child]);
1016
+ }
1017
+ if (op.ifBranches !== undefined) {
1018
+ for (const b of op.ifBranches) {
1019
+ const child = { id: nextScopeId.n++, kind: "if-branch" };
1020
+ walkOpsWithScope(b.body, visit, nextScopeId, [...path, child]);
1021
+ }
1022
+ }
1023
+ if (op.ifElseBody !== undefined) {
1024
+ const child = { id: nextScopeId.n++, kind: "if-else" };
1025
+ walkOpsWithScope(op.ifElseBody, visit, nextScopeId, [...path, child]);
1026
+ }
1027
+ }
1028
+ }
1029
+ function isStaticListLiteral(raw) {
1030
+ const t = raw.trim();
1031
+ return t.startsWith("[") && t.endsWith("]");
1032
+ }
1033
+ const UNINITIALIZED_APPEND = {
1034
+ id: "uninitialized-append",
1035
+ severity: "error",
1036
+ description: "`$append VAR ...` where VAR isn't initialized in any enclosing scope (target body, # Vars: declaration, or shallower foreach/if block).",
1037
+ remediation: "Add `$set VAR = []` before the `$append` (in the target body, not inside the foreach), or declare in `# Vars: VAR=[]`. If you meant a different variable, check the spelling against your declarations.",
1038
+ check: (ctx) => {
1039
+ const declaredGlobal = new Set();
1040
+ for (const v of ctx.parsed.vars)
1041
+ declaredGlobal.add(v.name);
1042
+ for (const r of ctx.parsed.requires)
1043
+ declaredGlobal.add(r.target);
1044
+ const findings = [];
1045
+ for (const [targetName, target] of ctx.parsed.targets) {
1046
+ const inits = new Map();
1047
+ const sc1 = { n: 1 };
1048
+ walkOpsWithScope(target.ops, (op, path) => {
1049
+ if (op.kind === "$set" && op.setName !== undefined) {
1050
+ const arr = inits.get(op.setName) ?? [];
1051
+ arr.push([...path]);
1052
+ inits.set(op.setName, arr);
1053
+ }
1054
+ }, sc1);
1055
+ const sc2 = { n: 1 };
1056
+ walkOpsWithScope(target.ops, (op, path) => {
1057
+ if (op.kind !== "$append" || op.setName === undefined)
1058
+ return;
1059
+ const varName = op.setName;
1060
+ if (declaredGlobal.has(varName))
1061
+ return;
1062
+ const initPaths = inits.get(varName) ?? [];
1063
+ const hasAncestor = initPaths.some((ip) => isAncestorScope(ip, path));
1064
+ const hasSame = initPaths.some((ip) => isSameScope(ip, path));
1065
+ // Same-scope counts as "visible" for resolution purposes — the
1066
+ // init runs before the append in the same block (foreach iteration,
1067
+ // straight target body, etc.). Whether it's the RIGHT shape for an
1068
+ // accumulator is `foreach-local-accumulator-target`'s job.
1069
+ const isVisible = hasAncestor || hasSame;
1070
+ const hasOther = initPaths.some((ip) => !isAncestorScope(ip, path) && !isSameScope(ip, path));
1071
+ if (initPaths.length === 0) {
1072
+ findings.push({
1073
+ rule: "uninitialized-append",
1074
+ severity: "error",
1075
+ message: `\`$append ${varName} ...\` in target '${targetName}': ${varName} is not initialized. Add \`$set ${varName} = []\` before the \`$append\` (or declare in \`# Vars: ${varName}=[]\`). If you meant a different variable, check the spelling against your declarations.`,
1076
+ block: targetName,
1077
+ extras: { var_name: varName },
1078
+ });
1079
+ }
1080
+ else if (!isVisible && hasOther) {
1081
+ // init exists in a sibling/inner scope, not visible at the append site.
1082
+ findings.push({
1083
+ rule: "uninitialized-append",
1084
+ severity: "error",
1085
+ message: `\`$append ${varName} ...\` in target '${targetName}': ${varName}'s \`$set\` initialization is in a sibling or inner block, not visible at this append site. Move the init to the target body (or a common enclosing scope) before the \`$append\`.`,
1086
+ block: targetName,
1087
+ extras: { var_name: varName },
1088
+ });
1089
+ }
1090
+ }, sc2);
1091
+ }
1092
+ return findings;
1093
+ },
1094
+ };
1095
+ const FOREACH_LOCAL_ACCUMULATOR_TARGET = {
1096
+ id: "foreach-local-accumulator-target",
1097
+ severity: "error",
1098
+ description: "`$append VAR ...` where VAR's `$set VAR = []` initialization is in the SAME scope (typically same foreach body). Each iteration resets VAR; the accumulator silently loses all but the last iteration's append.",
1099
+ remediation: "Move `$set VAR = []` outside the foreach (to the target body), so the append mutates a single outer-scope list that persists across iterations.",
1100
+ check: (ctx) => {
1101
+ const findings = [];
1102
+ for (const [targetName, target] of ctx.parsed.targets) {
1103
+ const inits = new Map();
1104
+ const sc1 = { n: 1 };
1105
+ walkOpsWithScope(target.ops, (op, path) => {
1106
+ if (op.kind === "$set" && op.setName !== undefined) {
1107
+ const arr = inits.get(op.setName) ?? [];
1108
+ arr.push([...path]);
1109
+ inits.set(op.setName, arr);
1110
+ }
1111
+ }, sc1);
1112
+ const sc2 = { n: 1 };
1113
+ walkOpsWithScope(target.ops, (op, path) => {
1114
+ if (op.kind !== "$append" || op.setName === undefined)
1115
+ return;
1116
+ const initPaths = inits.get(op.setName) ?? [];
1117
+ const hasAncestor = initPaths.some((ip) => isAncestorScope(ip, path));
1118
+ const hasSame = initPaths.some((ip) => isSameScope(ip, path));
1119
+ // Only fires when the SAME scope is inside a foreach. Same scope at
1120
+ // target-body level (both ops at depth 0) is fine — that's just
1121
+ // sequential init + append, no iteration to lose data across.
1122
+ if (!hasAncestor && hasSame && pathContainsForeach(path)) {
1123
+ findings.push({
1124
+ rule: "foreach-local-accumulator-target",
1125
+ severity: "error",
1126
+ message: `\`$append ${op.setName} ...\` in target '${targetName}': \`$set ${op.setName} = []\` is in the same scope as the append (typically the same foreach body). Each iteration resets ${op.setName}, silently losing all but the last iteration's data. Move the \`$set ${op.setName} = []\` to the target body, before the foreach.`,
1127
+ block: targetName,
1128
+ extras: { var_name: op.setName },
1129
+ });
1130
+ }
1131
+ }, sc2);
1132
+ }
1133
+ return findings;
1134
+ },
1135
+ };
1136
+ const APPEND_TO_NON_LIST = {
1137
+ id: "append-to-non-list",
1138
+ severity: "error",
1139
+ description: "`$append VAR ...` where VAR's static initialization is a non-list value. $append v0.3.0 is list-only.",
1140
+ remediation: "Initialize VAR with a list literal (`$set VAR = []` or `# Vars: VAR=[]`). String concat and map-shaped accumulation are out of scope for v0.3.0.",
1141
+ check: (ctx) => {
1142
+ const staticInits = new Map();
1143
+ for (const v of ctx.parsed.vars) {
1144
+ if (v.default !== undefined)
1145
+ staticInits.set(v.name, v.default);
1146
+ }
1147
+ const findings = [];
1148
+ for (const [targetName, target] of ctx.parsed.targets) {
1149
+ walkOps(target.ops, (op) => {
1150
+ if (op.kind === "$set" && op.setName !== undefined && op.setValue !== undefined && !/\$\(/.test(op.setValue)) {
1151
+ staticInits.set(op.setName, op.setValue);
1152
+ }
1153
+ });
1154
+ walkOps(target.ops, (op) => {
1155
+ if (op.kind !== "$append" || op.setName === undefined)
1156
+ return;
1157
+ const init = staticInits.get(op.setName);
1158
+ if (init === undefined)
1159
+ return;
1160
+ if (!isStaticListLiteral(init)) {
1161
+ findings.push({
1162
+ rule: "append-to-non-list",
1163
+ severity: "error",
1164
+ message: `\`$append ${op.setName} ...\` in target '${targetName}': ${op.setName} is initialized to a non-list value (\`${init.slice(0, 40)}${init.length > 40 ? "..." : ""}\`). $append v0.3.0 requires a list-typed target.`,
1165
+ block: targetName,
1166
+ extras: { var_name: op.setName, init_value: init },
1167
+ });
1168
+ }
1169
+ });
1170
+ }
1171
+ return findings;
1172
+ },
1173
+ };
860
1174
  const RULES = [
861
1175
  // Tier-1 (error)
862
1176
  PARSE_ERROR,
@@ -872,6 +1186,11 @@ const RULES = [
872
1186
  INDENTATION,
873
1187
  RESERVED_KEYWORD,
874
1188
  UNKNOWN_SKILL_REFERENCE,
1189
+ UNKNOWN_TEMPLATE_REFERENCE,
1190
+ UNKNOWN_RETRIEVAL_ARG,
1191
+ UNINITIALIZED_APPEND,
1192
+ FOREACH_LOCAL_ACCUMULATOR_TARGET,
1193
+ APPEND_TO_NON_LIST,
875
1194
  DISABLED_SKILL_REFERENCE,
876
1195
  CREDENTIAL_IN_ARGS,
877
1196
  STATUS_DISABLED,
@@ -882,6 +1201,7 @@ const RULES = [
882
1201
  DEPRECATED_QUESTION,
883
1202
  UNSAFE_SHELL_AMBIGUOUS_SUBST,
884
1203
  UNSAFE_SHELL_OP,
1204
+ UNSAFE_SHELL_DISABLED,
885
1205
  UNCONFIRMED_MUTATION,
886
1206
  MODEL_CONTENTION,
887
1207
  DRAFT_WITH_TRIGGER,
@@ -911,10 +1231,27 @@ function walkOps(ops, visit) {
911
1231
  }
912
1232
  }
913
1233
  function collectAmpRefsFromOps(ops) {
914
- const out = new Set();
1234
+ const out = [];
1235
+ const seen = new Set();
1236
+ const emit = (name, via) => {
1237
+ const key = `${via}:${name}`;
1238
+ if (seen.has(key))
1239
+ return;
1240
+ seen.add(key);
1241
+ out.push({ name, via });
1242
+ };
915
1243
  walkOps(ops, (op) => {
916
1244
  if (op.kind === "&" && op.ampParams !== undefined)
917
- out.add(op.ampParams.skillName);
1245
+ emit(op.ampParams.skillName, "&");
1246
+ // v0.2.11 Bug 7: $ execute_skill is also a composition primitive.
1247
+ if (op.kind === "$" && /^execute_skill\b/.test(op.body)) {
1248
+ const m = /\bskill_name\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w-]*))/.exec(op.body);
1249
+ if (m !== null) {
1250
+ const name = m[1] ?? m[2] ?? m[3];
1251
+ if (name !== undefined && name !== "")
1252
+ emit(name, "$ execute_skill");
1253
+ }
1254
+ }
918
1255
  });
919
1256
  return out;
920
1257
  }