skillscript-runtime 0.15.7 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +23 -24
  2. package/dist/compile.js +25 -71
  3. package/dist/compile.js.map +1 -1
  4. package/dist/connectors/mcp-remote.d.ts.map +1 -1
  5. package/dist/connectors/mcp-remote.js +7 -1
  6. package/dist/connectors/mcp-remote.js.map +1 -1
  7. package/dist/errors.d.ts +13 -7
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +20 -13
  10. package/dist/errors.js.map +1 -1
  11. package/dist/help-content.d.ts.map +1 -1
  12. package/dist/help-content.js +78 -103
  13. package/dist/help-content.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/lint.d.ts.map +1 -1
  19. package/dist/lint.js +167 -228
  20. package/dist/lint.js.map +1 -1
  21. package/dist/mutation-gate.d.ts +7 -13
  22. package/dist/mutation-gate.d.ts.map +1 -1
  23. package/dist/mutation-gate.js +6 -11
  24. package/dist/mutation-gate.js.map +1 -1
  25. package/dist/parser.d.ts +25 -74
  26. package/dist/parser.d.ts.map +1 -1
  27. package/dist/parser.js +80 -272
  28. package/dist/parser.js.map +1 -1
  29. package/dist/runtime.d.ts +1 -10
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +76 -188
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/safe-path.d.ts +20 -0
  34. package/dist/safe-path.d.ts.map +1 -0
  35. package/dist/safe-path.js +49 -0
  36. package/dist/safe-path.js.map +1 -0
  37. package/dist/skill-manager.js +1 -1
  38. package/dist/skill-manager.js.map +1 -1
  39. package/dist/trace.d.ts.map +1 -1
  40. package/dist/trace.js +26 -9
  41. package/dist/trace.js.map +1 -1
  42. package/docs/language-reference.md +3 -24
  43. package/examples/README.md +1 -2
  44. package/examples/connectors/McpConnectorTemplate/McpConnectorTemplate.ts +4 -5
  45. package/examples/connectors/SkillStoreTemplate/SkillStoreTemplate.ts +9 -9
  46. package/examples/custom-bootstrap.example.ts +4 -4
  47. package/examples/onboarding-scaffold/bootstrap.ts +7 -7
  48. package/examples/onboarding-scaffold/file-data-store.ts +3 -5
  49. package/examples/onboarding-scaffold/openai-local-model.ts +7 -8
  50. package/examples/onboarding-scaffold/tmux-shell-agent-connector.ts +4 -4
  51. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  52. package/package.json +1 -1
  53. package/examples/skillscripts/cut-release-tag.skill.md +0 -40
package/dist/lint.js CHANGED
@@ -257,11 +257,11 @@ const UNDECLARED_VAR = {
257
257
  for (const [targetName, target] of ctx.parsed.targets) {
258
258
  const reported = new Set(); // dedupe per target
259
259
  for (const op of target.ops) {
260
- // `@ unsafe` ops use bash `$(...)` syntax — handled by
260
+ // Unsafe `shell` ops use bash `$(...)` syntax — handled by
261
261
  // unsafe-shell-ambiguous-subst, which offers the dual rewrite
262
262
  // (`$$(NAME)` for bash, `$(KNOWN_VAR)` for skillscript). Skip here
263
263
  // to avoid double-reporting.
264
- if (op.kind === "@" && op.policy === "unsafe")
264
+ if (op.kind === "shell" && op.policy === "unsafe")
265
265
  continue;
266
266
  for (const ref of extractVarRefs(op)) {
267
267
  // Heuristic: dotted refs (targetname.output, MEMORY.field) pass
@@ -321,9 +321,9 @@ const MALFORMED_OP_GRAMMAR = {
321
321
  id: "malformed-op-grammar",
322
322
  severity: "error",
323
323
  description: "An op line failed parser grammar validation. Surfaces parse errors that originate from op-specific shape.",
324
- remediation: "Check the op's syntax against the language reference. Common cases: `>` and `~` need `key=value ... -> VAR`; `& skill arg=value -> VAR` for skill invocations.",
324
+ remediation: "Check the op's syntax against the language reference. `$ tool key=value ... -> VAR` for MCP dispatch; `verb(kwarg=value, ...) [-> VAR]` for runtime intrinsics.",
325
325
  check: (ctx) => ctx.parsed.parseErrors
326
- .filter((msg) => /Malformed `[~>&$@!?]/.test(msg))
326
+ .filter((msg) => /Malformed `\$|Malformed function-call|Legacy `[~>&@!?]/.test(msg))
327
327
  .map((msg) => ({
328
328
  rule: "malformed-op-grammar",
329
329
  severity: "error",
@@ -430,40 +430,6 @@ const UNKNOWN_SKILL_REFERENCE = {
430
430
  return findings;
431
431
  },
432
432
  };
433
- // v0.2.12 Bug 26. Tier-2 warning when a `>` retrieval op carries kwargs
434
- // outside the documented set. Cold author wrote `since=1h` (hallucinated
435
- // time-window predicate) and the kwarg passed silently. The documented
436
- // kwarg set is mode/query/limit/connector/fallback plus filter shapes
437
- // the connector advertises via staticCapabilities (out of band here).
438
- const KNOWN_RETRIEVAL_KWARGS = new Set(["mode", "query", "limit", "connector", "fallback"]);
439
- const UNKNOWN_RETRIEVAL_ARG = {
440
- id: "unknown-retrieval-arg",
441
- severity: "warning",
442
- description: "A `>` retrieval op carries a kwarg outside the documented set (mode/query/limit/connector/fallback).",
443
- remediation: "Remove the kwarg, or check it against your DataStore connector's documentation — extras pass through to the connector but unrecognized ones often indicate hallucinated syntax.",
444
- check: (ctx) => {
445
- const findings = [];
446
- for (const [targetName, target] of ctx.parsed.targets) {
447
- walkOps(target.ops, (op) => {
448
- if (op.kind !== ">")
449
- return;
450
- const extras = op.retrievalParams?.extra ?? {};
451
- for (const k of Object.keys(extras)) {
452
- if (KNOWN_RETRIEVAL_KWARGS.has(k))
453
- continue;
454
- findings.push({
455
- rule: "unknown-retrieval-arg",
456
- severity: "warning",
457
- 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.`,
458
- block: targetName,
459
- extras: { unknown_kwarg: k },
460
- });
461
- }
462
- });
463
- }
464
- return findings;
465
- },
466
- };
467
433
  // v0.4.0 — `$ name.tool` references a connector name not registered.
