skillscript-runtime 0.5.0 → 0.7.2

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 (43) hide show
  1. package/README.md +105 -35
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +15 -0
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/compile.d.ts.map +1 -1
  6. package/dist/compile.js +42 -2
  7. package/dist/compile.js.map +1 -1
  8. package/dist/connectors/config.d.ts.map +1 -1
  9. package/dist/connectors/config.js +10 -0
  10. package/dist/connectors/config.js.map +1 -1
  11. package/dist/connectors/local-model-mcp.d.ts +9 -0
  12. package/dist/connectors/local-model-mcp.d.ts.map +1 -0
  13. package/dist/connectors/local-model-mcp.js +73 -0
  14. package/dist/connectors/local-model-mcp.js.map +1 -0
  15. package/dist/connectors/memory-store-mcp.d.ts +9 -0
  16. package/dist/connectors/memory-store-mcp.d.ts.map +1 -0
  17. package/dist/connectors/memory-store-mcp.js +94 -0
  18. package/dist/connectors/memory-store-mcp.js.map +1 -0
  19. package/dist/help-content.d.ts.map +1 -1
  20. package/dist/help-content.js +316 -238
  21. package/dist/help-content.js.map +1 -1
  22. package/dist/lint.d.ts.map +1 -1
  23. package/dist/lint.js +296 -53
  24. package/dist/lint.js.map +1 -1
  25. package/dist/parser.d.ts +48 -3
  26. package/dist/parser.d.ts.map +1 -1
  27. package/dist/parser.js +377 -31
  28. package/dist/parser.js.map +1 -1
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +93 -16
  31. package/dist/runtime.js.map +1 -1
  32. package/examples/classify-support-ticket.skill.md +13 -13
  33. package/examples/cut-release-tag.skill.md +14 -14
  34. package/examples/doc-qa-with-citations.skill.md +3 -3
  35. package/examples/feedback-sentiment-scan.skill.md +13 -13
  36. package/examples/hello.skill.md +2 -2
  37. package/examples/hello.skill.provenance.json +1 -1
  38. package/examples/morning-brief.skill.md +6 -6
  39. package/examples/queue-length-monitor.skill.md +4 -4
  40. package/examples/service-health-watch.skill.md +7 -7
  41. package/examples/youtrack-morning-sweep.skill.md +4 -4
  42. package/package.json +1 -1
  43. package/scaffold/connectors.json +15 -13
package/dist/parser.js CHANGED
@@ -1,6 +1,23 @@
1
1
  // Source text → AST. The parser recognizes the full v1 grammar but performs
2
2
  // no resolution against external state. Semantic analysis (variable resolution,
3
3
  // data-skill inlining, topo-sort) lives in compile.ts.
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:
7
+ * `verb(kwarg=value, ...) [-> BINDING]`.
8
+ *
9
+ * Anything else with function-call shape is rejected by parser with a
10
+ * remediation pointing at `$ tool args -> R` for MCP dispatch.
11
+ */
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)
18
+ "file_read", // new — read file contents at runtime
19
+ "file_write", // new — write file contents at runtime
20
+ ];
4
21
  /**
5
22
  * Case-insensitive accept, canonical-form return. The `allowed` list defines
6
23
  * canonical form (the first match for any case-folded input). Returns `null`
@@ -21,12 +38,17 @@ const REQUIRES_LINE = /^(user-var|system-var):([A-Za-z0-9_-]+)\s*(?:→|->)\s*([
21
38
  const CAPABILITY_TOKEN = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
22
39
  /** `&` op: `& skill-name [arg=value ...] [-> VARNAME]`. Skill names follow the same charset as filesystem-safe identifiers (alphanumeric, hyphen, underscore). */
