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/parser.js CHANGED
@@ -2,22 +2,21 @@
2
2
  // no resolution against external state. Semantic analysis (variable resolution,
3
3
  // data-skill inlining, topo-sort) lives in compile.ts.
4
4
  /**
5
- * v0.7.0 — runtime-intrinsic function-call names. Closed set of ops the
6
- * language implements directly (no MCP dispatch). Function-call grammar:
5
+ * Runtime-intrinsic function-call names. Closed set of ops the language
6
+ * implements directly (no MCP dispatch). Function-call grammar:
7
7
  * `verb(kwarg=value, ...) [-> BINDING]`.
8
8
  *
9
9
  * Anything else with function-call shape is rejected by parser with a
10
10
  * remediation pointing at `$ tool args -> R` for MCP dispatch.
11
11
  */
12
12
  export const RUNTIME_INTRINSIC_FN_NAMES = [
13
- "emit", // → ! (output to skill consumer)
14
- "ask", // ?? (prompt user)
15
- "inline", // & (compile-time skill composition)
16
- "execute_skill", // $ execute_skill (runtime skill invocation)
17
- "shell", // → @ (local subprocess)
13
+ "emit", // output to skill consumer
14
+ "inline", // compile-time skill composition
15
+ "execute_skill", // runtime skill invocation (dispatches via $ execute_skill)
16
+ "shell", // local subprocess
18
17
  "file_read", // read file contents at runtime
19
18
  "file_write", // write file contents at runtime
20
- "notify", // v0.8.0 — mid-skill synchronous agent alert via AgentConnector(s)
19
+ "notify", // mid-skill synchronous agent alert via AgentConnector(s)
21
20
  ];
22
21
  /**
23
22
  * Case-insensitive accept, canonical-form return. The `allowed` list defines
@@ -37,8 +36,6 @@ function normalizeEnumValue(raw, allowed) {
37
36
  const REQUIRES_LINE = /^(user-var|system-var):([A-Za-z0-9_-]+)\s*(?:→|->)\s*([A-Za-z_][\w-]*)\s*(?:\(\s*fallback\s*:\s*(.+?)\s*\)\s*)?$/;
38
37
  /** Capability token: `connector_type.feature_flag`. Matches one space-separated token of a capability `# Requires:` line. */
39
38
  const CAPABILITY_TOKEN = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
