skillscript-runtime 0.15.7 → 0.16.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/runtime.js CHANGED
@@ -6,7 +6,7 @@ import { validateQualifiedDispatch } from "./dispatch-validate.js";
6
6
  import { spawn } from "node:child_process";
7
7
  import { readFile as fsReadFile, writeFile as fsWriteFile, mkdir as fsMkdir } from "node:fs/promises";
8
8
  import { dirname as pathDirname } from "node:path";
9
- import { OpError, ConnectorNotFoundError, OpTimeoutError, InteractiveOpInAutonomousModeError, UnsafeShellDisabledError, UnresolvedVariableError, TypeMismatchError, MissingSkillReferenceError, UnconfirmedMutationError, messageOf, } from "./errors.js";
9
+ import { OpError, ConnectorNotFoundError, OpTimeoutError, UnsafeShellDisabledError, UnresolvedVariableError, TypeMismatchError, MissingSkillReferenceError, UnconfirmedMutationError, messageOf, } from "./errors.js";
10
10
  import { classifyMutation, authorizationGranted, buildAuthorizationSuggestion, } from "./mutation-gate.js";
11
11
  import { TraceBuilder, shouldTraceFire } from "./trace.js";
12
12
  /**
@@ -112,12 +112,11 @@ export async function execute(parsed, initialVars, order, ctx) {
112
112
  continue;
113
113
  let targetLastBound = null;
114
114
  let targetLastValue = undefined;
115
- // v0.14.1 — fresh mutation-auth state per target. `skillAutonomous`
116
- // is set from the parsed header; `sawConfirm` resets so a `??` in
117
- // target T1 doesn't authorize mutations in T2 (correct scope).
115
+ // Fresh mutation-auth state per target. `skillAutonomous` is set from
116
+ // the parsed header. v0.16.0 retired the `sawConfirm` path with the
117
+ // `ask` op removal.
118
118
  const authState = {
119
119
  skillAutonomous: parsed.autonomous === true,
120
- sawConfirm: false,
121
120
  };
122
121
  try {
123
122
  const r = await execOps(target.ops, vars, emissions, fallbacks, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder, authState);
@@ -276,15 +275,11 @@ async function execOps(ops, vars, emissions, fallbacks, ctx, targetName, skillTi
276
275
  let lastBoundVar = null;
277
276
  let lastValue = undefined;
278
277
  for (const op of ops) {
279
- // v0.14.1 Layer A load-bearing mutation gate. Flip `sawConfirm` for the
280
- // current op iteration in lint-parity order (`??` flips before its own
281
- // gate check) so a `??` op's own classification path stays consistent
282
- // with lint. Non-mutation `??` skips the throw branch naturally.
283
- if (op.kind === "??")
284
- authState.sawConfirm = true;
278
+ // v0.16.0 `ask` removed; mutation gate now relies solely on `approved=`
279
+ // per-op kwarg + `# Autonomous: true` skill flag (sawConfirm path retired).
285
280
  const mutKind = classifyMutation(op);
286
281
  if (mutKind !== null && !authorizationGranted(op, authState)) {
287
- throw new UnconfirmedMutationError(mutKind.kind, mutKind.detail, ["# Autonomous: true header", "preceding ?? / ask() in same target", "approved=\"reason\" per-op kwarg"], buildAuthorizationSuggestion(mutKind), op.kind, targetName);
282
+ throw new UnconfirmedMutationError(mutKind.kind, mutKind.detail, ["# Autonomous: true header", "approved=\"reason\" per-op kwarg"], buildAuthorizationSuggestion(mutKind), op.kind, targetName);
288
283
  }
289
284
  const r = await execOp(op, vars, emissions, fallbacks, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder, authState);
290
285
  if (r.lastBoundVar !== null) {
@@ -327,16 +322,13 @@ async function execOp(op, vars, emissions, fallbacks, ctx, targetName, skillTime
327
322
  }
328
323
  }
329
324
  }
330
- /** Extract the connector instance name for $/~/> ops; undefined for others. */
325
+ /** Extract the connector instance name for `$` ops; undefined for others. */
331
326
  function extractOpConnector(op) {
332
- switch (op.kind) {
333
- case "$": return op.mcpConnector ?? "primary";
334
- case "~": return op.localModelParams?.model ?? "default";
335
- case ">": return op.retrievalParams?.connector ?? "primary";
336
- default: return undefined;
337
- }
327
+ if (op.kind === "$")
328
+ return op.mcpConnector ?? "primary";
329
+ return undefined;
338
330
  }