23
40
  const AMPERSAND_OP_REGEX = /^&\s+([A-Za-z0-9][\w-]*)\s*(.*?)(?:\s*->\s*([A-Za-z_]\w*))?(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
24
- const SET_OP_REGEX = /^\$set\s+([A-Za-z_]\w*)\s*=\s*(.*)$/;
41
+ // v0.7.2 `(.*)` widened to `([\s\S]*)` so multi-line triple-quote
42
+ // (`"""..."""`) values fold into a single $set value capture. Without the
43
+ // dotall-equivalent, the `.` excludes newlines and the regex stops at the
44
+ // first line's `"""` opening.
45
+ const SET_OP_REGEX = /^\$set\s+([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/;
25
46
  // v0.3.0 accumulator. `$append VAR <value>` — single-value append to a
26
47
  // list-typed VAR. Form: `$append IDENT <space> <value>`. Mirrors $set
27
48
  // in shape (var name + value) but the runtime mutates an outer-scope
28
49
  // list rather than overwriting. See spec memory `9d6079bb` + `442cf4bb`.
29
- const APPEND_OP_REGEX = /^\$append\s+([A-Za-z_]\w*)\s+(.+)$/;
50
+ // v0.7.2 `(.+)` widened to `([\s\S]+)` for same multi-line reason.
51
+ const APPEND_OP_REGEX = /^\$append\s+([A-Za-z_]\w*)\s+([\s\S]+)$/;
30
52
  const FOREACH_OP_REGEX = /^foreach\s+([A-Za-z_]\w*)\s+in\s+(.+?):\s*$/;
31
53
  const IF_OP_REGEX = /^if\s+(.+?):\s*$/;
32
54
  const ELIF_OP_REGEX = /^elif\s+(.+?):\s*$/;
@@ -53,20 +75,21 @@ const LOCAL_MODEL_OP_REGEX = /^~\s+(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s
53
75
  const MCP_CONNECTOR_PREFIX = /^([a-z_][a-z0-9_-]*)\.(?=[A-Za-z_])([\s\S]*)$/;
54
76
  // Narrow v1 condition grammar.
55
77
  // v0.3.4: filter chain support — each `(REF)(|filter)?` became `(REF)(|filter)*`
56
- // to match `substituteRuntime`'s chain capture (runtime.ts:1158). Closes the
57
- // recurring "filter chain works in substitution but not conditions" gap named
58
- // in dev-log §14. Runtime regex set mirrors this; both update together.
59
- const COND_TRUTHY = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*$/;
78
+ // to match `substituteRuntime`'s chain capture. Closes the recurring "filter
79
+ // chain works in substitution but not conditions" gap named in dev-log §14.
80
+ // v0.7.0: REF_PATTERN accepts both `$(REF)` (legacy) and `${REF}` (canonical).
81
+ // Both forms have identical semantics; migration tool rewrites old → new.
82
+ const REF_PATTERN = "\\$(?:\\([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*(?:\\s*\\|\\s*[A-Za-z_]\\w*(?:\\s*:\\s*\"[^\"]*\")?)*\\)|\\{[A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*(?:\\s*\\|\\s*[A-Za-z_]\\w*(?:\\s*:\\s*\"[^\"]*\")?)*\\})";
83
+ const REF_PATTERN_NO_FILTER = "\\$(?:\\([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*\\)|\\{[A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*\\})";
84
+ const COND_TRUTHY = new RegExp(`^\\s*${REF_PATTERN}\\s*$`);
60
85
  /** `$(REF) ==/!= "literal"` — ref-vs-string equality. Filter chain on the ref side. */
61
- const COND_EQ = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*(?:==|!=)\s*"[^"]*"\s*$/;
86
+ const COND_EQ = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:==|!=)\\s*"[^"]*"\\s*$`);
62
87
  /**
63
88
  * `$(REF) ==/!= $(REF)` — ref-vs-ref equality. Extended 2026-05-21 per
64
- * language reference §5; surfaced by the cold-agent skills battery (a
65
- * sub-agent reached for `$(FP|trim) == $(LAST_FP|trim)` unprompted as
66
- * the natural change-detection pattern). Filter chain + dotted field
67
- * access permitted on either side.
89
+ * language reference §5. Filter chain + dotted field access permitted on
90
+ * either side.
68
91
  */
69
- const COND_EQ_REF = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*(?:==|!=)\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*$/;
92
+ const COND_EQ_REF = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:==|!=)\\s*${REF_PATTERN}\\s*$`);
70
93
  /**
71
94
  * `$(REF) </>/<=/>= "literal"` and `$(REF) </>/<=/>= $(REF)` — numeric
72
95
  * comparison. v0.2.5 addition per the orchestration carve-out: comparison
@@ -75,9 +98,9 @@ const COND_EQ_REF = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]
75
98
  * chain + dotted field access permitted on either side, matching
76
99
  * EQ/EQ_REF shape.
77
100
  */
78
- const COND_CMP = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*(?:<=|>=|<|>)\s*"[^"]*"\s*$/;
79
- const COND_CMP_REF = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*(?:<=|>=|<|>)\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s*$/;
80
- const COND_IN = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*\)\s+(?:not\s+)?in\s+\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\)\s*$/;
101
+ const COND_CMP = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:<=|>=|<|>)\\s*"[^"]*"\\s*$`);
102
+ const COND_CMP_REF = new RegExp(`^\\s*${REF_PATTERN}\\s*(?:<=|>=|<|>)\\s*${REF_PATTERN}\\s*$`);
103
+ const COND_IN = new RegExp(`^\\s*${REF_PATTERN}\\s+(?:not\\s+)?in\\s+${REF_PATTERN_NO_FILTER}\\s*$`);
81
104
  function validateCondition(cond) {
82
105
  return validateCompoundCondition(cond.trim());
83
106
  }