40
- /** `&` op: `& skill-name [arg=value ...] [-> VARNAME]`. Skill names follow the same charset as filesystem-safe identifiers (alphanumeric, hyphen, underscore). */
41
- const AMPERSAND_OP_REGEX = /^&\s+([A-Za-z0-9][\w-]*)\s*(.*?)(?:\s*->\s*([A-Za-z_]\w*))?(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
42
39
  // v0.7.2 — `(.*)` widened to `([\s\S]*)` so multi-line triple-quote
43
40
  // (`"""..."""`) values fold into a single $set value capture. Without the
44
41
  // dotall-equivalent, the `.` excludes newlines and the regex stops at the
@@ -75,24 +72,9 @@ function extractApprovedKwarg(body) {
75
72
  return undefined;
76
73
  return m[1] ?? m[2] ?? "";
77
74
  }
78
- // v0.9.2 — P0.5 detect missing-space dispatch: `$<word> args` where `<word>`
79
- // isn't `set`/`append` (the only legitimate no-space `$`-prefix verbs).
75
+ // P0.5 detect missing-space dispatch: `$<word> args` where `<word>` isn't
76
+ // `set`/`append` (the only legitimate no-space `$`-prefix verbs).
80
77
  const NO_SPACE_DISPATCH_RE = /^\$(?!set\b|append\b)\w+\s/;
81
- /**
82
- * `>` and `~` ops accept optional trailing `(fallback: <value>)` per
83
- * language reference §9 (Error Handling, Layer 3). Fires when the op
84
- * throws or returns empty — runtime binds the fallback value to the
85
- * output var and continues without surfacing the error.
86
- *
87
- * Value is permissive (matching `# Requires:` cascade convention): bare
88
- * identifiers (`ip-based`), quoted strings (`"weather unavailable"`),
89
- * array literals (`[]`, `[a, b]`), and arbitrary text between the colon
90
- * and the closing paren are all accepted. Parser stores the raw form;
91
- * runtime applies `coerceLiteralValue` for `>` (binds array on `[...]`)
92
- * and the raw string for `~` (model response shape).
93
- */
94
- const RETRIEVAL_OP_REGEX = /^>\s+(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
95
- const LOCAL_MODEL_OP_REGEX = /^~\s+(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
96
78
  const MCP_CONNECTOR_PREFIX = /^([a-z_][a-z0-9_-]*)\.(?=[A-Za-z_])([\s\S]*)$/;
97
79
  // Narrow v1 condition grammar.
98
80
  // v0.3.4: filter chain support — each `(REF)(|filter)?` became `(REF)(|filter)*`
@@ -122,26 +104,44 @@ const COND_EQ_REF = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:==|!=)\\s*${REF_PATTER
122
104
  const COND_CMP = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:<=|>=|<|>)\\s*"[^"]*"\\s*$`);
123
105
  const COND_CMP_REF = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:<=|>=|<|>)\\s*${REF_PATTERN}\\s*$`);
124
106
  const COND_IN = new RegExp(`^\\s*${REF_PATTERN}\\s+(?:not\\s+)?in\\s+${REF_PATTERN_NO_FILTER}\\s*$`);
107
+ // Parser resource caps. The "parser never throws on bad input" contract
108
+ // requires bounded recursion + length-capped regex application — without
109
+ // these guards, adversarial input crashes the host (stack overflow via
110
+ // `a and b and c and ...` deeply-chained AND; CPU exhaustion via
111
+ // REF_PATTERN's nested `*` quantifiers backtracking on near-valid input).
112
+ // Length cap is upstream of regex application (smaller risk than regex
113
+ // refactor, which is deferred). Depth cap is downstream of the length
114
+ // check; both must hold.
115
+ const MAX_CONDITION_LENGTH = 4096;
116
+ const MAX_CONDITION_DEPTH = 64;
125
117
  function validateCondition(cond) {
126
- return validateCompoundCondition(cond.trim());
118
+ const trimmed = cond.trim();
119
+ if (trimmed.length > MAX_CONDITION_LENGTH)
120
+ return false;
121
+ return validateCompoundCondition(trimmed, 0);
127
122
  }
128
- // v0.3.2 — recursive structural decomposition matching runtime evalCondition.
123
+ // Recursive structural decomposition matching runtime evalCondition.
129
124
  // Order: strip parens → split on outermost OR → AND → not prefix → simple shape.
130
- function validateCompoundCondition(cond) {
125
+ // `depth` is incremented per recursive call and capped at MAX_CONDITION_DEPTH —
126
+ // adversarial input like `a and b and c and ... (1000 ANDs)` would otherwise
127
+ // blow the JS stack.
128
+ function validateCompoundCondition(cond, depth) {
129
+ if (depth > MAX_CONDITION_DEPTH)
130
+ return false;
131
131
  const stripped = stripOuterCondParens(cond);
132
132
  const orIdx = findOuterCondToken(stripped, "or");
133
133
  if (orIdx >= 0) {
134
- return validateCompoundCondition(stripped.slice(0, orIdx).trim())
135
- && validateCompoundCondition(stripped.slice(orIdx + 4).trim());
134
+ return validateCompoundCondition(stripped.slice(0, orIdx).trim(), depth + 1)
135
+ && validateCompoundCondition(stripped.slice(orIdx + 4).trim(), depth + 1);
136
136
  }
137
137
  const andIdx = findOuterCondToken(stripped, "and");
138
138
  if (andIdx >= 0) {
139
- return validateCompoundCondition(stripped.slice(0, andIdx).trim())
140
- && validateCompoundCondition(stripped.slice(andIdx + 5).trim());
139
+ return validateCompoundCondition(stripped.slice(0, andIdx).trim(), depth + 1)
140
+ && validateCompoundCondition(stripped.slice(andIdx + 5).trim(), depth + 1);
141
141
  }
142
142
  const lead = stripped.trimStart();
143
143
  if (lead.startsWith("not "))
144
- return validateCompoundCondition(lead.slice(4));
144
+ return validateCompoundCondition(lead.slice(4), depth + 1);
145
145
  return COND_TRUTHY.test(stripped) || COND_EQ.test(stripped) || COND_EQ_REF.test(stripped) ||
146
146
  COND_CMP.test(stripped) || COND_CMP_REF.test(stripped) || COND_IN.test(stripped);
147
147
  }
@@ -686,109 +686,6 @@ function splitMcpConnectorPrefix(body) {
686
686
  return { connector: undefined, rest: body };
687
687
  return { connector: m[1], rest: m[2] };
688
688
  }
689
- function parseRetrievalArgs(argsStr, targetName) {
690
- const errors = [];
691
- const map = {};
692
- const tokens = tokenizeKeywordArgs(argsStr);
693
- for (const tok of tokens) {
694
- const eq = tok.indexOf("=");
695
- if (eq === -1) {
696
- errors.push(`Malformed \`>\` arg '${tok}' in target '${targetName}' — expected key=value`);
697
- continue;
698
- }
699
- const key = tok.slice(0, eq).trim();
700
- const rawValue = tok.slice(eq + 1);
701
- map[key] = processSetValue(rawValue);
702
- }
703
- for (const required of ["mode", "query", "limit"]) {
704
- if (!(required in map) || map[required] === "") {
705
- errors.push(`\`>\` op in target '${targetName}' missing required param '${required}'`);
706
- }
707
- }
708
- // Defer integer validation when the value contains a `$(VAR)` ref — runtime
709
- // substitutes + parses after the ref resolves. Literal numerics still
710
- // validate at parse time.
711
- let limit = 0;
712
- const rawLimit = map["limit"] ?? "";
713
- if (/\$[(\{]/.test(rawLimit)) {
714
- limit = rawLimit;
715
- }
716
- else {
717
- const n = parseInt(rawLimit, 10);
718
- if (!Number.isFinite(n) || n <= 0) {
719
- errors.push(`\`>\` op in target '${targetName}': 'limit' must be a positive integer or a \`$(VAR)\` ref (got '${rawLimit}')`);
720
- }
721
- else {
722
- limit = n;
723
- }
724
- }
725
- const extra = {};
726
- for (const [k, v] of Object.entries(map)) {
727
- if (k === "mode" || k === "query" || k === "limit" || k === "connector")
728
- continue;
729
- extra[k] = v;
730
- }
731
- return {
732
- params: {
733
- mode: map["mode"] ?? "",
734
- query: map["query"] ?? "",
735
- limit,
736
- connector: map["connector"] ?? "primary",
737
- extra,
738
- },
739
- errors,
740
- };
741
- }
742
- function parseLocalModelArgs(argsStr, targetName) {
743
- const errors = [];
744
- const map = {};
745
- const tokens = tokenizeKeywordArgs(argsStr);
746
- for (const tok of tokens) {
747
- const eq = tok.indexOf("=");
748
- if (eq === -1) {
749
- errors.push(`Malformed \`~\` arg '${tok}' in target '${targetName}' — expected key=value`);
750
- continue;
751
- }
752
- const key = tok.slice(0, eq).trim();
753
- const rawValue = tok.slice(eq + 1);
754
- map[key] = processSetValue(rawValue);
755
- }
756
- const recognized = new Set(["prompt", "model", "maxTokens", "timeoutSeconds"]);
757
- for (const key of Object.keys(map)) {
758
- if (!recognized.has(key)) {
759
- errors.push(`\`~\` op in target '${targetName}': unrecognized param '${key}' — strict grammar allows prompt/model/maxTokens/timeoutSeconds only. Interpolate context into the prompt string via $(...) instead.`);
760
- }
761
- }
762
- if (!("prompt" in map) || map["prompt"] === "") {
763
- errors.push(`\`~\` op in target '${targetName}' missing required param 'prompt'`);
764
- }
765
- // Defer integer validation when the value contains a `$(VAR)` ref.
766
- function deferInt(key) {
767
- if (!(key in map))
768
- return undefined;
769
- const raw = map[key];
770
- if (/\$[(\{]/.test(raw))
771
- return raw;
772
- const n = parseInt(raw, 10);
773
- if (!Number.isFinite(n) || n <= 0) {
774
- errors.push(`\`~\` op in target '${targetName}': '${key}' must be a positive integer or a \`$(VAR)\` ref (got '${raw}')`);
775
- return undefined;
776
- }
777
- return n;
778
- }
779
- const maxTokens = deferInt("maxTokens");
780
- const timeoutSeconds = deferInt("timeoutSeconds");
781
- const params = {
782
- prompt: map["prompt"] ?? "",
783
- };
784
- if ("model" in map && map["model"] !== "")
785
- params.model = map["model"];
786
- if (maxTokens !== undefined)
787
- params.maxTokens = maxTokens;
788
- if (timeoutSeconds !== undefined)
789
- params.timeoutSeconds = timeoutSeconds;
790
- return { params, errors };
791
- }
792
689
  function popToDepth(stack, targetDepth) {
793
690
  while (stack.length > 0 && stack[stack.length - 1].depth > targetDepth) {
794
691
  stack.pop();
@@ -1349,82 +1246,18 @@ export function parse(source) {
1349
1246
  continue;
1350
1247
  }
1351
1248
  if (stripped0.startsWith("> ")) {
1352
- const match = RETRIEVAL_OP_REGEX.exec(stripped0);
1353
- if (!match) {
1354
- result.parseErrors.push(`Malformed \`>\` op in target '${currentTarget.name}' — expected \`> key=value ... -> VARNAME [(fallback: "value")]\``);
1355
- continue;
1356
- }
1357
- const [, argsStr, outputVar, fallback] = match;
1358
- const parsed = parseRetrievalArgs(argsStr, currentTarget.name);
1359
- if (parsed.errors.length > 0) {
1360
- for (const e of parsed.errors)
1361
- result.parseErrors.push(e);
1362
- continue;
1363
- }
1364
- if (fallback !== undefined)
1365
- parsed.params.fallback = processSetValue(fallback);
1366
- opBucket.push({
1367
- kind: ">",
1368
- body: stripped0,
1369
- outputVar: outputVar,
1370
- retrievalParams: parsed.params,
1371
- });
1249
+ result.parseErrors.push(`Legacy \`>\` retrieval op in target '${currentTarget.name}' is no longer supported. ` +
1250
+ `Use \`$ data_read mode="fts|semantic|rerank" query="..." limit=N -> R\` (or qualify the connector: \`$ <connector>.data_read ...\`).`);
1372
1251
  continue;
1373
1252
  }
1374
1253
  if (stripped0.startsWith("~ ")) {
1375
- const match = LOCAL_MODEL_OP_REGEX.exec(stripped0);
1376
- if (!match) {
1377
- result.parseErrors.push(`Malformed \`~\` op in target '${currentTarget.name}' — expected \`~ key=value ... -> VARNAME [(fallback: "value")]\``);
1378
- continue;
1379
- }
1380
- const [, argsStr, outputVar, fallback] = match;
1381
- const parsed = parseLocalModelArgs(argsStr, currentTarget.name);
1382
- if (parsed.errors.length > 0) {
1383
- for (const e of parsed.errors)
1384
- result.parseErrors.push(e);
1385
- continue;
1386
- }
1387
- if (fallback !== undefined)
1388
- parsed.params.fallback = processSetValue(fallback);
1389
- opBucket.push({
1390
- kind: "~",
1391
- body: stripped0,
1392
- outputVar: outputVar,
1393
- localModelParams: parsed.params,
1394
- });
1254
+ result.parseErrors.push(`Legacy \`~\` LocalModel op in target '${currentTarget.name}' is no longer supported. ` +
1255
+ `Use \`$ llm prompt="..." [maxTokens=N] [model="..."] -> R\` (op-level \`timeout=N\` kwarg and trailing \`(fallback: "value")\` are honored).`);
1395
1256
  continue;
1396
1257
  }
1397
1258
  if (stripped0.startsWith("& ")) {
1398
- const match = AMPERSAND_OP_REGEX.exec(stripped0);
1399
- if (!match) {
1400
- result.parseErrors.push(`Malformed \`&\` op in target '${currentTarget.name}' — expected \`& skill-name [key=value ...] [-> VARNAME]\``);
1401
- continue;
1402
- }
1403
- const [, skillName, argsStr, outputVar, ampFallback] = match;
1404
- const args = {};
1405
- const tokens = tokenizeKeywordArgs(argsStr ?? "");
1406
- let argError = false;
1407
- for (const tok of tokens) {
1408
- const eq = tok.indexOf("=");
1409
- if (eq === -1) {
1410
- result.parseErrors.push(`Malformed \`&\` arg '${tok}' in target '${currentTarget.name}' — expected key=value`);
1411
- argError = true;
1412
- continue;
1413
- }
1414
- args[tok.slice(0, eq).trim()] = processSetValue(tok.slice(eq + 1));
1415
- }
1416
- if (argError)
1417
- continue;
1418
- const ampOp = {
1419
- kind: "&",
1420
- body: stripped0,
1421
- ampParams: { skillName: skillName, args },
1422
- };
1423
- if (outputVar !== undefined)
1424
- ampOp.outputVar = outputVar;
1425
- if (ampFallback !== undefined)
1426
- ampOp.fallback = processSetValue(ampFallback);
1427
- opBucket.push(ampOp);
1259
+ result.parseErrors.push(`Legacy \`&\` inline op in target '${currentTarget.name}' is no longer supported. ` +
1260
+ `Use \`inline(skill="...")\` for compile-time data-skill inlines, or \`execute_skill(name="...", ...) -> R\` for runtime composition.`);
1428
1261
  continue;
1429
1262
  }
1430
1263
  if (stripped0.startsWith("foreach ")) {
@@ -1515,34 +1348,21 @@ export function parse(source) {
1515
1348
  }
1516
1349
  const text = kwArgs["text"] ?? "";
1517
1350
  opBucket.push({
1518
- kind: "!",
1351
+ kind: "emit",
1519
1352
  body: text,
1520
1353
  ...(approved !== undefined ? { approved } : {}),
1521
- sourceForm: "function-call",
1522
- });
1523
- continue;
1524
- }
1525
- if (fnName === "ask") {
1526
- const prompt = kwArgs["prompt"] ?? "";
1527
- opBucket.push({
1528
- kind: "??",
1529
- body: prompt,
1530
- ...(outputVar !== undefined ? { outputVar } : {}),
1531
- ...(approved !== undefined ? { approved } : {}),
1532
- sourceForm: "function-call",
1533
1354
  });
1534
1355
  continue;
1535
1356
  }
1536
1357
  if (fnName === "inline") {
1537
1358
  const skill = kwArgs["skill"] ?? "";
1538
1359
  opBucket.push({
1539
- kind: "&",
1360
+ kind: "inline",
1540
1361
  body: stripped0,
1541
1362
  ampParams: { skillName: skill, args: {} },
1542
1363
  ...(outputVar !== undefined ? { outputVar } : {}),
1543
1364
  ...(fallback !== undefined ? { fallback } : {}),
1544
1365
  ...(approved !== undefined ? { approved } : {}),
1545
- sourceForm: "function-call",
1546
1366
  });
1547
1367
  continue;
1548
1368
  }
@@ -1568,7 +1388,6 @@ export function parse(source) {
1568
1388
  ...(outputVar !== undefined ? { outputVar } : {}),
1569
1389
  ...(fallback !== undefined ? { fallback } : {}),
1570
1390
  ...(approved !== undefined ? { approved } : {}),
1571
- sourceForm: "function-call",
1572
1391
  });
1573
1392
  continue;
1574
1393
  }
@@ -1576,13 +1395,12 @@ export function parse(source) {
1576
1395
  const command = kwArgs["command"] ?? "";
1577
1396
  const unsafe = kwArgs["unsafe"] === "true";
1578
1397
  opBucket.push({
1579
- kind: "@",
1398
+ kind: "shell",
1580
1399
  body: command,
1581
1400
  ...(unsafe ? { policy: "unsafe" } : {}),
1582
1401
  ...(outputVar !== undefined ? { outputVar } : {}),
1583
1402
  ...(fallback !== undefined ? { fallback } : {}),
1584
1403
  ...(approved !== undefined ? { approved } : {}),
1585
- sourceForm: "function-call",
1586
1404
  });
1587
1405
  continue;
1588
1406
  }
@@ -1594,7 +1412,6 @@ export function parse(source) {
1594
1412
  fileParams: { path },
1595
1413
  ...(outputVar !== undefined ? { outputVar } : {}),
1596
1414
  ...(fallback !== undefined ? { fallback } : {}),
1597
- sourceForm: "function-call",
1598
1415
  });
1599
1416
  continue;
1600
1417
  }
@@ -1607,7 +1424,6 @@ export function parse(source) {
1607
1424
  fileParams: { path, content },
1608
1425
  ...(outputVar !== undefined ? { outputVar } : {}),
1609
1426
  ...(approved !== undefined ? { approved } : {}),
1610
- sourceForm: "function-call",
1611
1427
  });