468
434
  // `connectorNames` is the authoritative list from the Registry (passed
469
435
  // through LintOptions); when undefined (caller doesn't know what's
@@ -685,11 +651,19 @@ const UNVERIFIED_QUALIFIED_TOOL = {
685
651
  // the lint message didn't explain the layering. Now: if all sites have
686
652
  // fallback → info with layering explanation; if any site lacks it → error
687
653
  // (unchanged).
654
+ // v0.16.0 — bare-form `$ <tool>` is reserved for runtime-intrinsic ops + tools
655
+ // whose name name-matches a registered MCP connector (the typed-contract pattern,
656
+ // e.g. `$ llm prompt=...` dispatches to the `llm` connector). Substrate-specific
657
+ // MCP tool dispatch (e.g. `amp_olsen_task`) MUST use named form `$ <connector>.<tool>`
658
+ // — no more "primary fallback" leniency. Closes the discipline-only-contract gap
659
+ // where adopters wrote bare-form expecting `primary` and silent-failed on cron-fired
660
+ // paths when primary wasn't registered.
661
+ const BARE_FORM_INTRINSICS = new Set(["execute_skill", "json_parse"]);
688
662
  const UNWIRED_PRIMARY_CONNECTOR = {
689
663
  id: "unwired-primary-connector",
690
664
  severity: "error",
691
- 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).",
692
- 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.",
665
+ description: "A bare `$ TOOL` op (no connector prefix) routes only to (a) a runtime-intrinsic op or (b) a wired connector whose name matches the op name. v0.16.0 removed the `primary` fallback substrate-specific MCP tools require named form `$ <connector>.<tool>`.",
666
+ remediation: "Use named form `$ <connector>.<tool>` for substrate-specific MCP dispatch (e.g., `$ amp.amp_olsen_task` instead of `$ amp_olsen_task`). Bare form is reserved for runtime intrinsics (`execute_skill`, `json_parse`) and typed-contract ops where the tool name matches a wired connector (e.g., `$ llm prompt=...` if `llm` is wired).",
693
667
  check: (ctx) => {
694
668
  if (ctx.mcpConnectorNames === undefined)
695
669
  return [];
@@ -700,22 +674,17 @@ const UNWIRED_PRIMARY_CONNECTOR = {
700
674
  walkOps(target.ops, (op) => {
701
675
  if (op.kind !== "$" || op.mcpConnector !== undefined)
702
676
  return;
703
- // Built-in intercepts that don't need a connector at all.
704
677
  const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
705
678
  if (m === null)
706
679
  return;
707
680
  const toolName = m[1];
708
- if (toolName === "execute_skill" || toolName === "json_parse")
681
+ if (BARE_FORM_INTRINSICS.has(toolName))
709
682
  return;
710
- // v0.7.3 name-match: if the bare op name matches a wired
711
- // connector, the runtime dispatch resolver routes there directly.
712
- // (Mirrors `runtime.ts` `$` op dispatch kept in sync with that
713
- // fix to prevent the v0.7.2-shape regression of lint-fails-then-
714
- // user-never-reaches-runtime.)
683
+ // Name-match: bare op name matches a wired connector → typed-contract
684
+ // pattern (e.g., `$ llm` against wired `llm` connector). Runtime
685
+ // dispatch resolver routes directly.
715
686
  if (ctx.mcpConnectorNames.includes(toolName))
716
687
  return;
717
- if (ctx.mcpConnectorNames.includes("primary"))
718
- return;
719
688
  const key = `${targetName}:${toolName}`;
720
689
  const hasFallback = op.fallback !== undefined;
721
690
  const existing = groups.get(key);
@@ -742,7 +711,7 @@ const UNWIRED_PRIMARY_CONNECTOR = {
742
711
  findings.push({
743
712
  rule: "unwired-primary-connector",
744
713
  severity: "error",
745
- message: `\`$ ${toolName}\` in target '${targetName}' is a bare tool op no connector named '${toolName}' wired and no \`primary\` fallback. Wired connectors: ${wired}.`,
714
+ message: `\`$ ${toolName}\` in target '${targetName}' is bare-form but '${toolName}' is not a runtime intrinsic AND doesn't match any wired MCP connector. Bare form is reserved for typed-contract / intrinsic ops. Use named form \`$ <connector>.${toolName}\` for substrate-specific MCP dispatch. Wired connectors: ${wired}.`,
746
715
  block: targetName,
747
716
  extras: { tool: toolName },
748
717
  });
@@ -809,14 +778,6 @@ const UNPARSED_JSON_FIELD_ACCESS = {
809
778
  for (const b of op.ifBranches)
810
779
  reportIfMatches(b.cond, targetName);
811
780
  }
812
- if (op.retrievalParams !== undefined) {
813
- reportIfMatches(op.retrievalParams.query, targetName);
814
- for (const v of Object.values(op.retrievalParams.extra))
815
- reportIfMatches(String(v), targetName);
816
- }
817
- if (op.localModelParams !== undefined) {
818
- reportIfMatches(op.localModelParams.prompt, targetName);
819
- }
820
781
  if (op.ampParams !== undefined) {
821
782
  for (const v of Object.values(op.ampParams.args))
822
783
  reportIfMatches(v, targetName);
@@ -892,26 +853,60 @@ const DISABLED_SKILL_REFERENCE = {
892
853
  return findings;
893
854
  },
894
855
  };
895
- /** Patterns that strongly suggest a credential in plaintext. Conservative — false positives are noisy, false negatives are dangerous, so we err on the side of catching obvious cases. */
896
- const CREDENTIAL_ARG_PATTERN = /\b(apikey|api_key|token|secret|password|passwd|pwd|auth_token|access_token|bearer)\s*=/i;
856
+ // Credential detection covers two surfaces:
857
+ //
858
+ // 1. KEY shape — identifier names that conventionally hold secrets,
859
+ // followed by `=` or `:` (covers MCP-tool kwargs, HTTP headers like
860
+ // `Authorization: Bearer ...`, connector config k:v pairs).
861
+ //
862
+ // 2. VALUE shape — recognizable token prefixes/structures (Bearer
863
+ // tokens, OpenAI `sk-` keys, GitHub `ghp_` tokens, JWT `eyJ.X.Y`).
864
+ // Catches credentials in unnamed positions (e.g., literal Bearer
865
+ // token in a shell command body).
866
+ //
867
+ // Tier-2 (warning) — broader patterns mean some false positives are
868
+ // expected. False positives are noisy; false negatives are dangerous.
869
+ const CREDENTIAL_KEY_PATTERN = /\b(api[_-]?key|token|secret|password|passwd|pwd|auth(?:[_-]?token)?|access[_-]?token|bearer|client[_-]?secret|private[_-]?key|signing[_-]?key|refresh[_-]?token|connection[_-]?string|vault[_-]?token|db[_-]?password)\s*[:=]/i;
870
+ const CREDENTIAL_VALUE_PATTERN = /(?:Bearer\s+[A-Za-z0-9_.~+/=-]{20,}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{20,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/;
871
+ function findsCredential(text) {
872
+ return CREDENTIAL_KEY_PATTERN.test(text) || CREDENTIAL_VALUE_PATTERN.test(text);
873
+ }
897
874
  const CREDENTIAL_IN_ARGS = {
898
875
  id: "credential-in-args",
899
- severity: "error",
900
- description: "A `$` op carries arg values that match credential-like patterns. Credentials don't belong in skill source.",
901
- remediation: "Move credentials to per-connector config (env vars, mounted secrets). Skill args should reference operator-managed values, not embed them.",
876
+ severity: "warning",
877
+ description: "An op or `# Vars:` default appears to carry credential-like content. Credentials don't belong in skill source.",
878
+ remediation: "Move credentials to per-connector config (env vars in connectors.json, mounted secrets). Skill source should reference operator-managed values via `# Requires: user-var:NAME -> VAR`, not embed them.",
902
879
  check: (ctx) => {
903
880
  const findings = [];
881
+ const reported = new Set();
882
+ const emit = (where, snippet, block) => {
883
+ const key = `${where}:${snippet.slice(0, 60)}`;
884
+ if (reported.has(key))
885
+ return;
886
+ reported.add(key);
887
+ findings.push({
888
+ rule: "credential-in-args",
889
+ severity: "warning",
890
+ message: `${where}: credential-like content detected ('${snippet.slice(0, 60).replace(/\n/g, " ")}${snippet.length > 60 ? "..." : ""}').`,
891
+ ...(block !== undefined ? { block } : {}),
892
+ });
893
+ };
894
+ // # Vars: default values — author-written, often where adopters paste secrets by mistake.
895
+ for (const v of ctx.parsed.vars) {
896
+ if (v.default !== undefined && findsCredential(v.default)) {
897
+ emit(`# Vars: ${v.name} default`, v.default);
898
+ }
899
+ }
900
+ // Op bodies + value-bearing fields across all op kinds.
904
901
  for (const [targetName, target] of ctx.parsed.targets) {
905
902
  walkOps(target.ops, (op) => {
906
- if (op.kind !== "$")
907
- return;
908
- if (CREDENTIAL_ARG_PATTERN.test(op.body)) {
909
- findings.push({
910
- rule: "credential-in-args",
911
- severity: "error",
912
- message: `\`$\` op in target '${targetName}' appears to carry credential-like arg ('${op.body.slice(0, 40)}...').`,
913
- block: targetName,
914
- });
903
+ // Op body — covers $, shell, emit, $set body, foreach list, etc.
904
+ if (op.body !== undefined && findsCredential(op.body)) {
905
+ emit(`Op '${op.kind}' in target '${targetName}'`, op.body, targetName);
906
+ }
907
+ // Mutation-statement value (setValue holds the RHS of $set / $append).
908
+ if (op.setValue !== undefined && findsCredential(op.setValue)) {
909
+ emit(`'${op.kind}' value in target '${targetName}'`, op.setValue, targetName);
915
910
  }
916
911
  });
917
912
  }
@@ -1013,11 +1008,11 @@ const MISSING_SKILLSTORE_FOR_DATA_REF = {
1013
1008
  const findings = [];
1014
1009
  for (const [targetName, target] of ctx.parsed.targets) {
1015
1010
  for (const op of target.ops) {
1016
- if (op.kind === "&") {
1011
+ if (op.kind === "inline") {
1017
1012
  findings.push({
1018
1013
  rule: "missing-skillstore-for-data-ref",
1019
1014
  severity: "error",
1020
- message: `Skill references skill '${op.ampParams?.skillName ?? "(unknown)"}' via \`&\`, but lint was invoked without a SkillStore (call site: ${ctx.callSite}). Data-skill inlining will silently skip; the \`&\` op will survive into the runtime and error.`,
1015
+ message: `Skill references skill '${op.ampParams?.skillName ?? "(unknown)"}' via \`inline(...)\`, but lint was invoked without a SkillStore (call site: ${ctx.callSite}). Data-skill inlining will silently skip; the \`inline\` op will survive into the runtime and error.`,
1021
1016
  block: targetName,
1022
1017
  extras: { call_site: ctx.callSite },
1023
1018
  });
@@ -1088,7 +1083,7 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
1088
1083
  for (const [targetName, target] of ctx.parsed.targets) {
1089
1084
  const reported = new Set();
1090
1085
  walkOps(target.ops, (op) => {
1091
- if (op.kind !== "@" || op.policy !== "unsafe")
1086
+ if (op.kind !== "shell" || op.policy !== "unsafe")
1092
1087
  return;
1093
1088
  const re = new RegExp(REF_RE.source, "g");
1094
1089
  let m;
@@ -1128,6 +1123,81 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
1128
1123
  return findings;
1129
1124
  },
1130
1125
  };
1126
+ // Sibling to unsafe-shell-ambiguous-subst: that rule catches refs that
1127
+ // AREN'T declared (potential bash command-sub collision); this rule
1128
+ // catches refs that ARE declared but are inlined raw into bash, missing
1129
+ // the `|shell` escape filter. Author intent is "substitute this variable"
1130
+ // but they haven't told the runtime to bash-escape it — variable values
1131
+ // containing spaces or shell metacharacters break the command silently
1132
+ // or, worse, become injectable.
1133
+ const UNSAFE_SHELL_UNESCAPED_SUBST = {
1134
+ id: "unsafe-shell-unescaped-subst",
1135
+ severity: "warning",
1136
+ description: "An `unsafe=true` shell op interpolates a declared variable into a bash command body without the `|shell` escape filter. Variable values containing whitespace or shell metacharacters break the command or become injectable.",
1137
+ remediation: "Add the `|shell` filter on every interpolation: `${VAR|shell}` produces a single bash-quoted token. The filter exists for exactly this case — it's the canonical escape for unsafe-shell substitution.",
1138
+ check: (ctx) => {
1139
+ const declared = new Set();
1140
+ for (const v of ctx.parsed.vars)
1141
+ declared.add(v.name);
1142
+ for (const r of ctx.parsed.requires)
1143
+ declared.add(r.target);
1144
+ for (const target of ctx.parsed.targets.values()) {
1145
+ const collect = (op) => {
1146
+ if (op.setName !== undefined)
1147
+ declared.add(op.setName);
1148
+ if (op.outputVar !== undefined)
1149
+ declared.add(op.outputVar);
1150
+ if (op.foreachIter !== undefined)
1151
+ declared.add(op.foreachIter);
1152
+ };
1153
+ walkOps(target.ops, collect);
1154
+ if (target.elseBlock !== undefined)
1155
+ walkOps(target.elseBlock, collect);
1156
+ }
1157
+ const findings = [];
1158
+ // Match both `${VAR|filters}` and `$(VAR|filters)` forms. Capture the
1159
+ // var name + the filter chain. Single `$` only — `$$(...)` is the
1160
+ // bash-literal escape, not a skillscript substitution.
1161
+ const REF_RE = /(?<!\$)\$(?:\{([^|}\s]+)((?:\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*)\}|\(([^|)\s]+)((?:\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*)\))/g;
1162
+ for (const [targetName, target] of ctx.parsed.targets) {
1163
+ const reported = new Set();
1164
+ walkOps(target.ops, (op) => {
1165
+ if (op.kind !== "shell" || op.policy !== "unsafe")
1166
+ return;
1167
+ const re = new RegExp(REF_RE.source, "g");
1168
+ let m;
1169
+ while ((m = re.exec(op.body)) !== null) {
1170
+ const varName = (m[1] ?? m[3]);
1171
+ const chain = (m[2] ?? m[4]) ?? "";
1172
+ // Skip dotted refs (structured access — author's responsibility).
1173
+ if (varName.includes("."))
1174
+ continue;
1175
+ // Skip ambient refs — runtime-injected, author can't filter them.
1176
+ if (AMBIENT_VARS.includes(varName))
1177
+ continue;
1178
+ // Skip refs whose ROOT identifier isn't declared (those fire as
1179
+ // `unsafe-shell-ambiguous-subst` or `undeclared-var` instead).
1180
+ if (!declared.has(varName))
1181
+ continue;
1182
+ // The cure: a `|shell` filter in the chain.
1183
+ if (/\|\s*shell\b/.test(chain))
1184
+ continue;
1185
+ if (reported.has(varName))
1186
+ continue;
1187
+ reported.add(varName);
1188
+ findings.push({
1189
+ rule: "unsafe-shell-unescaped-subst",
1190
+ severity: "warning",
1191
+ message: `\`\${${varName}}\` interpolated into \`shell(command=..., unsafe=true)\` body in target '${targetName}' without the \`|shell\` escape filter. Variable values with spaces or shell metacharacters will break the command or become injectable. Rewrite as \`\${${varName}|shell}\`.`,
1192
+ block: targetName,
1193
+ extras: { var_name: varName },
1194
+ });
1195
+ }
1196
+ });
1197
+ }
1198
+ return findings;
1199
+ },
1200
+ };
1131
1201
  const UNSAFE_SHELL_OP = {
1132
1202
  id: "unsafe-shell-op",
1133
1203
  severity: "warning",
@@ -1137,7 +1207,7 @@ const UNSAFE_SHELL_OP = {
1137
1207
  const findings = [];
1138
1208
  for (const [targetName, target] of ctx.parsed.targets) {
1139
1209
  walkOps(target.ops, (op) => {
1140
- if (op.kind === "@" && op.policy === "unsafe") {
1210
+ if (op.kind === "shell" && op.policy === "unsafe") {
1141
1211
  findings.push({
1142
1212
  rule: "unsafe-shell-op",
1143
1213
  severity: "warning",
@@ -1173,7 +1243,7 @@ const UNSAFE_SHELL_DISABLED = {
1173
1243
  const findings = [];
1174
1244
  for (const [targetName, target] of ctx.parsed.targets) {
1175
1245
  walkOps(target.ops, (op) => {
1176
- if (op.kind === "@" && op.policy === "unsafe") {
1246
+ if (op.kind === "shell" && op.policy === "unsafe") {
1177
1247
  findings.push({
1178
1248
  rule: "unsafe-shell-disabled",
1179
1249
  severity: "error",
@@ -1202,8 +1272,8 @@ const UNSAFE_SHELL_DISABLED = {
1202
1272
  const UNCONFIRMED_MUTATION = {
1203
1273
  id: "unconfirmed-mutation",
1204
1274
  severity: "warning",
1205
- description: "A mutation-class op runs without author authorization. Mutation classes: `$ tool` with mutating-name shape (write/update/delete/...); `$ data_write` MCP dispatch; `file_write(...)` function-call op. Silent when the skill declares `# Autonomous: true` (v0.4.2), when a preceding `??` / `ask(...)` confirmation gates the op, or (v0.7.0+) when the op carries `approved=\"reason\"` per-op authorization.",
1206
- remediation: "Three ways to authorize: (1) add `# Autonomous: true` at the skill header for cron/agent-fired skills; (2) add a preceding `??` / `ask(prompt=\"...\")` confirmation op in the same target; (3) v0.7.0+: pass `approved=\"reason\"` kwarg on the mutation op itself (any non-empty string; presence is what matters, value not parsed semantically).",
1275
+ description: "A mutation-class op runs without author authorization. Mutation classes: `$ tool` with mutating-name shape (write/update/delete/...); `$ data_write` MCP dispatch; `file_write(...)` function-call op. Silent when the skill declares `# Autonomous: true` or when the op carries `approved=\"reason\"` per-op authorization.",
1276
+ remediation: "Two ways to authorize: (1) add `# Autonomous: true` at the skill header for cron/agent-fired skills; (2) pass `approved=\"reason\"` kwarg on the mutation op itself (any non-empty string; presence is what matters, value not parsed semantically).",
1207
1277
  check: (ctx) => {
1208
1278
  const skillAutonomous = ctx.parsed.autonomous === true;
1209
1279
  // v0.4.2 — `# Autonomous: true` skills are unattended by design;
@@ -1214,12 +1284,8 @@ const UNCONFIRMED_MUTATION = {
1214
1284
  return [];
1215
1285
  const findings = [];
1216
1286
  for (const [targetName, target] of ctx.parsed.targets) {
1217
- const authState = { skillAutonomous, sawConfirm: false };
1287
+ const authState = { skillAutonomous };
1218
1288
  for (const op of target.ops) {
1219
- // v0.7.0+: ask(prompt=...) parses to kind "??" in the AST, so the
1220
- // same check captures both legacy `??` and canonical `ask()`.
1221
- if (op.kind === "??")
1222
- authState.sawConfirm = true;
1223
1289
  if (authorizationGranted(op, authState))
1224
1290
  continue;
1225
1291
  const classification = classifyMutation(op);
@@ -1229,7 +1295,7 @@ const UNCONFIRMED_MUTATION = {
1229
1295
  findings.push({
1230
1296
  rule: "unconfirmed-mutation",
1231
1297
  severity: "warning",
1232
- message: `\`file_write(path="${classification.detail}")\` in target '${targetName}' is a mutation op without authorization. Add \`approved="..."\` kwarg, precede with \`ask(...)\`, or declare \`# Autonomous: true\`.`,
1298
+ message: `\`file_write(path="${classification.detail}")\` in target '${targetName}' is a mutation op without authorization. Add \`approved="..."\` kwarg or declare \`# Autonomous: true\`.`,
1233
1299
  block: targetName,
1234
1300
  extras: { op_kind: "file_write", path: classification.detail },
1235
1301
  });
@@ -1238,7 +1304,7 @@ const UNCONFIRMED_MUTATION = {
1238
1304
  findings.push({
1239
1305
  rule: "unconfirmed-mutation",
1240
1306
  severity: "warning",
1241
- message: `\`$\` op in target '${targetName}' invokes '${classification.detail}' (mutating shape) without authorization. Add \`approved="..."\` kwarg, precede with \`ask(...)\`, or declare \`# Autonomous: true\`.`,
1307
+ message: `\`$\` op in target '${targetName}' invokes '${classification.detail}' (mutating shape) without authorization. Add \`approved="..."\` kwarg or declare \`# Autonomous: true\`.`,
1242
1308
  block: targetName,
1243
1309
  extras: { tool_name: classification.detail },
1244
1310
  });
@@ -1248,41 +1314,6 @@ const UNCONFIRMED_MUTATION = {
1248
1314
  return findings;
1249
1315
  },
1250
1316
  };
1251
- const MODEL_CONTENTION = {
1252
- id: "model-contention",
1253
- severity: "warning",
1254
- description: "Skill body has a `$` op dispatching async batch work on a model + a downstream `~ model=X` synchronous call to the same model. The runtime serializes per-model; the sync call queues behind the batch.",
1255
- remediation: "Use distinct models for async vs sync work: e.g., `gemma2` for batch + `qwen` for the interactive verdict. See ERD §3 model selection convention.",
1256
- check: (ctx) => {
1257
- const findings = [];
1258
- // Heuristic: collect ~ op model names per target. Flag if any $ op
1259
- // in the same target dispatches a batch-classification-shaped tool
1260
- // (name contains "olsen", "scan", "batch", "classify"). Conservative.
1261
- for (const [targetName, target] of ctx.parsed.targets) {
1262
- const syncModels = new Set();
1263
- walkOps(target.ops, (op) => {
1264
- if (op.kind === "~" && op.localModelParams?.model)
1265
- syncModels.add(op.localModelParams.model);
1266
- });
1267
- if (syncModels.size === 0)
1268
- return findings;
1269
- walkOps(target.ops, (op) => {
1270
- if (op.kind !== "$")
1271
- return;
1272
- const toolName = op.body.split(/\s+/)[0] ?? "";
1273
- if (/scan|batch|classify|atomize/i.test(toolName)) {
1274
- findings.push({
1275
- rule: "model-contention",
1276
- severity: "warning",
1277
- message: `Target '${targetName}' dispatches batch work via '${toolName}' AND uses sync \`~ model=...\` — possible model contention on the same backend.`,
1278
- block: targetName,
1279
- });
1280
- }
1281
- });
1282
- }
1283
- return findings;
1284
- },
1285
- };
1286
1317
  const DRAFT_WITH_TRIGGER = {
1287
1318
  id: "draft-with-trigger",
1288
1319
  severity: "warning",
@@ -1523,7 +1554,7 @@ const OUTPUT_AGENT_TARGET_NO_EMIT = {
1523
1554
  return findings;
1524
1555
  let hasEmit = false;
1525
1556
  for (const [, target] of ctx.parsed.targets) {
1526
- walkOps(target.ops, (op) => { if (op.kind === "!")
1557
+ walkOps(target.ops, (op) => { if (op.kind === "emit")
1527
1558
  hasEmit = true; });
1528
1559
  if (hasEmit)
1529
1560
  break;
@@ -2055,12 +2086,8 @@ function buildBindingOrigins(parsed) {
2055
2086
  if (op.outputVar !== undefined) {
2056
2087
  if (op.kind === "$")
2057
2088
  origins.set(op.outputVar, { kind: "op-output", op: "$" });
2058
- else if (op.kind === "~")
2059
- origins.set(op.outputVar, { kind: "op-output", op: "~" });
2060
- else if (op.kind === ">")
2061
- origins.set(op.outputVar, { kind: "op-output", op: ">" });
2062
- else if (op.kind === "@")
2063
- origins.set(op.outputVar, { kind: "op-output", op: "@" });
2089
+ else if (op.kind === "shell")
2090
+ origins.set(op.outputVar, { kind: "op-output", op: "shell" });
2064
2091
  }
2065
2092
  if (op.kind === "foreach" && op.foreachIter !== undefined) {
2066
2093
  origins.set(op.foreachIter, { kind: "foreach-iter" });
@@ -2136,14 +2163,13 @@ const UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE = {
2136
2163
  });
2137
2164
  }
2138
2165
  }
2139
- else if (op.kind === "@") {
2140
- // v0.7.2 legacy @ shell op. Tokenize the body the same way the
2141
- // runtime would (whitespace-separated, quotes respected), then
2142
- // flag any token that is a bare unquoted substitution. Quoted
2143
- // tokens (`"${VAR}"` / `'${VAR}'`) are safe.
2166
+ else if (op.kind === "shell") {
2167
+ // Tokenize the shell body the same way the runtime would
2168
+ // (whitespace-separated, quotes respected), then flag any token
2169
+ // that is a bare unquoted substitution. Quoted tokens
2170
+ // (`"${VAR}"` / `'${VAR}'`) are safe.
2144
2171
  const tokens = tokenizeKeywordArgs(op.body);
2145
2172
  for (const tok of tokens) {
2146
- // Skip quoted tokens — the quotes protect against whitespace split.
2147
2173
  if ((tok.startsWith('"') && tok.endsWith('"')) || (tok.startsWith("'") && tok.endsWith("'")))
2148
2174
  continue;
2149
2175
  if (!(tok.startsWith("$(") || tok.startsWith("${")))
@@ -2156,16 +2182,16 @@ const UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE = {
2156
2182
  const origin = origins.get(rootVar);
2157
2183
  if (!isOriginSuspect(origin))
2158
2184
  continue;
2159
- const dedupKey = `${targetName}:@:${varName}`;
2185
+ const dedupKey = `${targetName}:shell:${varName}`;
2160
2186
  if (reported.has(dedupKey))
2161
2187
  continue;
2162
2188
  reported.add(dedupKey);
2163
2189
  findings.push({
2164
2190
  rule: "unquoted-substitution-in-kwarg-value",
2165
2191
  severity: "warning",
2166
- message: `\`@ ... \${${varName}}\` shell arg in target '${targetName}': unquoted substitution. ${describeOriginRisk(origin)} Wrap as \`"\${${varName}}"\` to prevent silent word-splitting if the value contains whitespace.`,
2192
+ message: `\`shell(command="... \${${varName}} ...")\` in target '${targetName}': unquoted substitution. ${describeOriginRisk(origin)} Wrap as \`"\${${varName}}"\` to prevent silent word-splitting if the value contains whitespace.`,
2167
2193
  block: targetName,
2168
- extras: { var_name: varName, origin: origin.kind, op: "@" },
2194
+ extras: { var_name: varName, origin: origin.kind, op: "shell" },
2169
2195
  });
2170
2196
  }
2171
2197
  }
@@ -2188,86 +2214,6 @@ function describeOriginRisk(origin) {
2188
2214
  return `Variable is a \`foreach\` iterator — element type unknown statically.`;
2189
2215
  }
2190
2216
  }
2191
- /**
2192
- * v0.7.1 — tier-2 visibility nudge for legacy symbol-form ops (`~`, `>`,
2193
- * `@`, `!`, `??`, `&`). The parser still accepts these during the v0.7.x
2194
- * grace period; the runtime dispatches them as before. This rule surfaces
2195
- * the canonical replacement so authors editing skills see the migration
2196
- * path. Tier-1 promotion (refuse-to-compile) lands in v0.8 or v0.9 once
2197
- * the adopter ecosystem confirms migration is settled.
2198
- *
2199
- * The `$ tool` op is NOT flagged — that shape is canonical (MCP dispatch
2200
- * marker). Only the 6 symbol ops that became function-call ops in v0.7.0.
2201
- */
2202
- const DEPRECATED_SYMBOL_OP_REPLACEMENT = {
2203
- // v0.7.2: bridge classes ship default-wired so `$ llm` / `$ data_read` work
2204
- // out of the box in default deployments. Suggestions are load-bearing
2205
- // (no more "(or your wired connector name)" caveat).
2206
- "~": "$ llm prompt=\"...\" [maxTokens=N] [model=\"...\"] -> R",
2207
- ">": "$ data_read mode=\"fts|semantic|rerank\" query=\"...\" limit=N -> R",
2208
- "@": "shell(command=\"...\") [-> R]",
2209
- "!": "emit(text=\"...\")",
2210
- "??": "ask(prompt=\"...\") -> R",
2211
- "&": "inline(skill=\"...\") (or execute_skill(skill_name=\"...\", ...) -> R for procedural composition)",
2212
- };
2213
- const DEPRECATED_SYMBOL_OP = {
2214
- id: "deprecated-symbol-op",
2215
- severity: "warning",
2216
- description: "An op uses the legacy symbol form deprecated in v0.7.0.",
2217
- remediation: "Rewrite to the canonical v0.7.0 form (see message). All legacy ops continue to compile during the grace period; tier-1 promotion (refuse-to-compile) lands in v0.8/v0.9. See CHANGELOG.md `## 0.7.0 — Migration` for the full rewrite rules.",
2218
- check: (ctx) => {
2219
- const findings = [];
2220
- for (const [targetName, target] of ctx.parsed.targets) {
2221
- const reported = new Set();
2222
- walkOps(target.ops, (op) => {
2223
- const replacement = DEPRECATED_SYMBOL_OP_REPLACEMENT[op.kind];
2224
- if (replacement === undefined)
2225
- return;
2226
- // v0.7.1: skip ops authored in canonical function-call form. The
2227
- // parser collapses both `! x` and `emit(text="x")` to kind "!",
2228
- // so without the sourceForm marker the lint would fire on
2229
- // canonical code. Source-form marker preserves which surface
2230
- // the author wrote.
2231
- if (op.sourceForm === "function-call")
2232
- return;
2233
- // Dedupe per-kind-per-target — one nudge per legacy op type per
2234
- // target is plenty; further occurrences don't add signal.
2235
- const key = `${targetName}:${op.kind}`;
2236
- if (reported.has(key))
2237
- return;
2238
- reported.add(key);
2239
- findings.push({
2240
- rule: "deprecated-symbol-op",
2241
- severity: "warning",
2242
- message: `Op '${op.kind}' in target '${targetName}' is deprecated in v0.7.0. Rewrite as: \`${replacement}\``,
2243
- block: targetName,
2244
- extras: { legacy_op: op.kind, canonical_replacement: replacement },
2245
- });
2246
- });
2247
- if (target.elseBlock !== undefined) {
2248
- walkOps(target.elseBlock, (op) => {
2249
- const replacement = DEPRECATED_SYMBOL_OP_REPLACEMENT[op.kind];
2250
- if (replacement === undefined)
2251
- return;
2252
- if (op.sourceForm === "function-call")
2253
- return;
2254
- const key = `${targetName}:else:${op.kind}`;
2255
- if (reported.has(key))
2256
- return;
2257
- reported.add(key);
2258
- findings.push({
2259
- rule: "deprecated-symbol-op",
2260
- severity: "warning",
2261
- message: `Op '${op.kind}' in target '${targetName}' (else block) is deprecated in v0.7.0. Rewrite as: \`${replacement}\``,
2262
- block: targetName,
2263
- extras: { legacy_op: op.kind, canonical_replacement: replacement },
2264
- });
2265
- });
2266
- }
2267
- }
2268
- return findings;
2269
- },
2270
- };
2271
2217
  /**
2272
2218
  * v0.7.1 — tier-2 visibility nudge for legacy `$(VAR)` substitution form.
2273
2219
  * Canonical v0.7.0+ form is `${VAR}`. Parser/runtime accept both during
@@ -2381,7 +2327,6 @@ const RULES = [
2381
2327
  RESERVED_KEYWORD,
2382
2328
  UNKNOWN_SKILL_REFERENCE,
2383
2329
  UNKNOWN_TEMPLATE_REFERENCE,
2384
- UNKNOWN_RETRIEVAL_ARG,
2385
2330
  UNKNOWN_CONNECTOR,
2386
2331
  UNKNOWN_CONNECTOR_CLASS,
2387
2332
  UNWIRED_PRIMARY_CONNECTOR,
@@ -2399,14 +2344,13 @@ const RULES = [
2399
2344
  MISSING_SKILLSTORE_FOR_DATA_REF,
2400
2345
  // Tier-2 (warning)
2401
2346
  DEPRECATED_QUESTION,
2402
- DEPRECATED_SYMBOL_OP,
2403
2347
  DEPRECATED_SUBSTITUTION_SHAPE,
2404
2348
  UNSAFE_SHELL_AMBIGUOUS_SUBST,
2349
+ UNSAFE_SHELL_UNESCAPED_SUBST,
2405
2350
  UNSAFE_SHELL_OP,
2406
2351
  UNSAFE_SHELL_DISABLED,
2407
2352
  UNCONFIRMED_MUTATION,
2408
2353
  UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE,
2409
- MODEL_CONTENTION,
2410
2354
  DRAFT_WITH_TRIGGER,
2411
2355
  REFERENCE_TO_DISABLED_SKILL,
2412
2356
  UNUSED_AUGMENTING_HEADER,
@@ -2457,9 +2401,9 @@ function collectAmpRefsFromOps(ops) {
2457
2401
  out.push({ name, via });
2458
2402
  };
2459
2403
  walkOps(ops, (op) => {
2460
- if (op.kind === "&" && op.ampParams !== undefined)
2461
- emit(op.ampParams.skillName, "&");
2462
- // v0.2.11 Bug 7: $ execute_skill is also a composition primitive.
2404
+ if (op.kind === "inline" && op.ampParams !== undefined)
2405
+ emit(op.ampParams.skillName, "inline");
2406
+ // `$ execute_skill` is also a composition primitive.
2463
2407
  if (op.kind === "$" && /^execute_skill\b/.test(op.body)) {
2464
2408
  // v0.15.2 — accept either `name` or `skill_name` (back-compat alias).
2465
2409
  const m = /\b(?:skill_name|name)\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w-]*))/.exec(op.body);
@@ -2520,11 +2464,6 @@ function extractVarRefsWithFilter(op) {
2520
2464
  }
2521
2465
  function collectOpText(op) {
2522
2466
  let text = op.body;
2523
- if (op.retrievalParams !== undefined) {
2524
- text += " " + op.retrievalParams.query + " " + Object.values(op.retrievalParams.extra).join(" ");
2525
- }
2526
- if (op.localModelParams !== undefined)
2527
- text += " " + op.localModelParams.prompt;
2528
2467
  if (op.setValue !== undefined)
2529
2468
  text += " " + op.setValue;
2530
2469
  if (op.foreachList !== undefined)