@@ -161,20 +184,20 @@ function stripOuterCondParens(cond) {
161
184
  }
162
185
  return trimmed.slice(1, -1).trim();
163
186
  }
164
- /** Detects `$(REF) = "literal"` — a single `=` in condition position. */
165
- const SINGLE_EQ_IN_COND = /\$\([^)]+\)\s*=(?!=)\s*"[^"]*"/;
187
+ /** Detects `$(REF) = "literal"` or `${REF} = "literal"` — single `=` in condition position. */
188
+ const SINGLE_EQ_IN_COND = /\$(?:\([^)]+\)|\{[^}]+\})\s*=(?!=)\s*"[^"]*"/;
166
189
  /**
167
- * If the condition contains `$(REF) = "..."` (single `=`), emit a specific
168
- * diagnostic suggesting `==`. Returns the diagnostic string when matched,
169
- * `null` otherwise. The grammar rejects single-`=` in condition position;
170
- * this surfaces the JS-shaped-bug pattern as a specific error rather than
171
- * the generic "unsupported condition" fallback.
190
+ * If the condition contains `$(REF) = "..."` or `${REF} = "..."` (single `=`),
191
+ * emit a specific diagnostic suggesting `==`. Returns the diagnostic string
192
+ * when matched, `null` otherwise.
172
193
  */
173
194
  function detectSingleEqualsInCondition(cond) {
174
195
  const m = SINGLE_EQ_IN_COND.exec(cond);
175
196
  if (m === null)
176
197
  return null;
177
- const fixed = cond.replace(/\$\(([^)]+)\)\s*=(?!=)\s*"([^"]*)"/, '$($1) == "$2"');
198
+ const fixed = cond
199
+ .replace(/\$\(([^)]+)\)\s*=(?!=)\s*"([^"]*)"/, '$($1) == "$2"')
200
+ .replace(/\$\{([^}]+)\}\s*=(?!=)\s*"([^"]*)"/, '${$1} == "$2"');
178
201
  return `\`=\` is not valid in a condition; use \`==\` for equality. rewrite as: \`${fixed}\``;
179
202
  }