1612
1428
  continue;
1613
1429
  }
@@ -1655,7 +1471,6 @@ export function parse(source) {
1655
1471
  ...(outputVar !== undefined ? { outputVar } : {}),
1656
1472
  ...(fallback !== undefined ? { fallback } : {}),
1657
1473
  ...(approved !== undefined ? { approved } : {}),
1658
- sourceForm: "function-call",
1659
1474
  });
1660
1475
  continue;
1661
1476
  }
@@ -1670,19 +1485,11 @@ export function parse(source) {
1670
1485
  let kind = null;
1671
1486
  let body = "";
1672
1487
  let mcpConnectorForOp = undefined;
1673
- let atPolicy = undefined;
1674
- let atOutputVar = undefined;
1675
- let atFallback = undefined;
1676
- // Check `??` before `?`, `$set` before `$`.
1488
+ // Check `??`/`$set` before bare `?`/`$`.
1677
1489
  if (stripped.startsWith("?? ") || stripped === "??") {
1678
- const tail = stripped.slice(3).trim();
1679
- const m = /^(.+?)\s+->\s+([A-Za-z_]\w*)\s*$/.exec(tail);
1680
- if (m !== null) {
1681
- opBucket.push({ kind: "??", body: m[1].trim(), outputVar: m[2] });
1682
- }
1683
- else {
1684
- opBucket.push({ kind: "??", body: tail });
1685
- }
1490
+ result.parseErrors.push(`Legacy \`??\` ask op in target '${currentTarget.name}' is no longer supported. ` +
1491
+ `\`ask\` was removed in v0.16.0 — it conflated user-surfacing (which the runtime can't guarantee a channel for) with mutation-gating (already covered by \`approved="reason"\` per-op kwarg + \`# Autonomous: true\` skill flag). ` +
1492
+ `For input, use \`emit(text="...")\` and have the caller handle the round-trip. For mutation authorization, use \`approved=\` or \`# Autonomous:\`.`);
1686
1493
  continue;
1687
1494
  }
1688
1495
  else if (stripped.startsWith("$set ") || stripped === "$set") {
@@ -1696,6 +1503,30 @@ export function parse(source) {
1696
1503
  setValue: processSetValue(rawValue),
1697
1504
  });
1698
1505
  }