339
- async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder, authState = { skillAutonomous: false, sawConfirm: false }) {
331
+ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder, authState = { skillAutonomous: false }) {
340
332
  switch (op.kind) {
341
333
  case "$set": {
342
334
  // v0.5.0 item 3 — `$set X = "...$(REF)..."` now resolves $(REF) at
@@ -392,12 +384,12 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
392
384
  emissions.push(`Reason: ${body}`);
393
385
  return { lastBoundVar: null, lastValue: undefined };
394
386
  }
395
- case "!": {
387
+ case "emit": {
396
388
  const body = substituteRuntime(op.body, vars);
397
389
  emissions.push(body);
398
390
  return { lastBoundVar: null, lastValue: undefined };
399
391
  }
400
- case "@": {
392
+ case "shell": {
401
393
  const body = op.policy === "unsafe"
402
394
  ? substituteRuntimeUnsafe(op.body, vars)
403
395
  : substituteRuntime(op.body, vars);
@@ -405,8 +397,6 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
405
397
  if (ctx.mechanical === true) {
406
398
  const label = op.policy === "unsafe" ? "Would run unsafe shell" : "Would run shell";
407
399
  emissions.push(`${label}: ${body} (mechanical: true preview).`);
408
- // Bind a placeholder so downstream `$(VAR)` substitutions resolve.
409
- // Matches the convention used by `$`/`~`/`>` mechanical-mode binding.
410
400
  const flatKey = `${targetName}.output`;
411
401
  const placeholder = `[mechanical: would run ${body.slice(0, 40)}${body.length > 40 ? "..." : ""}]`;
412
402
  vars.set(flatKey, placeholder);
@@ -427,7 +417,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
427
417
  else {
428
418
  const tokens = tokenizeShellArgs(body);
429
419
  if (tokens.length === 0) {
430
- throw new OpError(`Empty \`@\` op body in target '${targetName}'.`, "@", "Provide a shell command body, e.g., `@ echo hello`.", targetName);
420
+ throw new OpError(`Empty \`shell(...)\` op body in target '${targetName}'.`, "shell", "Provide a non-empty `command=\"...\"` kwarg.", targetName);
431
421
  }
432
422
  const [bin, ...args] = tokens;
433
423
  stdout = await execShellCommand(bin, args, shellTimeoutMs);
@@ -441,41 +431,14 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
441
431
  lastValue: stdout,
442
432
  };
443
433
  }
444
- case "??": {
445
- const promptStr = substituteRuntime(op.body, vars);
446
- if (ctx.askUser === undefined) {
447
- // Autonomous mode no interactive surface wired. Per decision 6 +
448
- // §6 dispatcher routing, `??` fails fast so dependent targets don't
449
- // silently fall through.
450
- throw new InteractiveOpInAutonomousModeError(promptStr, targetName);
451
- }
452
- const response = await ctx.askUser(promptStr);
453
- const outName = op.outputVar;
454
- if (outName !== undefined)
455
- vars.set(outName, response);
456
- // Decline semantics (per Section 2 Ops + §13 Open Q #2 resolution):
457
- // bind the response AND short-circuit downstream via soft op-error
458
- // routed through else: / # OnError:. Closes the silent-fall-through
459
- // security bug pattern (subsequent `apply:` running on a "no").
460
- if (isDeclineResponse(response)) {
461
- throw new OpError(`User declined at \`??\` prompt: '${promptStr}' (response: '${response}'). Dependent targets short-circuited.`, "??", "If decline should not short-circuit, handle the response in an `else:` branch or `# OnError:` fallback.", targetName);
462
- }
463
- return {
464
- lastBoundVar: outName ?? null,
465
- lastValue: response,
466
- };
467
- }
468
- case "&": {
469
- // v0.3.1: deferred-resolution path. `&` ops that reached runtime
470
- // are either (a) forward-references that compile couldn't inline
471
- // because the target wasn't yet stored, or (b) the rare "raw AST
472
- // bypassed compile()" case. Try to resolve through a SkillStore on
473
- // the context if one is wired; otherwise throw MissingSkillReferenceError
474
- // with the structured fields so `# OnError:` can catch.
434
+ case "inline": {
435
+ // Deferred-resolution path. `inline` ops that reached runtime are
436
+ // either (a) forward-references that compile couldn't inline because
437
+ // the target wasn't yet stored, or (b) the rare "raw AST bypassed
438
+ // compile()" case. Surface as MissingSkillReferenceError so `# OnError:`
439
+ // can catch.
475
440
  const skillName = op.ampParams?.skillName ?? "(unknown)";
476
- // No store wired = can't resolve. Surface as MissingSkillReferenceError
477
- // for consistency (same shape as runtime resolve-and-miss path).
478
- throw new MissingSkillReferenceError(skillName, "&", "&", targetName);
441
+ throw new MissingSkillReferenceError(skillName, "inline", "inline", targetName);
479
442
  }
480
443
  case "file_read": {
481
444
  // v0.7.0 — runtime-intrinsic file read. Substitutes `${VAR}` /
@@ -647,6 +610,18 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
647
610
  const toolName = m[1];
648
611
  const argsStr = m[2] ?? "";
649
612
  const args = parseToolArgs(argsStr);
613
+ // v0.16.0 — op-level `timeout=N` kwarg reserved for `$` dispatch (parity
614
+ // with legacy `~` `timeoutSeconds`). Pop from args before forwarding so
615
+ // connectors don't see a kwarg they didn't declare. Accepts int literal
616
+ // or `$(VAR)` / `${VAR}` ref string; resolveOpTimeoutMs coerces refs.
617
+ const rawTimeoutKwarg = args["timeout"];
618
+ let perOpTimeoutSec;
619
+ if (typeof rawTimeoutKwarg === "number")
620
+ perOpTimeoutSec = rawTimeoutKwarg;
621
+ else if (typeof rawTimeoutKwarg === "string" && rawTimeoutKwarg !== "")
622
+ perOpTimeoutSec = rawTimeoutKwarg;
623
+ if (rawTimeoutKwarg !== undefined)
624
+ delete args["timeout"];
650
625
  const connectorLabel = op.mcpConnector !== undefined ? `${op.mcpConnector}.` : "";
651
626
  const flatKey = `${targetName}.output`;
652
627
  // Mechanical preview, registry-routed, test escape hatch, no-dispatcher.
@@ -703,19 +678,23 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
703
678
  vars.set(op.outputVar, parsed);
704
679
  return { lastBoundVar: op.outputVar ?? flatKey, lastValue: parsed };
705
680
  }
706
- // v0.7.2 — bare-form name-match dispatch resolution. When `$ <name> ...`
707
- // is bare (no dotted prefix) AND `<name>` matches a registered
708
- // connector name, route to that connector directly. This makes the
709
- // canonical `$ llm prompt="..."` and `$ data_read mode=... query=...
710
- // limit=N` paths work in default deployments where the bridges are
711
- // auto-wired as `llm` + `memory` (rather than as `primary`). If
712
- // `<name>` isn't a registered connector, fall back to the legacy
713
- // `primary` lookup — preserves backward-compat for skills that wrote
714
- // `$ <tool>` expecting routing through the primary MCP connector.
715
- let connectorName = op.mcpConnector ?? "primary";
716
- if (op.mcpConnector === undefined && ctx.registry.hasMcpConnector(toolName)) {
681
+ // Bare-form name-match dispatch: when `$ <name> ...` is bare (no dotted
682
+ // prefix), `<name>` must match a registered connector name. This makes
683
+ // typed-contract patterns (`$ llm prompt=...`, `$ data_read mode=...`)
684
+ // dispatch cleanly to the auto-wired bridge connectors. v0.16.0 dropped
685
+ // the legacy "primary" fallback substrate-specific MCP tools require
686
+ // named form `$ <connector>.<tool>` and fail clearly if bare-form is
687
+ // used with no matching connector.
688
+ let connectorName;
689
+ if (op.mcpConnector !== undefined) {
690
+ connectorName = op.mcpConnector;
691
+ }
692
+ else if (ctx.registry.hasMcpConnector(toolName)) {
717
693
  connectorName = toolName;
718
694
  }
695
+ else {
696
+ connectorName = toolName; // will fail through to ConnectorNotFoundError below
697
+ }
719
698
  // v0.4.1 — defense-in-depth allowlist check. Lint catches this at
720
699
  // compile time via `disallowed-tool`; this runtime check is the
721
700
  // backstop for compiled artifacts run against a different runtime
@@ -740,7 +719,7 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
740
719
  }
741
720
  let rawResult;
742
721
  let dispatched = false;
743
- const timeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
722
+ const timeoutMs = resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars);
744
723
  // Op-level fallback (per language reference §9, extended to `$` for
745
724
  // cold-agent corpus consistency). On dispatch throw, bind the
746
725
  // fallback value to the output var; on missing connector with
@@ -796,121 +775,34 @@ async function execOpInner(op, vars, emissions, fallbacks, ctx, targetName, skil
796
775
  throw new OpError(`tool ${connectorLabel}${toolName} returned isError: ${innerText}`, "$", "The tool itself failed; inspect the inner error text. Add `(fallback: ...)` to the op for graceful failure.", targetName);
797
776
  }
798
777
  const bindValue = unwrapToolResult(rawResult);
799
- vars.set(flatKey, bindValue);
778
+ // v0.16.0 — fallback-on-empty parity with legacy `~`/`>` semantics
779
+ // (LocalModel empty-trimmed-response + Retrieval empty-array both bind
780
+ // fallback). Without this, the doc-vs-code mismatch at parser.ts:84-91
781
+ // (which claims `$` fallback fires "on throw or empty result") would
782
+ // persist. Empty = "" after trim, OR [], OR null/undefined.
783
+ let finalValue = bindValue;
784
+ if (dollarFallback !== undefined) {
785
+ const isEmptyString = typeof bindValue === "string" && bindValue.trim() === "";
786
+ const isEmptyArray = Array.isArray(bindValue) && bindValue.length === 0;
787
+ const isNullish = bindValue === null || bindValue === undefined;
788
+ if (isEmptyString || isEmptyArray || isNullish) {
789
+ finalValue = dollarFallback;
790
+ fallbacks.push({
791
+ target: targetName,
792
+ opKind: "$",
793
+ value: dollarFallback,
794
+ reason: `$ ${connectorLabel}${toolName} returned empty result`,
795
+ });
796
+ }
797
+ }
798
+ vars.set(flatKey, finalValue);
800
799
  if (op.outputVar !== undefined)
801
- vars.set(op.outputVar, bindValue);
800
+ vars.set(op.outputVar, finalValue);
802
801
  return {
803
802
  lastBoundVar: op.outputVar ?? flatKey,
804
- lastValue: bindValue,
803
+ lastValue: finalValue,
805
804
  };
806
805
  }
807
- case ">": {
808
- const p = op.retrievalParams;
809
- const querySub = substituteRuntime(p.query, vars);
810
- const extraSub = {};
811
- for (const [k, v] of Object.entries(p.extra)) {
812
- extraSub[k] = substituteRuntime(v, vars);
813
- }
814
- if (ctx.mechanical === true) {
815
- // Bind a 1-element array of placeholders so foreach M in $(RESULTS)
816
- // iterates once with a dotted-accessible M (matches common author
817
- // patterns like `$(M.id)`, `$(M.summary)`). Authors expecting empty
818
- // result sets test the empty case in unit tests, not mechanical mode.
819
- const mechanicalValue = p.fallback !== undefined
820
- ? p.fallback
821
- : [makeMechanicalPlaceholder(`${op.outputVar}[0]`)];
822
- emissions.push(`Would query DataStore \`${p.connector}\` with mode=${p.mode}, ` +
823
- `query="${querySub}", limit=${p.limit} (mechanical: true preview). ` +
824
- `Binding $(${op.outputVar}) = placeholder result set.`);
825
- vars.set(op.outputVar, mechanicalValue);
826
- return { lastBoundVar: op.outputVar, lastValue: mechanicalValue };
827
- }
828
- const store = ctx.registry.getDataStore(p.connector);
829
- const limitResolved = resolveIntParam(p.limit, vars, "limit");
830
- const filters = {
831
- query: querySub,
832
- mode: p.mode,
833
- limit: limitResolved,
834
- ...extraSub,
835
- };
836
- const retrievalTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
837
- // Op-level fallback (per language reference §9): on throw OR empty
838
- // result, bind the fallback value and continue. Without a fallback,
839
- // throws propagate to `else:` / `# OnError:` / target error.
840
- // coerceLiteralValue parses array-shaped literals (`[]`, `[a, b]`)
841
- // into actual arrays so downstream `foreach M in $(VAR)` iterates
842
- // correctly on the empty/sentinel case.
843
- const coercedFallback = p.fallback !== undefined ? coerceLiteralValue(p.fallback) : undefined;
844
- let results;
845
- try {
846
- results = await dispatchWithTimeout(() => store.query(filters), retrievalTimeoutMs, ">");
847
- if (coercedFallback !== undefined && Array.isArray(results) && results.length === 0) {
848
- results = coercedFallback;
849
- }
850
- }
851
- catch (err) {
852
- if (coercedFallback !== undefined) {
853
- results = coercedFallback;
854
- }
855
- else {
856
- throw err;
857
- }
858
- }
859
- vars.set(op.outputVar, results);
860
- return { lastBoundVar: op.outputVar, lastValue: results };
861
- }
862
- case "~": {
863
- const p = op.localModelParams;
864
- const promptSub = substituteRuntime(p.prompt, vars);
865
- if (ctx.mechanical === true) {
866
- const modelName = p.model ?? "default";
867
- // v0.2.12 Bug 23: bind a Proxy placeholder (same shape as the `$`/`>`
868
- // mechanical handlers) so dotted field access — `$(HI.outputs.text)`,
869
- // `$(HI.choices.0.message.content)` — resolves to deeper placeholders
870
- // instead of erroring with UnresolvedVariableError. Pre-fix the `~` op
871
- // bound a flat string, which broke field access in mechanical mode.
872
- const placeholder = makeMechanicalPlaceholder(op.outputVar ?? `${modelName}.output`);
873
- emissions.push(`Would invoke LocalModel \`${modelName}\` with prompt='${promptSub}' ` +
874
- `(mechanical: true preview). Binding $(${op.outputVar}) = ${stringifyValue(placeholder)}`);
875
- vars.set(op.outputVar, placeholder);
876
- return { lastBoundVar: op.outputVar, lastValue: placeholder };
877
- }
878
- let model;
879
- try {
880
- model = ctx.registry.getLocalModel(p.model);
881
- }
882
- catch (err) {
883
- if (p.fallback !== undefined) {
884
- vars.set(op.outputVar, p.fallback);
885
- return { lastBoundVar: op.outputVar, lastValue: p.fallback };
886
- }
887
- throw err;
888
- }
889
- const runOpts = {};
890
- if (p.maxTokens !== undefined) {
891
- runOpts.maxTokens = resolveIntParam(p.maxTokens, vars, "maxTokens");
892
- }
893
- const tildeTimeoutMs = resolveOpTimeoutMs(p.timeoutSeconds, skillTimeoutSec, absoluteTimeoutMs, vars);
894
- // Op-level fallback (per language reference §9): on throw OR empty
895
- // (trimmed) response, bind the fallback value.
896
- let response;
897
- try {
898
- response = await dispatchWithTimeout(() => model.run(promptSub, runOpts), tildeTimeoutMs, "~");
899
- if (p.fallback !== undefined && response.trim() === "") {
900
- response = p.fallback;
901
- }
902
- }
903
- catch (err) {
904
- if (p.fallback !== undefined) {
905
- response = p.fallback;
906
- }
907
- else {
908
- throw err;
909
- }
910
- }
911
- vars.set(op.outputVar, response);
912
- return { lastBoundVar: op.outputVar, lastValue: response };
913
- }
914
806
  case "foreach": {
915
807
  const listVal = resolveListExpr(op.foreachList, vars);
916
808
  const iterName = op.foreachIter;
@@ -1086,17 +978,17 @@ async function execShellCommand(bin, args, timeoutMs) {
1086
978
  child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
1087
979
  child.on("error", (err) => {
1088
980
  clearTimeout(timer);
1089
- reject(new OpError(`Failed to spawn '${bin}': ${err.message}`, "@", "Verify the binary is on PATH and executable. Check for typos in the `@` op body."));
981
+ reject(new OpError(`Failed to spawn '${bin}': ${err.message}`, "shell", "Verify the binary is on PATH and executable. Check for typos in the `shell(command=...)` body."));
1090
982
  });
1091
983
  child.on("close", (code) => {
1092
984
  clearTimeout(timer);
1093
985
  if (killed) {
1094
- reject(new OpTimeoutError(timeoutMs, "@"));
986
+ reject(new OpTimeoutError(timeoutMs, "shell"));
1095
987
  return;
1096
988
  }
1097
989
  if (code !== 0) {
1098
990
  const trimmed = stderr.trim();
1099
- reject(new OpError(`Shell command '${bin}' exited with code ${code}${trimmed ? `: ${trimmed.slice(0, 200)}` : ""}.`, "@", "Inspect the stderr output. Add `(fallback: ...)` to the `@` op for graceful failure on non-zero exit."));
991
+ reject(new OpError(`Shell command '${bin}' exited with code ${code}${trimmed ? `: ${trimmed.slice(0, 200)}` : ""}.`, "shell", "Inspect the stderr output. Add `(fallback: ...)` to the `shell(...)` op for graceful failure on non-zero exit."));
1100
992
  return;
1101
993
  }
1102
994
  // Strip trailing newline — convention for shell command output.
@@ -1104,10 +996,6 @@ async function execShellCommand(bin, args, timeoutMs) {
1104
996
  });
1105
997
  });
1106
998
  }
1107
- function isDeclineResponse(raw) {
1108
- const t = raw.trim().toLowerCase();
1109
- return t === "" || t === "no" || t === "n" || t === "false" || t === "0";
1110
- }
1111
999
  /**
1112
1000
  * Resolve an integer parameter that may be a literal number or a string
1113
1001
  * containing a `$(VAR)` ref. Substitutes any refs then parseInts. Throws