180
203
  /**
@@ -299,10 +322,22 @@ function splitVarsLine(value) {
299
322
  function foldQuotedContinuations(lines) {
300
323
  const out = [];
301
324
  let buffer = null;
325
+ // v0.7.2 — triple-quote folding engages regardless of op kind. Three
326
+ // consecutive `"` chars don't accidentally appear in natural English
327
+ // prose, so the "phantom-scope from apostrophe" risk that gates single-
328
+ // quote folding to kwarg-bearing ops doesn't apply. inTripleAccum tracks
329
+ // which fold mode the buffer is in so we know which closing condition
330
+ // to test against.
331
+ let inTripleAccum = false;
302
332
  for (const line of lines) {
303
333
  if (buffer === null) {
304
- if (isKwargBearingLine(line) && hasUnclosedQuote(line)) {
334
+ if (hasUnclosedTriple(line)) {
335
+ buffer = line;
336
+ inTripleAccum = true;
337
+ }
338
+ else if (isKwargBearingLine(line) && hasUnclosedQuote(line)) {
305
339
  buffer = line;
340
+ inTripleAccum = false;
306
341
  }
307
342
  else {
308
343
  out.push(line);
@@ -310,9 +345,11 @@ function foldQuotedContinuations(lines) {
310
345
  }
311
346
  else {
312
347
  buffer = buffer + "\n" + line;
313
- if (!hasUnclosedQuote(buffer)) {
348
+ const closed = inTripleAccum ? !hasUnclosedTriple(buffer) : !hasUnclosedQuote(buffer);
349
+ if (closed) {
314
350
  out.push(buffer);
315
351
  buffer = null;
352
+ inTripleAccum = false;
316
353
  }
317
354
  }
318
355
  }
@@ -344,6 +381,24 @@ function hasUnclosedQuote(text) {
344
381
  }
345
382
  return inDouble || inSingle;
346
383
  }
384
+ /**
385
+ * v0.7.2 — true if `text` contains an odd number of `"""` triple-quote
386
+ * delimiters (i.e., an unterminated triple-quote literal). Scans the
387
+ * string counting non-overlapping `"""` occurrences.
388
+ */
389
+ function hasUnclosedTriple(text) {
390
+ let count = 0;
391
+ for (let i = 0; i <= text.length - 3;) {
392
+ if (text[i] === '"' && text[i + 1] === '"' && text[i + 2] === '"') {
393
+ count++;
394
+ i += 3;
395
+ }
396
+ else {
397
+ i++;
398
+ }
399
+ }
400
+ return count % 2 === 1;
401
+ }
347
402
  /**
348
403
  * Split a `# Triggers:` header value into separate trigger entries.
349
404
  *
@@ -366,21 +421,56 @@ function splitTriggersLine(value) {
366
421
  return value.split(splitRegex);
367
422
  }
368
423
  /**
369
- * `$set` and `>` / `~` arg-value quote-strip rules:
370
- * - Matching outer `"..."` or `'...'`: stripped, inner whitespace preserved.
424
+ * `$set` / `>` / `~` / kwarg arg-value quote-strip rules:
425
+ * - Matching outer `"..."`: stripped + interpret \n/\t/\\/\" escapes (v0.7.2).
426
+ * - Matching outer `'...'`: stripped, no escape interpretation (literal).
371
427
  * - Mismatched / unquoted: verbatim, trailing whitespace trimmed.
428
+ *
429
+ * v0.7.2 — escape interpretation in double-quoted strings closes the R4
430
+ * cold-author footgun where `$set X = "line1\nline2"` stored literal
431
+ * `\n` bytes. Bash/Python/JS/Go all interpret these escapes; skillscript
432
+ * not interpreting was the surprise. Single-quoted strings reserved for
433
+ * v0.8+ explicit literal-pass-through semantics if a real use case
434
+ * surfaces.
372
435
  */