1506
+ else {
1507
+ // Malformed `$set` no longer silently drops. Diagnose the
1508
+ // specific shape so the author sees the cause, not a downstream
1509
+ // `undeclared-var` blaming the symptom. Sibling to v0.9.2 P0.8
1510
+ // (`$append VAR =`) + qwen-test P0.5 (`$<word>`) treatments.
1511
+ const tail = stripped.slice(4).trim();
1512
+ let detail;
1513
+ if (tail === "") {
1514
+ detail = "missing variable name and `=` assignment. Canonical: `$set VAR = value`.";
1515
+ }
1516
+ else if (!tail.includes("=")) {
1517
+ detail = `'${tail.slice(0, 40)}' has no \`=\` assignment. Canonical: \`$set VAR = value\` (the \`=\` is required).`;
1518
+ }
1519
+ else if (/^\d/.test(tail)) {
1520
+ detail = `'${tail.slice(0, 40)}' starts with a digit. Variable names must start with a letter or underscore.`;
1521
+ }
1522
+ else if (/^[A-Za-z_]\w*\.[A-Za-z_]/.test(tail)) {
1523
+ detail = `'${tail.slice(0, 40)}' uses dotted target. \`$set\` binds top-level variables only; dotted writes aren't supported. Bind a parent var first via \`$set PARENT = {...}\` then mutate via \`$ json_parse\` + structured ops.`;
1524
+ }
1525
+ else {
1526
+ detail = `'${tail.slice(0, 40)}' doesn't match the \`VAR = value\` shape. Variable names: \`[A-Za-z_]\\w*\`. Canonical: \`$set VAR = value\`.`;
1527
+ }
1528
+ result.parseErrors.push(`Malformed \`$set\` op in target '${currentTarget.name}': ${detail}`);
1529
+ }
1699
1530
  continue;
1700
1531
  }
1701
1532
  else if (stripped.startsWith("$append ") || stripped === "$append") {
@@ -1771,45 +1602,22 @@ export function parse(source) {
1771
1602
  body = stripped.slice(2).trim();
1772
1603
  }
1773
1604
  else if (stripped.startsWith("@ ") || stripped === "@") {
1774
- kind = "@";
1775
- let tail = stripped.slice(2).trim();
1776
- // Optional output binding: `-> VAR [(fallback: "...")]` at end of line.
1777
- // v0.2.4 Bug F: the trailing `(fallback: ...)` clause is now supported
1778
- // for parity with $/~/> ops — cold authors reach for op-level fallback
1779
- // as a defensive-coding posture and previously hit silent
1780
- // outputVar-not-bound failures.
1781
- const outMatch = /^(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/.exec(tail);
1782
- if (outMatch !== null) {
1783
- atOutputVar = outMatch[2];
1784
- if (outMatch[3] !== undefined)
1785
- atFallback = processSetValue(outMatch[3]);
1786
- tail = outMatch[1].trim();
1787
- }
1788
- // `@ unsafe <command>` — `unsafe` as literal first token signals
1789
- // opt-in full-shell exec (vs default structured-spawn sandbox).
1790
- const unsafeMatch = /^unsafe(?:\s+(.*))?$/.exec(tail);
1791
- if (unsafeMatch !== null) {
1792
- atPolicy = "unsafe";
1793
- body = (unsafeMatch[1] ?? "").trim();
1794
- }
1795
- else {
1796
- body = tail;
1797
- }
1605
+ result.parseErrors.push(`Legacy \`@\` shell op in target '${currentTarget.name}' is no longer supported. ` +
1606
+ `Use \`shell(command="...") [-> R] [(fallback: "...")]\` (add \`unsafe=true\` kwarg for full-shell exec; runtime opt-in still required).`);
1607
+ continue;
1798
1608
  }
1799
1609
  else if (stripped.startsWith("! ") || stripped === "!") {
1800
- kind = "!";
1801
- body = stripped.slice(2).trim();
1610
+ result.parseErrors.push(`Legacy \`!\` emit op in target '${currentTarget.name}' is no longer supported. ` +
1611
+ `Use \`emit(text="...")\`.`);
1612
+ continue;
1802
1613
  }
1803
1614
  if (kind !== null) {
1804
- // v0.9.4 — N1 also extract `approved=` for the no-binding `$` op path
1615
+ // N1: extract `approved=` for the no-binding `$` op path.
1805
1616
  const approvedKwarg = kind === "$" ? extractApprovedKwarg(body) : undefined;
1806
1617
  opBucket.push({
1807
1618
  kind,
1808
1619
  body,
1809
1620
  ...(mcpConnectorForOp !== undefined ? { mcpConnector: mcpConnectorForOp } : {}),
1810
- ...(atPolicy !== undefined ? { policy: atPolicy } : {}),
1811
- ...(atOutputVar !== undefined ? { outputVar: atOutputVar } : {}),
1812
- ...(atFallback !== undefined ? { fallback: atFallback } : {}),
1813
1621
  ...(approvedKwarg !== undefined ? { approved: approvedKwarg } : {}),
1814
1622
  });
1815
1623
  continue;
@@ -1826,7 +1634,7 @@ export function parse(source) {
1826
1634
  result.parseErrors.push(`Unknown block-introducer '${keyword}:' in target '${currentTarget.name}'. ` +
1827
1635
  `Skillscript recognizes \`if COND:\`, \`elif COND:\`, \`else:\`, and \`foreach IT in $(LIST):\` ` +
1828
1636
  `at body scope (target-level \`else:\` is the error handler). ` +
1829
- `Composition is via \`& skill-name\` (data-skill inline) or \`$ execute_skill skill_name="..."\` (in-skill invocation), not block syntax.`);
1637
+ `Composition is via \`inline(skill="...")\` (data-skill inline) or \`execute_skill(name="...")\` (in-skill invocation), not block syntax.`);
1830
1638
  scopeStack.push({
1831
1639
  kind: "unknown-block",
1832
1640
  target: currentTarget,