373
436
  export function processSetValue(raw) {
374
437
  const trimmed = raw.replace(/\s+$/, "");
438
+ // v0.7.2 — triple-quote `"""..."""` multi-line literal. Check before the
439
+ // single-quote pair check; a value like `"""abc"""` shouldn't be shortened
440
+ // to `""abc""` via the `"..."` branch.
441
+ if (trimmed.length >= 6 && trimmed.startsWith('"""') && trimmed.endsWith('"""')) {
442
+ return interpretDoubleQuotedEscapes(trimmed.slice(3, -3));
443
+ }
375
444
  if (trimmed.length >= 2) {
376
445
  const first = trimmed[0];
377
446
  const last = trimmed[trimmed.length - 1];
378
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
447
+ if (first === '"' && last === '"') {
448
+ return interpretDoubleQuotedEscapes(trimmed.slice(1, -1));
449
+ }
450
+ if (first === "'" && last === "'") {
379
451
  return trimmed.slice(1, -1);
380
452
  }
381
453
  }
382
454
  return trimmed;
383
455
  }
456
+ /**
457
+ * v0.7.2 — interpret common escape sequences in double-quoted string
458
+ * literals: `\n` → newline, `\t` → tab, `\\` → literal backslash,
459
+ * `\"` → literal quote. Other `\X` sequences pass through verbatim
460
+ * (no over-eager interpretation; future v0.8+ may add `\r` / `\0` /
461
+ * etc. if cold-author demand surfaces).
462
+ */
463
+ function interpretDoubleQuotedEscapes(s) {
464
+ return s.replace(/\\(["\\nt])/g, (match, ch) => {
465
+ switch (ch) {
466
+ case '"': return '"';
467
+ case "\\": return "\\";
468
+ case "n": return "\n";
469
+ case "t": return "\t";
470
+ default: return match;
471
+ }
472
+ });
473
+ }
384
474
  /**
385
475
  * Tokenize whitespace-separated `key=value` pairs, respecting matching
386
476
  * single/double quotes and `[...]` brackets.
@@ -389,15 +479,35 @@ export function tokenizeKeywordArgs(input) {
389
479
  const tokens = [];
390
480
  let current = "";
391
481
  let inQuote = null;
482
+ let inTriple = false; // v0.7.2 — triple-quote `"""..."""` state
392
483
  let bracketDepth = 0;
393
484
  for (let i = 0; i < input.length; i++) {
394
485
  const ch = input[i];
486
+ // Triple-quote state takes precedence: inside `"""..."""`, single `"`
487
+ // and whitespace are content, not delimiters.
488
+ if (inTriple) {
489
+ current += ch;
490
+ if (ch === '"' && input[i + 1] === '"' && input[i + 2] === '"') {
491
+ current += input[i + 1];
492
+ current += input[i + 2];
493
+ i += 2;
494
+ inTriple = false;
495
+ }
496
+ continue;
497
+ }
395
498
  if (inQuote) {
396
499
  current += ch;
397
500
  if (ch === inQuote)
398
501
  inQuote = null;
399
502
  continue;
400
503
  }
504
+ // Check for triple-quote OPEN before single-quote (greedy match).
505
+ if (ch === '"' && input[i + 1] === '"' && input[i + 2] === '"') {
506
+ current += '"""';
507
+ i += 2;
508
+ inTriple = true;
509
+ continue;
510
+ }
401
511
  if (ch === '"' || ch === "'") {
402
512
  current += ch;
403
513
  inQuote = ch;
@@ -425,6 +535,99 @@ export function tokenizeKeywordArgs(input) {
425
535
  tokens.push(current);
426
536
  return tokens;
427
537
  }
538
+ /**
539
+ * v0.7.0 — paren-balanced extraction. Given text and the index of an opening
540
+ * `(`, return the substring between matched parens plus the index of the
541
+ * closing `)`. Quote-aware (skips parens inside `"..."`/`'...'`). Returns
542
+ * null on unbalanced parens.
543
+ */
544
+ function extractParenBody(text, openIdx) {
545
+ if (text[openIdx] !== "(")
546
+ return null;
547
+ let depth = 1;
548
+ let inQuote = null;
549
+ let inTriple = false; // v0.7.2 — `"""..."""` state
550
+ for (let i = openIdx + 1; i < text.length; i++) {
551
+ const ch = text[i];
552
+ if (inTriple) {
553
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') {
554
+ i += 2;
555
+ inTriple = false;
556
+ }
557
+ continue;
558
+ }
559
+ if (inQuote !== null) {
560
+ if (ch === inQuote)
561
+ inQuote = null;
562
+ continue;
563
+ }
564
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') {
565
+ i += 2;
566
+ inTriple = true;
567
+ continue;
568
+ }
569
+ if (ch === '"' || ch === "'") {
570
+ inQuote = ch;
571
+ continue;
572
+ }
573
+ if (ch === "(")
574
+ depth++;
575
+ else if (ch === ")") {
576
+ depth--;
577
+ if (depth === 0)
578
+ return { body: text.slice(openIdx + 1, i), endIdx: i };
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+ /**
584
+ * v0.7.0 — split a function-call argument list on top-level commas.
585
+ * Respects matched single/double quotes and `[...]`/`{...}`/`(...)` nesting.
586
+ */
587
+ function splitTopLevelCommas(text) {
588
+ const parts = [];
589
+ let cur = "";
590
+ let depth = 0;
591
+ let inQuote = null;
592
+ for (let i = 0; i < text.length; i++) {
593
+ const ch = text[i];
594
+ if (inQuote !== null) {
595
+ cur += ch;
596
+ if (ch === inQuote)
597
+ inQuote = null;
598
+ continue;
599
+ }
600
+ if (ch === '"' || ch === "'") {
601
+ cur += ch;
602
+ inQuote = ch;
603
+ continue;
604
+ }
605
+ if (ch === "(" || ch === "[" || ch === "{") {
606
+ depth++;
607
+ cur += ch;
608
+ continue;
609
+ }
610
+ if (ch === ")" || ch === "]" || ch === "}") {
611
+ depth = Math.max(0, depth - 1);
612
+ cur += ch;
613
+ continue;
614
+ }
615
+ if (ch === "," && depth === 0) {
616
+ const t = cur.trim();
617
+ if (t !== "")
618
+ parts.push(t);
619
+ cur = "";
620
+ continue;
621
+ }
622
+ cur += ch;
623
+ }
624
+ const t = cur.trim();
625
+ if (t !== "")
626
+ parts.push(t);
627
+ return parts;
628
+ }
629
+ /** v0.7.0 — prefix probe for function-call shape: `name(`. */
630
+ const FN_CALL_PREFIX = /^([a-z_][\w]*)\s*\(/;
428
631
  function splitMcpConnectorPrefix(body) {
429
632
  const m = MCP_CONNECTOR_PREFIX.exec(body);
430
633
  if (m === null)
@@ -455,7 +658,7 @@ function parseRetrievalArgs(argsStr, targetName) {
455
658
  // validate at parse time.
456
659
  let limit = 0;
457
660
  const rawLimit = map["limit"] ?? "";
458
- if (/\$\(/.test(rawLimit)) {
661
+ if (/\$[(\{]/.test(rawLimit)) {
459
662
  limit = rawLimit;
460
663
  }
461
664
  else {
@@ -512,7 +715,7 @@ function parseLocalModelArgs(argsStr, targetName) {
512
715
  if (!(key in map))
513
716
  return undefined;
514
717
  const raw = map[key];
515
- if (/\$\(/.test(raw))
718
+ if (/\$[(\{]/.test(raw))
516
719
  return raw;
517
720
  const n = parseInt(raw, 10);
518
721
  if (!Number.isFinite(n) || n <= 0) {
@@ -676,7 +879,7 @@ export function parse(source) {
676
879
  else if (key === "timeout") {
677
880
  // Per lesson ab6c19db: defer integer validation when value contains
678
881
  // `$(VAR)` ref. Runtime resolves via resolveIntParam at op dispatch.
679
- if (/\$\(/.test(value)) {
882
+ if (/\$[(\{]/.test(value)) {
680
883
  result.timeout = value;
681
884
  }
682
885
  else {
@@ -1177,6 +1380,149 @@ export function parse(source) {
1177
1380
  });
1178
1381
  continue;
1179
1382
  }
1383
+ // v0.7.0 — function-call op grammar: `verb(kwarg=value, ...) [-> VAR] [(fallback: "...")]`
1384
+ // Closed runtime-intrinsic op set in RUNTIME_INTRINSIC_FN_NAMES. Unknown
1385
+ // function-call names are parse-errors with remediation pointing at `$`.
1386
+ {
1387
+ const fnPrefix = FN_CALL_PREFIX.exec(stripped0);
1388
+ if (fnPrefix !== null) {
1389
+ const fnName = fnPrefix[1];
1390
+ const parenOpenIdx = fnPrefix[0].length - 1;
1391
+ const parsed = extractParenBody(stripped0, parenOpenIdx);
1392
+ if (parsed === null) {
1393
+ result.parseErrors.push(`Malformed function-call op '${fnName}(...)' in target '${currentTarget.name}' — unbalanced parens.`);
1394
+ continue;
1395
+ }
1396
+ // Parse comma-separated kwargs.
1397
+ const kwArgs = {};
1398
+ let argErr = false;
1399
+ for (const arg of splitTopLevelCommas(parsed.body)) {
1400
+ const eq = arg.indexOf("=");
1401
+ if (eq === -1) {
1402
+ result.parseErrors.push(`Malformed function-call arg '${arg}' in '${fnName}(...)' (target '${currentTarget.name}') — expected name=value.`);
1403
+ argErr = true;
1404
+ continue;
1405
+ }
1406
+ const k = arg.slice(0, eq).trim();
1407
+ const v = arg.slice(eq + 1).trim();
1408
+ kwArgs[k] = processSetValue(v);
1409
+ }
1410
+ if (argErr)
1411
+ continue;
1412
+ // Trailing `-> VAR` and optional `(fallback: "...")`.
1413
+ const tail = stripped0.slice(parsed.endIdx + 1).trim();
1414
+ let outputVar;
1415
+ let fallback;
1416
+ if (tail !== "") {
1417
+ const tailMatch = /^(?:->\s*([A-Za-z_]\w*))?(?:\s*\(fallback\s*:\s*(.+?)\))?\s*$/.exec(tail);
1418
+ if (tailMatch !== null) {
1419
+ if (tailMatch[1] !== undefined)
1420
+ outputVar = tailMatch[1];
1421
+ if (tailMatch[2] !== undefined)
1422
+ fallback = processSetValue(tailMatch[2]);
1423
+ }
1424
+ else {
1425
+ result.parseErrors.push(`Malformed function-call op '${fnName}(...)' trailer in target '${currentTarget.name}': '${tail}' — expected '-> VAR' and/or '(fallback: "value")'.`);
1426
+ continue;
1427
+ }
1428
+ }
1429
+ const approved = kwArgs["approved"];
1430
+ // Per-op dispatch — map function-call form to canonical AST shapes.
1431
+ if (fnName === "emit") {
1432
+ const text = kwArgs["text"] ?? "";
1433
+ opBucket.push({
1434
+ kind: "!",
1435
+ body: text,
1436
+ ...(outputVar !== undefined ? { outputVar } : {}),
1437
+ ...(approved !== undefined ? { approved } : {}),
1438
+ sourceForm: "function-call",
1439
+ });
1440
+ continue;
1441
+ }
1442
+ if (fnName === "ask") {
1443
+ const prompt = kwArgs["prompt"] ?? "";
1444
+ opBucket.push({
1445
+ kind: "??",
1446
+ body: prompt,
1447
+ ...(outputVar !== undefined ? { outputVar } : {}),
1448
+ ...(approved !== undefined ? { approved } : {}),
1449
+ sourceForm: "function-call",
1450
+ });
1451
+ continue;
1452
+ }
1453
+ if (fnName === "inline") {
1454
+ const skill = kwArgs["skill"] ?? "";
1455
+ opBucket.push({
1456
+ kind: "&",
1457
+ body: stripped0,
1458
+ ampParams: { skillName: skill, args: {} },
1459
+ ...(outputVar !== undefined ? { outputVar } : {}),
1460
+ ...(fallback !== undefined ? { fallback } : {}),
1461
+ ...(approved !== undefined ? { approved } : {}),
1462
+ sourceForm: "function-call",
1463
+ });
1464
+ continue;
1465
+ }
1466
+ if (fnName === "execute_skill") {
1467
+ const skillName = kwArgs["skill_name"] ?? "";
1468
+ const rest = Object.entries(kwArgs).filter(([k]) => k !== "skill_name" && k !== "approved");
1469
+ const inner = rest.map(([k, v]) => /\s/.test(v) || v.startsWith("{") || v.startsWith("[") ? `${k}=${v}` : `${k}="${v}"`).join(" ");
1470
+ opBucket.push({
1471
+ kind: "$",
1472
+ body: `execute_skill skill_name="${skillName}"${inner ? " " + inner : ""}`,
1473
+ ...(outputVar !== undefined ? { outputVar } : {}),
1474
+ ...(fallback !== undefined ? { fallback } : {}),
1475
+ ...(approved !== undefined ? { approved } : {}),
1476
+ sourceForm: "function-call",
1477
+ });
1478
+ continue;
1479
+ }
1480
+ if (fnName === "shell") {
1481
+ const command = kwArgs["command"] ?? "";
1482
+ const unsafe = kwArgs["unsafe"] === "true";
1483
+ opBucket.push({
1484
+ kind: "@",
1485
+ body: command,
1486
+ ...(unsafe ? { policy: "unsafe" } : {}),
1487
+ ...(outputVar !== undefined ? { outputVar } : {}),
1488
+ ...(fallback !== undefined ? { fallback } : {}),
1489
+ ...(approved !== undefined ? { approved } : {}),
1490
+ sourceForm: "function-call",
1491
+ });
1492
+ continue;
1493
+ }
1494
+ if (fnName === "file_read") {
1495
+ const path = kwArgs["path"] ?? "";
1496
+ opBucket.push({
1497
+ kind: "file_read",
1498
+ body: stripped0,
1499
+ fileParams: { path },
1500
+ ...(outputVar !== undefined ? { outputVar } : {}),
1501
+ ...(fallback !== undefined ? { fallback } : {}),
1502
+ sourceForm: "function-call",
1503
+ });
1504
+ continue;
1505
+ }
1506
+ if (fnName === "file_write") {
1507
+ const path = kwArgs["path"] ?? "";
1508
+ const content = kwArgs["content"] ?? "";
1509
+ opBucket.push({
1510
+ kind: "file_write",
1511
+ body: stripped0,
1512
+ fileParams: { path, content },
1513
+ ...(outputVar !== undefined ? { outputVar } : {}),
1514
+ ...(approved !== undefined ? { approved } : {}),
1515
+ sourceForm: "function-call",
1516
+ });
1517
+ continue;
1518
+ }
1519
+ // Unknown function-call name — runtime-intrinsic set is closed.
1520
+ result.parseErrors.push(`Unknown function-call op '${fnName}(...)' in target '${currentTarget.name}'. ` +
1521
+ `Runtime-intrinsic ops are: ${RUNTIME_INTRINSIC_FN_NAMES.join(", ")}. ` +
1522
+ `If this is an MCP tool, use \`$ ${fnName} args -> R\` shape instead.`);
1523
+ continue;
1524
+ }
1525
+ }
1180
1526
  const stripped = line.replace(/^\s+/, "");
1181
1527
  let kind = null;
1182
1528
  let body = "";