skillscript-runtime 0.2.4

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 (132) hide show
  1. package/ARCHITECTURE.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +346 -0
  4. package/dist/audit.d.ts +33 -0
  5. package/dist/audit.d.ts.map +1 -0
  6. package/dist/audit.js +76 -0
  7. package/dist/audit.js.map +1 -0
  8. package/dist/bootstrap.d.ts +69 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +117 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +805 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/compile.d.ts +88 -0
  17. package/dist/compile.d.ts.map +1 -0
  18. package/dist/compile.js +544 -0
  19. package/dist/compile.js.map +1 -0
  20. package/dist/connectors/agent-noop.d.ts +23 -0
  21. package/dist/connectors/agent-noop.d.ts.map +1 -0
  22. package/dist/connectors/agent-noop.js +43 -0
  23. package/dist/connectors/agent-noop.js.map +1 -0
  24. package/dist/connectors/agent.d.ts +54 -0
  25. package/dist/connectors/agent.d.ts.map +1 -0
  26. package/dist/connectors/agent.js +21 -0
  27. package/dist/connectors/agent.js.map +1 -0
  28. package/dist/connectors/index.d.ts +13 -0
  29. package/dist/connectors/index.d.ts.map +1 -0
  30. package/dist/connectors/index.js +17 -0
  31. package/dist/connectors/index.js.map +1 -0
  32. package/dist/connectors/local-model.d.ts +41 -0
  33. package/dist/connectors/local-model.d.ts.map +1 -0
  34. package/dist/connectors/local-model.js +106 -0
  35. package/dist/connectors/local-model.js.map +1 -0
  36. package/dist/connectors/mcp.d.ts +22 -0
  37. package/dist/connectors/mcp.d.ts.map +1 -0
  38. package/dist/connectors/mcp.js +31 -0
  39. package/dist/connectors/mcp.js.map +1 -0
  40. package/dist/connectors/memory-store.d.ts +53 -0
  41. package/dist/connectors/memory-store.d.ts.map +1 -0
  42. package/dist/connectors/memory-store.js +169 -0
  43. package/dist/connectors/memory-store.js.map +1 -0
  44. package/dist/connectors/registry.d.ts +74 -0
  45. package/dist/connectors/registry.d.ts.map +1 -0
  46. package/dist/connectors/registry.js +127 -0
  47. package/dist/connectors/registry.js.map +1 -0
  48. package/dist/connectors/skill-store.d.ts +38 -0
  49. package/dist/connectors/skill-store.d.ts.map +1 -0
  50. package/dist/connectors/skill-store.js +314 -0
  51. package/dist/connectors/skill-store.js.map +1 -0
  52. package/dist/connectors/types.d.ts +188 -0
  53. package/dist/connectors/types.d.ts.map +1 -0
  54. package/dist/connectors/types.js +35 -0
  55. package/dist/connectors/types.js.map +1 -0
  56. package/dist/dashboard/server.d.ts +40 -0
  57. package/dist/dashboard/server.d.ts.map +1 -0
  58. package/dist/dashboard/server.js +122 -0
  59. package/dist/dashboard/server.js.map +1 -0
  60. package/dist/dashboard/spa/app.js +375 -0
  61. package/dist/dashboard/spa/index.html +26 -0
  62. package/dist/dashboard/spa/styles.css +99 -0
  63. package/dist/errors.d.ts +111 -0
  64. package/dist/errors.d.ts.map +1 -0
  65. package/dist/errors.js +187 -0
  66. package/dist/errors.js.map +1 -0
  67. package/dist/filters.d.ts +17 -0
  68. package/dist/filters.d.ts.map +1 -0
  69. package/dist/filters.js +40 -0
  70. package/dist/filters.js.map +1 -0
  71. package/dist/index.d.ts +41 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +33 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/lint.d.ts +97 -0
  76. package/dist/lint.d.ts.map +1 -0
  77. package/dist/lint.js +990 -0
  78. package/dist/lint.js.map +1 -0
  79. package/dist/mcp-server.d.ts +93 -0
  80. package/dist/mcp-server.d.ts.map +1 -0
  81. package/dist/mcp-server.js +505 -0
  82. package/dist/mcp-server.js.map +1 -0
  83. package/dist/metrics.d.ts +51 -0
  84. package/dist/metrics.d.ts.map +1 -0
  85. package/dist/metrics.js +107 -0
  86. package/dist/metrics.js.map +1 -0
  87. package/dist/parser.d.ts +160 -0
  88. package/dist/parser.d.ts.map +1 -0
  89. package/dist/parser.js +991 -0
  90. package/dist/parser.js.map +1 -0
  91. package/dist/provenance.d.ts +43 -0
  92. package/dist/provenance.d.ts.map +1 -0
  93. package/dist/provenance.js +58 -0
  94. package/dist/provenance.js.map +1 -0
  95. package/dist/runtime.d.ts +145 -0
  96. package/dist/runtime.d.ts.map +1 -0
  97. package/dist/runtime.js +1071 -0
  98. package/dist/runtime.js.map +1 -0
  99. package/dist/scheduler.d.ts +121 -0
  100. package/dist/scheduler.d.ts.map +1 -0
  101. package/dist/scheduler.js +271 -0
  102. package/dist/scheduler.js.map +1 -0
  103. package/dist/skill-manager.d.ts +121 -0
  104. package/dist/skill-manager.d.ts.map +1 -0
  105. package/dist/skill-manager.js +251 -0
  106. package/dist/skill-manager.js.map +1 -0
  107. package/dist/testing/conformance.d.ts +57 -0
  108. package/dist/testing/conformance.d.ts.map +1 -0
  109. package/dist/testing/conformance.js +365 -0
  110. package/dist/testing/conformance.js.map +1 -0
  111. package/dist/testing/index.d.ts +3 -0
  112. package/dist/testing/index.d.ts.map +1 -0
  113. package/dist/testing/index.js +5 -0
  114. package/dist/testing/index.js.map +1 -0
  115. package/dist/trace.d.ts +141 -0
  116. package/dist/trace.d.ts.map +1 -0
  117. package/dist/trace.js +226 -0
  118. package/dist/trace.js.map +1 -0
  119. package/examples/README.md +56 -0
  120. package/examples/classify-support-ticket.skill.md +30 -0
  121. package/examples/cut-release-tag.skill.md +40 -0
  122. package/examples/doc-qa-with-citations.skill.md +12 -0
  123. package/examples/feedback-sentiment-scan.skill.md +29 -0
  124. package/examples/hello.skill.md +9 -0
  125. package/examples/hello.skill.provenance.json +10 -0
  126. package/examples/morning-brief.skill.md +24 -0
  127. package/examples/programmatic-trace-demo.mjs +89 -0
  128. package/examples/service-health-watch.skill.md +18 -0
  129. package/package.json +100 -0
  130. package/scaffold/config.toml +35 -0
  131. package/scaffold/connectors.json +19 -0
  132. package/scaffold/examples/hello.skill.md +9 -0
package/dist/parser.js ADDED
@@ -0,0 +1,991 @@
1
+ // Source text → AST. The parser recognizes the full v1 grammar but performs
2
+ // no resolution against external state. Semantic analysis (variable resolution,
3
+ // data-skill inlining, topo-sort) lives in compile.ts.
4
+ /**
5
+ * Case-insensitive accept, canonical-form return. The `allowed` list defines
6
+ * canonical form (the first match for any case-folded input). Returns `null`
7
+ * when the input doesn't match any canonical entry. Used uniformly across
8
+ * every enumerated frontmatter field per Section 1 Lexical conventions.
9
+ */
10
+ function normalizeEnumValue(raw, allowed) {
11
+ const lower = raw.toLowerCase();
12
+ for (const candidate of allowed) {
13
+ if (candidate.toLowerCase() === lower)
14
+ return candidate;
15
+ }
16
+ return null;
17
+ }
18
+ // Regex grammar.
19
+ const REQUIRES_LINE = /^(user-var|system-var):([A-Za-z0-9_-]+)\s*(?:→|->)\s*([A-Za-z_][\w-]*)\s*(?:\(\s*fallback\s*:\s*(.+?)\s*\)\s*)?$/;
20
+ /** Capability token: `connector_type.feature_flag`. Matches one space-separated token of a capability `# Requires:` line. */
21
+ const CAPABILITY_TOKEN = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
22
+ /** `&` op: `& skill-name [arg=value ...] [-> VARNAME]`. Skill names follow the same charset as filesystem-safe identifiers (alphanumeric, hyphen, underscore). */
23
+ 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*(.*)$/;
25
+ const FOREACH_OP_REGEX = /^foreach\s+([A-Za-z_]\w*)\s+in\s+(.+?):\s*$/;
26
+ const IF_OP_REGEX = /^if\s+(.+?):\s*$/;
27
+ const ELIF_OP_REGEX = /^elif\s+(.+?):\s*$/;
28
+ /**
29
+ * `>` and `~` ops accept optional trailing `(fallback: <value>)` per
30
+ * language reference §9 (Error Handling, Layer 3). Fires when the op
31
+ * throws or returns empty — runtime binds the fallback value to the
32
+ * output var and continues without surfacing the error.
33
+ *
34
+ * Value is permissive (matching `# Requires:` cascade convention): bare
35
+ * identifiers (`ip-based`), quoted strings (`"weather unavailable"`),
36
+ * array literals (`[]`, `[a, b]`), and arbitrary text between the colon
37
+ * and the closing paren are all accepted. Parser stores the raw form;
38
+ * runtime applies `coerceLiteralValue` for `>` (binds array on `[...]`)
39
+ * and the raw string for `~` (model response shape).
40
+ */
41
+ const RETRIEVAL_OP_REGEX = /^>\s+(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
42
+ const LOCAL_MODEL_OP_REGEX = /^~\s+(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/s;
43
+ const MCP_CONNECTOR_PREFIX = /^([a-z_][a-z0-9_-]*)\.(?=[A-Za-z_])([\s\S]*)$/;
44
+ // Narrow v1 condition grammar. AND/OR, numeric comparisons, defined-checks
45
+ // are deliberately excluded — lint surfaces complexity-creep at authoring time.
46
+ const COND_TRUTHY = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*)?\)\s*$/;
47
+ /** `$(REF) ==/!= "literal"` — ref-vs-string equality. */
48
+ const COND_EQ = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*)?\)\s*(?:==|!=)\s*"[^"]*"\s*$/;
49
+ /**
50
+ * `$(REF) ==/!= $(REF)` — ref-vs-ref equality. Extended 2026-05-21 per
51
+ * language reference §5; surfaced by the cold-agent skills battery (a
52
+ * sub-agent reached for `$(FP|trim) == $(LAST_FP|trim)` unprompted as
53
+ * the natural change-detection pattern). Filters + dotted field access
54
+ * permitted on either side.
55
+ */
56
+ const COND_EQ_REF = /^\s*\$\([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*$/;
57
+ const COND_IN = /^\s*\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s*\|\s*[A-Za-z_]\w*)?\)\s+(?:not\s+)?in\s+\$\([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\)\s*$/;
58
+ function validateCondition(cond) {
59
+ return COND_TRUTHY.test(cond) || COND_EQ.test(cond) || COND_EQ_REF.test(cond) || COND_IN.test(cond);
60
+ }
61
+ /** Detects `$(REF) = "literal"` — a single `=` in condition position. */
62
+ const SINGLE_EQ_IN_COND = /\$\([^)]+\)\s*=(?!=)\s*"[^"]*"/;
63
+ /**
64
+ * If the condition contains `$(REF) = "..."` (single `=`), emit a specific
65
+ * diagnostic suggesting `==`. Returns the diagnostic string when matched,
66
+ * `null` otherwise. The grammar rejects single-`=` in condition position;
67
+ * this surfaces the JS-shaped-bug pattern as a specific error rather than
68
+ * the generic "unsupported condition" fallback.
69
+ */
70
+ function detectSingleEqualsInCondition(cond) {
71
+ const m = SINGLE_EQ_IN_COND.exec(cond);
72
+ if (m === null)
73
+ return null;
74
+ const fixed = cond.replace(/\$\(([^)]+)\)\s*=(?!=)\s*"([^"]*)"/, '$($1) == "$2"');
75
+ return `\`=\` is not valid in a condition; use \`==\` for equality. rewrite as: \`${fixed}\``;
76
+ }
77
+ /**
78
+ * Reserved identifiers per Section 1 Lexical conventions. Rejected as
79
+ * variable names, target names (other than the special `default:` goal
80
+ * declaration), skill names, and foreach iterator IDENTs. Case-sensitive
81
+ * exact match — `default` is reserved; `Default` is allowed.
82
+ */
83
+ const RESERVED_KEYWORDS_CURRENT = new Set([
84
+ "default", "needs", "if", "elif", "else", "foreach", "in", "not", "unsafe",
85
+ ]);
86
+ /**
87
+ * Future-reserved — no current semantics. Reserved so v2 grammar additions
88
+ * stay non-breaking.
89
+ */
90
+ const RESERVED_KEYWORDS_FUTURE = new Set([
91
+ "while", "for", "match", "try", "catch", "return",
92
+ ]);
93
+ const ALL_RESERVED = new Set([...RESERVED_KEYWORDS_CURRENT, ...RESERVED_KEYWORDS_FUTURE]);
94
+ function checkReserved(name, positionLabel, suggestionExample) {
95
+ if (!ALL_RESERVED.has(name))
96
+ return null;
97
+ const futureNote = RESERVED_KEYWORDS_FUTURE.has(name) ? " (future-reserved for v2 grammar)" : "";
98
+ return `'${name}' is a reserved keyword${futureNote} and cannot be used as ${positionLabel}. Rename (e.g., ${suggestionExample}).`;
99
+ }
100
+ const INDENT_STEP = 4;
101
+ function leadingSpaces(rawLine) {
102
+ const m = /^( *)/.exec(rawLine);
103
+ return m ? m[1].length : 0;
104
+ }
105
+ /**
106
+ * Detect tab characters in indentation. Tabs are a parse error per Section 1
107
+ * Lexical conventions — the language enforces spaces-only block structure
108
+ * to eliminate editor-config debates. Returns the 1-indexed line numbers
109
+ * where tabs appear in leading whitespace.
110
+ */
111
+ function findTabIndentedLines(source) {
112
+ const offenders = [];
113
+ const lines = source.split("\n");
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ const match = /^[\t ]*/.exec(line);
117
+ if (match !== null && match[0].includes("\t")) {
118
+ offenders.push(i + 1);
119
+ }
120
+ }
121
+ return offenders;
122
+ }
123
+ // Top-level comma split — preserves commas inside `[...]` list literals.
124
+ function splitVarsLine(value) {
125
+ const parts = [];
126
+ let current = "";
127
+ let bracketDepth = 0;
128
+ for (const ch of value) {
129
+ if (ch === "[")
130
+ bracketDepth++;
131
+ else if (ch === "]")
132
+ bracketDepth = Math.max(0, bracketDepth - 1);
133
+ if (ch === "," && bracketDepth === 0) {
134
+ parts.push(current);
135
+ current = "";
136
+ }
137
+ else {
138
+ current += ch;
139
+ }
140
+ }
141
+ parts.push(current);
142
+ return parts;
143
+ }
144
+ /**
145
+ * Fold physical lines whose quoted-string values span line breaks into
146
+ * single logical lines. Cold-author corpus (Perry's 2/3 minion-battery
147
+ * hit, v0.2.2) showed multi-line `~ prompt="..."` strings are a common
148
+ * authoring pattern — multi-step LLM prompts, JSON examples, multi-
149
+ * paragraph instructions. Without folding, the line-iterating parse loop
150
+ * treats each interior newline as a block break and mis-parses.
151
+ *
152
+ * Folding only engages on kwarg-bearing op lines (`~ `, `> `, `& `) —
153
+ * the three op kinds whose values legitimately span newlines. Plain
154
+ * frontmatter (`# Description: symbol's intraday drops`), target labels,
155
+ * `!` literals, and shell `@` bodies are left untouched so that
156
+ * apostrophes in natural English prose don't open phantom string scopes
157
+ * that swallow the rest of the skill (Perry's v0.2.4 Bug D regression
158
+ * from the v0.2.2 fix).
159
+ */
160
+ function foldQuotedContinuations(lines) {
161
+ const out = [];
162
+ let buffer = null;
163
+ for (const line of lines) {
164
+ if (buffer === null) {
165
+ if (isKwargBearingLine(line) && hasUnclosedQuote(line)) {
166
+ buffer = line;
167
+ }
168
+ else {
169
+ out.push(line);
170
+ }
171
+ }
172
+ else {
173
+ buffer = buffer + "\n" + line;
174
+ if (!hasUnclosedQuote(buffer)) {
175
+ out.push(buffer);
176
+ buffer = null;
177
+ }
178
+ }
179
+ }
180
+ // Unterminated quote at EOF: push the accumulated buffer as-is so the
181
+ // downstream regex match fails cleanly with a malformed-op diagnostic
182
+ // rather than swallowing content.
183
+ if (buffer !== null)
184
+ out.push(buffer);
185
+ return out;
186
+ }
187
+ /**
188
+ * Three op kinds use `key=value` kwarg args where the value may legitimately
189
+ * span newlines. Everything else (frontmatter, target labels, `!` / `@` / `$`
190
+ * op bodies, control-flow keywords) is single-line by convention and must
191
+ * not engage the multi-line fold.
192
+ */
193
+ function isKwargBearingLine(line) {
194
+ const stripped = line.replace(/^\s+/, "");
195
+ return stripped.startsWith("~ ") || stripped.startsWith("> ") || stripped.startsWith("& ");
196
+ }
197
+ function hasUnclosedQuote(text) {
198
+ let inDouble = false;
199
+ let inSingle = false;
200
+ for (const ch of text) {
201
+ if (!inSingle && ch === '"')
202
+ inDouble = !inDouble;
203
+ else if (!inDouble && ch === "'")
204
+ inSingle = !inSingle;
205
+ }
206
+ return inDouble || inSingle;
207
+ }
208
+ /**
209
+ * Split a `# Triggers:` header value into separate trigger entries.
210
+ *
211
+ * Cron expressions naturally contain commas (e.g. `30,45 9 * * 1-5`), so a
212
+ * naive comma-split breaks legitimate multi-value cron schedules. Instead
213
+ * split at comma + source-keyword boundaries — the next entry begins where
214
+ * a known source token (cron/session/event/agent-event/file-watch/sensor)
215
+ * appears after a comma. v0.2.2 fix per Perry's 3/3 minion-battery hit.
216
+ *
217
+ * Examples:
218
+ * `cron: 30,45 9 * * 1-5` → one entry
219
+ * `cron: 0 9 * * *, session: start` → two entries
220
+ * `cron: 30,45 9 * * 1-5, cron: 0 16 * * 1-5` → two entries
221
+ */
222
+ function splitTriggersLine(value) {
223
+ const sourcePattern = ["session", "cron", "event", "agent-event", "file-watch", "sensor"]
224
+ .map((s) => s.replace(/-/g, "\\-"))
225
+ .join("|");
226
+ const splitRegex = new RegExp(`,\\s*(?=(?:${sourcePattern})\\s*:)`, "g");
227
+ return value.split(splitRegex);
228
+ }
229
+ /**
230
+ * `$set` and `>` / `~` arg-value quote-strip rules:
231
+ * - Matching outer `"..."` or `'...'`: stripped, inner whitespace preserved.
232
+ * - Mismatched / unquoted: verbatim, trailing whitespace trimmed.
233
+ */
234
+ export function processSetValue(raw) {
235
+ const trimmed = raw.replace(/\s+$/, "");
236
+ if (trimmed.length >= 2) {
237
+ const first = trimmed[0];
238
+ const last = trimmed[trimmed.length - 1];
239
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
240
+ return trimmed.slice(1, -1);
241
+ }
242
+ }
243
+ return trimmed;
244
+ }
245
+ /**
246
+ * Tokenize whitespace-separated `key=value` pairs, respecting matching
247
+ * single/double quotes and `[...]` brackets.
248
+ */
249
+ export function tokenizeKeywordArgs(input) {
250
+ const tokens = [];
251
+ let current = "";
252
+ let inQuote = null;
253
+ let bracketDepth = 0;
254
+ for (let i = 0; i < input.length; i++) {
255
+ const ch = input[i];
256
+ if (inQuote) {
257
+ current += ch;
258
+ if (ch === inQuote)
259
+ inQuote = null;
260
+ continue;
261
+ }
262
+ if (ch === '"' || ch === "'") {
263
+ current += ch;
264
+ inQuote = ch;
265
+ continue;
266
+ }
267
+ if (ch === "[") {
268
+ bracketDepth++;
269
+ current += ch;
270
+ continue;
271
+ }
272
+ if (ch === "]") {
273
+ bracketDepth = Math.max(0, bracketDepth - 1);
274
+ current += ch;
275
+ continue;
276
+ }
277
+ if (/\s/.test(ch) && bracketDepth === 0) {
278
+ if (current.trim() !== "")
279
+ tokens.push(current);
280
+ current = "";
281
+ continue;
282
+ }
283
+ current += ch;
284
+ }
285
+ if (current.trim() !== "")
286
+ tokens.push(current);
287
+ return tokens;
288
+ }
289
+ function splitMcpConnectorPrefix(body) {
290
+ const m = MCP_CONNECTOR_PREFIX.exec(body);
291
+ if (m === null)
292
+ return { connector: undefined, rest: body };
293
+ return { connector: m[1], rest: m[2] };
294
+ }
295
+ function parseRetrievalArgs(argsStr, targetName) {
296
+ const errors = [];
297
+ const map = {};
298
+ const tokens = tokenizeKeywordArgs(argsStr);
299
+ for (const tok of tokens) {
300
+ const eq = tok.indexOf("=");
301
+ if (eq === -1) {
302
+ errors.push(`Malformed \`>\` arg '${tok}' in target '${targetName}' — expected key=value`);
303
+ continue;
304
+ }
305
+ const key = tok.slice(0, eq).trim();
306
+ const rawValue = tok.slice(eq + 1);
307
+ map[key] = processSetValue(rawValue);
308
+ }
309
+ for (const required of ["mode", "query", "limit"]) {
310
+ if (!(required in map) || map[required] === "") {
311
+ errors.push(`\`>\` op in target '${targetName}' missing required param '${required}'`);
312
+ }
313
+ }
314
+ // Defer integer validation when the value contains a `$(VAR)` ref — runtime
315
+ // substitutes + parses after the ref resolves. Literal numerics still
316
+ // validate at parse time.
317
+ let limit = 0;
318
+ const rawLimit = map["limit"] ?? "";
319
+ if (/\$\(/.test(rawLimit)) {
320
+ limit = rawLimit;
321
+ }
322
+ else {
323
+ const n = parseInt(rawLimit, 10);
324
+ if (!Number.isFinite(n) || n <= 0) {
325
+ errors.push(`\`>\` op in target '${targetName}': 'limit' must be a positive integer or a \`$(VAR)\` ref (got '${rawLimit}')`);
326
+ }
327
+ else {
328
+ limit = n;
329
+ }
330
+ }
331
+ const extra = {};
332
+ for (const [k, v] of Object.entries(map)) {
333
+ if (k === "mode" || k === "query" || k === "limit" || k === "connector")
334
+ continue;
335
+ extra[k] = v;
336
+ }
337
+ return {
338
+ params: {
339
+ mode: map["mode"] ?? "",
340
+ query: map["query"] ?? "",
341
+ limit,
342
+ connector: map["connector"] ?? "primary",
343
+ extra,
344
+ },
345
+ errors,
346
+ };
347
+ }
348
+ function parseLocalModelArgs(argsStr, targetName) {
349
+ const errors = [];
350
+ const map = {};
351
+ const tokens = tokenizeKeywordArgs(argsStr);
352
+ for (const tok of tokens) {
353
+ const eq = tok.indexOf("=");
354
+ if (eq === -1) {
355
+ errors.push(`Malformed \`~\` arg '${tok}' in target '${targetName}' — expected key=value`);
356
+ continue;
357
+ }
358
+ const key = tok.slice(0, eq).trim();
359
+ const rawValue = tok.slice(eq + 1);
360
+ map[key] = processSetValue(rawValue);
361
+ }
362
+ const recognized = new Set(["prompt", "model", "maxTokens", "timeoutSeconds"]);
363
+ for (const key of Object.keys(map)) {
364
+ if (!recognized.has(key)) {
365
+ 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.`);
366
+ }
367
+ }
368
+ if (!("prompt" in map) || map["prompt"] === "") {
369
+ errors.push(`\`~\` op in target '${targetName}' missing required param 'prompt'`);
370
+ }
371
+ // Defer integer validation when the value contains a `$(VAR)` ref.
372
+ function deferInt(key) {
373
+ if (!(key in map))
374
+ return undefined;
375
+ const raw = map[key];
376
+ if (/\$\(/.test(raw))
377
+ return raw;
378
+ const n = parseInt(raw, 10);
379
+ if (!Number.isFinite(n) || n <= 0) {
380
+ errors.push(`\`~\` op in target '${targetName}': '${key}' must be a positive integer or a \`$(VAR)\` ref (got '${raw}')`);
381
+ return undefined;
382
+ }
383
+ return n;
384
+ }
385
+ const maxTokens = deferInt("maxTokens");
386
+ const timeoutSeconds = deferInt("timeoutSeconds");
387
+ const params = {
388
+ prompt: map["prompt"] ?? "",
389
+ };
390
+ if ("model" in map && map["model"] !== "")
391
+ params.model = map["model"];
392
+ if (maxTokens !== undefined)
393
+ params.maxTokens = maxTokens;
394
+ if (timeoutSeconds !== undefined)
395
+ params.timeoutSeconds = timeoutSeconds;
396
+ return { params, errors };
397
+ }
398
+ function popToDepth(stack, targetDepth) {
399
+ while (stack.length > 0 && stack[stack.length - 1].depth > targetDepth) {
400
+ stack.pop();
401
+ }
402
+ }
403
+ /**
404
+ * Parse a skill source string into an AST. Collects syntax errors in
405
+ * `parseErrors`; never throws on bad input.
406
+ */
407
+ export function parse(source) {
408
+ const lines = foldQuotedContinuations(source.split("\n"));
409
+ const result = {
410
+ name: null,
411
+ description: null,
412
+ type: "procedural",
413
+ status: null,
414
+ timeout: null,
415
+ vars: [],
416
+ requires: [],
417
+ requiredCapabilities: [],
418
+ useWhen: null,
419
+ targets: new Map(),
420
+ entryTarget: null,
421
+ onError: null,
422
+ triggers: [],
423
+ outputs: [],
424
+ parseErrors: [],
425
+ };
426
+ const tabLines = findTabIndentedLines(source);
427
+ if (tabLines.length > 0) {
428
+ const shown = tabLines.slice(0, 3).join(", ");
429
+ const more = tabLines.length > 3 ? ` (+${tabLines.length - 3} more)` : "";
430
+ result.parseErrors.push(`Tab characters in indentation at line ${shown}${more}. Skillscript requires spaces-only indentation — replace tabs with spaces (conventional indent is 4 spaces).`);
431
+ }
432
+ let currentTarget = null;
433
+ let scopeStack = [];
434
+ for (const rawLine of lines) {
435
+ const line = rawLine.replace(/\s+$/, "");
436
+ if (line === "") {
437
+ currentTarget = null;
438
+ scopeStack = [];
439
+ continue;
440
+ }
441
+ if (line.startsWith("#")) {
442
+ const stripped = line.replace(/^#\s*/, "");
443
+ const colonIdx = stripped.indexOf(":");
444
+ if (colonIdx === -1)
445
+ continue;
446
+ const key = stripped.slice(0, colonIdx).trim().toLowerCase();
447
+ const value = stripped.slice(colonIdx + 1).trim();
448
+ if (key === "skill") {
449
+ const diag = checkReserved(value, "a skill name", `${value}-task`);
450
+ if (diag !== null)
451
+ result.parseErrors.push(diag);
452
+ result.name = value;
453
+ }
454
+ else if (key === "description") {
455
+ result.description = value;
456
+ }
457
+ else if (key === "type") {
458
+ const norm = normalizeEnumValue(value, ["procedural", "data"]);
459
+ if (norm !== null) {
460
+ result.type = norm;
461
+ }
462
+ else {
463
+ result.parseErrors.push(`\`# Type:\` value must be 'procedural' or 'data' (got '${value}')`);
464
+ }
465
+ }
466
+ else if (key === "status") {
467
+ const norm = normalizeEnumValue(value, ["Draft", "Approved", "Disabled"]);
468
+ if (norm !== null) {
469
+ result.status = norm;
470
+ }
471
+ else {
472
+ result.parseErrors.push(`\`# Status:\` value must be 'Draft', 'Approved', or 'Disabled' (got '${value}')`);
473
+ }
474
+ }
475
+ else if (key === "timeout") {
476
+ // Per lesson ab6c19db: defer integer validation when value contains
477
+ // `$(VAR)` ref. Runtime resolves via resolveIntParam at op dispatch.
478
+ if (/\$\(/.test(value)) {
479
+ result.timeout = value;
480
+ }
481
+ else {
482
+ const n = parseInt(value, 10);
483
+ if (!Number.isFinite(n) || n <= 0) {
484
+ result.parseErrors.push(`\`# Timeout:\` must be a positive integer (seconds) or a \`$(VAR)\` ref (got '${value}').`);
485
+ }
486
+ else {
487
+ result.timeout = n;
488
+ }
489
+ }
490
+ }
491
+ else if (key === "vars") {
492
+ if (value.toLowerCase() === "(none)" || value === "") {
493
+ result.vars = [];
494
+ }
495
+ else {
496
+ result.vars = splitVarsLine(value).map((entry) => {
497
+ const trimmed = entry.trim();
498
+ const eq = trimmed.indexOf("=");
499
+ const varName = eq === -1 ? trimmed : trimmed.slice(0, eq).trim();
500
+ const diag = checkReserved(varName, "a variable name", `${varName}_value`);
501
+ if (diag !== null)
502
+ result.parseErrors.push(diag);
503
+ if (eq === -1) {
504
+ return { name: varName, required: true };
505
+ }
506
+ return {
507
+ name: varName,
508
+ default: trimmed.slice(eq + 1).trim(),
509
+ required: false,
510
+ };
511
+ });
512
+ }
513
+ }
514
+ else if (key === "use when") {
515
+ result.useWhen = value;
516
+ }
517
+ else if (key === "onerror") {
518
+ result.onError = value === "" ? null : value;
519
+ }
520
+ else if (key === "triggers") {
521
+ if (value.toLowerCase() === "(none)" || value === "")
522
+ continue;
523
+ for (const raw of splitTriggersLine(value)) {
524
+ const decl = raw.trim();
525
+ if (decl === "")
526
+ continue;
527
+ const colon = decl.indexOf(":");
528
+ if (colon === -1) {
529
+ result.parseErrors.push(`Malformed \`# Triggers:\` declaration '${decl}' — expected '<source>: <name>'`);
530
+ continue;
531
+ }
532
+ const rawSource = decl.slice(0, colon).trim();
533
+ const name = decl.slice(colon + 1).trim();
534
+ const allowed = ["session", "cron", "event", "agent-event", "file-watch", "sensor"];
535
+ const source = normalizeEnumValue(rawSource, allowed);
536
+ if (source === null) {
537
+ result.parseErrors.push(`Unsupported trigger source '${rawSource}' — allowed: ${allowed.join(", ")}`);
538
+ continue;
539
+ }
540
+ if (name === "") {
541
+ result.parseErrors.push(`\`# Triggers:\` declaration '${decl}' has empty name`);
542
+ continue;
543
+ }
544
+ result.triggers.push({ source, name });
545
+ }
546
+ }
547
+ else if (key === "output") {
548
+ if (value.toLowerCase() === "(none)" || value === "")
549
+ continue;
550
+ for (const raw of splitVarsLine(value)) {
551
+ const decl = raw.trim();
552
+ if (decl === "")
553
+ continue;
554
+ const allowedKinds = ["text", "slack", "prompt-context", "template", "file", "card", "none"];
555
+ const colon = decl.indexOf(":");
556
+ if (colon === -1) {
557
+ const bareKind = normalizeEnumValue(decl, allowedKinds);
558
+ if (bareKind === "text" || bareKind === "none") {
559
+ result.outputs.push({ kind: bareKind });
560
+ }
561
+ else {
562
+ result.parseErrors.push(`\`# Output:\` kind '${decl}' missing target — kinds 'slack', 'prompt-context', 'template', 'file', 'card' require '<kind>: <target>'. Only 'text' and 'none' are bare-only.`);
563
+ }
564
+ continue;
565
+ }
566
+ const rawKind = decl.slice(0, colon).trim();
567
+ const target = decl.slice(colon + 1).trim();
568
+ const kind = normalizeEnumValue(rawKind, allowedKinds);
569
+ if (kind === null) {
570
+ result.parseErrors.push(`Unsupported output kind '${rawKind}' — allowed: ${allowedKinds.join(", ")}`);
571
+ continue;
572
+ }
573
+ if (kind === "text" || kind === "none") {
574
+ result.parseErrors.push(`\`# Output:\` kind '${kind}' is bare-only — no target accepted (got '${target}'). Use '# Output: ${kind}' instead.`);
575
+ continue;
576
+ }
577
+ if (target === "") {
578
+ result.parseErrors.push(`\`# Output:\` kind '${kind}' requires a target after the colon`);
579
+ continue;
580
+ }
581
+ result.outputs.push({ kind, target });
582
+ }
583
+ }
584
+ else if (key === "requires") {
585
+ if (value.toLowerCase() === "(none)" || value === "")
586
+ continue;
587
+ const match = REQUIRES_LINE.exec(value);
588
+ if (match) {
589
+ const [, namespace, k, target, fallback] = match;
590
+ result.requires.push({
591
+ namespace: namespace,
592
+ key: k,
593
+ target: target,
594
+ fallback: fallback === undefined ? null : fallback,
595
+ raw: value,
596
+ });
597
+ }
598
+ else {
599
+ // Try capability form: space-separated `connector_type.feature_flag`
600
+ // tokens. Silently drop the line if it matches neither shape
601
+ // (existing parser convention for unknown # Requires: dialects).
602
+ const tokens = value.trim().split(/\s+/);
603
+ if (tokens.length > 0 && tokens.every((t) => CAPABILITY_TOKEN.test(t))) {
604
+ for (const t of tokens)
605
+ result.requiredCapabilities.push(t);
606
+ }
607
+ }
608
+ }
609
+ continue;
610
+ }
611
+ if (!/^\s/.test(line) && /^(if|elif)\s+/.test(line)) {
612
+ result.parseErrors.push("`if:` / `elif:` only valid inside a target body, not at top level");
613
+ continue;
614
+ }
615
+ if (!/^\s/.test(line) && /^else:\s*$/.test(line)) {
616
+ if (!currentTarget || scopeStack.length === 0) {
617
+ result.parseErrors.push("`else:` block has no preceding target body to attach to");
618
+ continue;
619
+ }
620
+ const top = scopeStack[scopeStack.length - 1];
621
+ if (top.kind === "target-else") {
622
+ result.parseErrors.push(`Nested or duplicate \`else:\` block in target '${currentTarget.name}'`);
623
+ continue;
624
+ }
625
+ scopeStack.pop();
626
+ currentTarget.elseBlock = [];
627
+ scopeStack.push({
628
+ kind: "target-else",
629
+ target: currentTarget,
630
+ opsBucket: currentTarget.elseBlock,
631
+ depth: INDENT_STEP,
632
+ });
633
+ continue;
634
+ }
635
+ if (!/^\s/.test(line)) {
636
+ const colonIdx = line.indexOf(":");
637
+ if (colonIdx === -1)
638
+ continue;
639
+ const name = line.slice(0, colonIdx).trim();
640
+ let depsStr = line.slice(colonIdx + 1).trim();
641
+ // Accept `target: needs: dep1 dep2` form per language reference §1
642
+ // overview ("declares targets and their dependencies (`needs:` keyword)").
643
+ // The keyword is optional — the canonical/terse form is just
644
+ // `target: dep1 dep2`. Both shapes parse to the same dep list.
645
+ if (/^needs\s*:\s*/.test(depsStr)) {
646
+ depsStr = depsStr.replace(/^needs\s*:\s*/, "");
647
+ }
648
+ // Separator: whitespace OR comma (or both). Cold-agent corpus
649
+ // surfaced `target: needs: a, b, c` as a natural form alongside
650
+ // `target: a b c`. Both shapes parse to the same dep list.
651
+ const deps = depsStr === "" ? [] : depsStr.split(/[\s,]+/).filter((s) => s !== "");
652
+ if (name === "default") {
653
+ result.entryTarget = deps[0] ?? null;
654
+ currentTarget = null;
655
+ scopeStack = [];
656
+ continue;
657
+ }
658
+ const targetReserved = checkReserved(name, "a target name", `${name}_target`);
659
+ if (targetReserved !== null)
660
+ result.parseErrors.push(targetReserved);
661
+ currentTarget = { name, deps, ops: [] };
662
+ scopeStack = [{
663
+ kind: "main",
664
+ target: currentTarget,
665
+ opsBucket: currentTarget.ops,
666
+ depth: INDENT_STEP,
667
+ }];
668
+ result.targets.set(name, currentTarget);
669
+ continue;
670
+ }
671
+ if (!currentTarget || scopeStack.length === 0)
672
+ continue;
673
+ const lineIndent = leadingSpaces(rawLine);
674
+ const stripped0 = line.replace(/^\s+/, "");
675
+ // Conditional chain continuation: `elif:` / `else:` re-enters the same
676
+ // if-frame depth. MUST run before popToDepth so the dedent doesn't fire
677
+ // first and pop the if-body frame we're trying to extend.
678
+ if ((stripped0.startsWith("elif ") || /^else:\s*$/.test(stripped0)) &&
679
+ (scopeStack[scopeStack.length - 1].kind === "if" || scopeStack[scopeStack.length - 1].kind === "elif") &&
680
+ scopeStack[scopeStack.length - 1].depth === lineIndent + INDENT_STEP) {
681
+ const preTop = scopeStack[scopeStack.length - 1];
682
+ const ifOp = preTop.ifOp;
683
+ const continuationDepth = preTop.depth;
684
+ scopeStack.pop();
685
+ if (stripped0.startsWith("elif ")) {
686
+ const elifMatch = ELIF_OP_REGEX.exec(stripped0);
687
+ if (!elifMatch) {
688
+ result.parseErrors.push(`Malformed \`elif\` op in target '${currentTarget.name}' — expected \`elif COND:\``);
689
+ continue;
690
+ }
691
+ const cond = elifMatch[1].trim();
692
+ const eqDiag = detectSingleEqualsInCondition(cond);
693
+ if (eqDiag !== null) {
694
+ result.parseErrors.push(`\`elif\` in target '${currentTarget.name}': ${eqDiag}`);
695
+ continue;
696
+ }
697
+ if (!validateCondition(cond)) {
698
+ result.parseErrors.push(`Unsupported condition in \`elif\` (target '${currentTarget.name}'): \`${cond}\` — v1 grammar is truthy / \`==\` / \`!=\` against quoted literals, or \`in\` / \`not in\` between two \`$(NAME)\` refs`);
699
+ continue;
700
+ }
701
+ const newBranch = { cond, body: [] };
702
+ ifOp.ifBranches.push(newBranch);
703
+ scopeStack.push({
704
+ kind: "elif",
705
+ target: currentTarget,
706
+ opsBucket: newBranch.body,
707
+ depth: continuationDepth,
708
+ ifOp,
709
+ });
710
+ }
711
+ else {
712
+ ifOp.ifElseBody = [];
713
+ scopeStack.push({
714
+ kind: "conditional-else",
715
+ target: currentTarget,
716
+ opsBucket: ifOp.ifElseBody,
717
+ depth: continuationDepth,
718
+ ifOp,
719
+ });
720
+ }
721
+ continue;
722
+ }
723
+ popToDepth(scopeStack, lineIndent);
724
+ if (scopeStack.length === 0)
725
+ continue;
726
+ const topFrame = scopeStack[scopeStack.length - 1];
727
+ if (topFrame.depth !== lineIndent) {
728
+ result.parseErrors.push(`Mid-block indent change in target '${currentTarget.name}': line indented to ${lineIndent} spaces but enclosing block expects ${topFrame.depth}. Use consistent indentation within a block.`);
729
+ continue;
730
+ }
731
+ const opBucket = topFrame.opsBucket;
732
+ // `needs: dep1 dep2` body-line form for declaring target deps. Only
733
+ // recognized at the main target-body scope (not inside foreach/if/else
734
+ // sub-blocks). Cold-agent corpus surfaced this as a natural authoring
735
+ // style alongside `target: dep1 dep2` and `target: needs: dep1`.
736
+ if (topFrame.kind === "main" && /^needs\s*:/.test(stripped0)) {
737
+ const depsTail = stripped0.replace(/^needs\s*:\s*/, "");
738
+ const newDeps = depsTail.split(/[\s,]+/).filter((s) => s !== "");
739
+ for (const d of newDeps)
740
+ currentTarget.deps.push(d);
741
+ continue;
742
+ }
743
+ if (stripped0.startsWith("elif ")) {
744
+ result.parseErrors.push(`\`elif\` without preceding \`if:\` in target '${currentTarget.name}'`);
745
+ continue;
746
+ }
747
+ if (stripped0.startsWith("if ")) {
748
+ const ifMatch = IF_OP_REGEX.exec(stripped0);
749
+ if (!ifMatch) {
750
+ result.parseErrors.push(`Malformed \`if\` op in target '${currentTarget.name}' — expected \`if COND:\``);
751
+ continue;
752
+ }
753
+ const cond = ifMatch[1].trim();
754
+ const eqDiag = detectSingleEqualsInCondition(cond);
755
+ if (eqDiag !== null) {
756
+ result.parseErrors.push(`\`if\` in target '${currentTarget.name}': ${eqDiag}`);
757
+ continue;
758
+ }
759
+ if (!validateCondition(cond)) {
760
+ result.parseErrors.push(`Unsupported condition in \`if\` (target '${currentTarget.name}'): \`${cond}\` — v1 grammar is truthy / \`==\` / \`!=\` against quoted literals, or \`in\` / \`not in\` between two \`$(NAME)\` refs`);
761
+ continue;
762
+ }
763
+ const firstBranch = { cond, body: [] };
764
+ const ifOp = {
765
+ kind: "if",
766
+ body: stripped0,
767
+ ifBranches: [firstBranch],
768
+ };
769
+ opBucket.push(ifOp);
770
+ scopeStack.push({
771
+ kind: "if",
772
+ target: currentTarget,
773
+ opsBucket: firstBranch.body,
774
+ depth: lineIndent + INDENT_STEP,
775
+ ifOp,
776
+ });
777
+ continue;
778
+ }
779
+ if (stripped0.startsWith("> ")) {
780
+ const match = RETRIEVAL_OP_REGEX.exec(stripped0);
781
+ if (!match) {
782
+ result.parseErrors.push(`Malformed \`>\` op in target '${currentTarget.name}' — expected \`> key=value ... -> VARNAME [(fallback: "value")]\``);
783
+ continue;
784
+ }
785
+ const [, argsStr, outputVar, fallback] = match;
786
+ const parsed = parseRetrievalArgs(argsStr, currentTarget.name);
787
+ if (parsed.errors.length > 0) {
788
+ for (const e of parsed.errors)
789
+ result.parseErrors.push(e);
790
+ continue;
791
+ }
792
+ if (fallback !== undefined)
793
+ parsed.params.fallback = processSetValue(fallback);
794
+ opBucket.push({
795
+ kind: ">",
796
+ body: stripped0,
797
+ outputVar: outputVar,
798
+ retrievalParams: parsed.params,
799
+ });
800
+ continue;
801
+ }
802
+ if (stripped0.startsWith("~ ")) {
803
+ const match = LOCAL_MODEL_OP_REGEX.exec(stripped0);
804
+ if (!match) {
805
+ result.parseErrors.push(`Malformed \`~\` op in target '${currentTarget.name}' — expected \`~ key=value ... -> VARNAME [(fallback: "value")]\``);
806
+ continue;
807
+ }
808
+ const [, argsStr, outputVar, fallback] = match;
809
+ const parsed = parseLocalModelArgs(argsStr, currentTarget.name);
810
+ if (parsed.errors.length > 0) {
811
+ for (const e of parsed.errors)
812
+ result.parseErrors.push(e);
813
+ continue;
814
+ }
815
+ if (fallback !== undefined)
816
+ parsed.params.fallback = processSetValue(fallback);
817
+ opBucket.push({
818
+ kind: "~",
819
+ body: stripped0,
820
+ outputVar: outputVar,
821
+ localModelParams: parsed.params,
822
+ });
823
+ continue;
824
+ }
825
+ if (stripped0.startsWith("& ")) {
826
+ const match = AMPERSAND_OP_REGEX.exec(stripped0);
827
+ if (!match) {
828
+ result.parseErrors.push(`Malformed \`&\` op in target '${currentTarget.name}' — expected \`& skill-name [key=value ...] [-> VARNAME]\``);
829
+ continue;
830
+ }
831
+ const [, skillName, argsStr, outputVar, ampFallback] = match;
832
+ const args = {};
833
+ const tokens = tokenizeKeywordArgs(argsStr ?? "");
834
+ let argError = false;
835
+ for (const tok of tokens) {
836
+ const eq = tok.indexOf("=");
837
+ if (eq === -1) {
838
+ result.parseErrors.push(`Malformed \`&\` arg '${tok}' in target '${currentTarget.name}' — expected key=value`);
839
+ argError = true;
840
+ continue;
841
+ }
842
+ args[tok.slice(0, eq).trim()] = processSetValue(tok.slice(eq + 1));
843
+ }
844
+ if (argError)
845
+ continue;
846
+ const ampOp = {
847
+ kind: "&",
848
+ body: stripped0,
849
+ ampParams: { skillName: skillName, args },
850
+ };
851
+ if (outputVar !== undefined)
852
+ ampOp.outputVar = outputVar;
853
+ if (ampFallback !== undefined)
854
+ ampOp.fallback = processSetValue(ampFallback);
855
+ opBucket.push(ampOp);
856
+ continue;
857
+ }
858
+ if (stripped0.startsWith("foreach ")) {
859
+ const fmatch = FOREACH_OP_REGEX.exec(stripped0);
860
+ if (!fmatch) {
861
+ result.parseErrors.push(`Malformed \`foreach\` op in target '${currentTarget.name}' — expected \`foreach IDENT in EXPR:\``);
862
+ continue;
863
+ }
864
+ const [, iter, listExpr] = fmatch;
865
+ const iterReserved = checkReserved(iter, "a foreach iterator", `${iter}_item`);
866
+ if (iterReserved !== null)
867
+ result.parseErrors.push(iterReserved);
868
+ const foreachOp = {
869
+ kind: "foreach",
870
+ body: stripped0,
871
+ foreachIter: iter,
872
+ foreachList: listExpr.trim(),
873
+ foreachBody: [],
874
+ };
875
+ opBucket.push(foreachOp);
876
+ scopeStack.push({
877
+ kind: "foreach",
878
+ target: currentTarget,
879
+ opsBucket: foreachOp.foreachBody,
880
+ depth: lineIndent + INDENT_STEP,
881
+ });
882
+ continue;
883
+ }
884
+ const stripped = line.replace(/^\s+/, "");
885
+ let kind = null;
886
+ let body = "";
887
+ let mcpConnectorForOp = undefined;
888
+ let atPolicy = undefined;
889
+ let atOutputVar = undefined;
890
+ let atFallback = undefined;
891
+ // Check `??` before `?`, `$set` before `$`.
892
+ if (stripped.startsWith("?? ") || stripped === "??") {
893
+ const tail = stripped.slice(3).trim();
894
+ const m = /^(.+?)\s+->\s+([A-Za-z_]\w*)\s*$/.exec(tail);
895
+ if (m !== null) {
896
+ opBucket.push({ kind: "??", body: m[1].trim(), outputVar: m[2] });
897
+ }
898
+ else {
899
+ opBucket.push({ kind: "??", body: tail });
900
+ }
901
+ continue;
902
+ }
903
+ else if (stripped.startsWith("$set ") || stripped === "$set") {
904
+ const match = SET_OP_REGEX.exec(stripped);
905
+ if (match) {
906
+ const [, setName, rawValue] = match;
907
+ opBucket.push({
908
+ kind: "$set",
909
+ body: stripped,
910
+ setName: setName,
911
+ setValue: processSetValue(rawValue),
912
+ });
913
+ }
914
+ continue;
915
+ }
916
+ else if (stripped.startsWith("$ ") || stripped === "$") {
917
+ const tail = stripped.slice(2).trim();
918
+ // `$ <tool> args -> VAR [(fallback: <value>)]` — fallback optional.
919
+ const dollarOutMatch = /^(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/.exec(tail);
920
+ if (dollarOutMatch !== null) {
921
+ const bodyPart = dollarOutMatch[1].trim();
922
+ const { connector, rest } = splitMcpConnectorPrefix(bodyPart);
923
+ const dollarFallback = dollarOutMatch[3];
924
+ opBucket.push({
925
+ kind: "$",
926
+ body: rest,
927
+ outputVar: dollarOutMatch[2],
928
+ ...(connector !== undefined ? { mcpConnector: connector } : {}),
929
+ ...(dollarFallback !== undefined ? { fallback: processSetValue(dollarFallback) } : {}),
930
+ });
931
+ continue;
932
+ }
933
+ const { connector, rest } = splitMcpConnectorPrefix(tail);
934
+ kind = "$";
935
+ body = rest;
936
+ mcpConnectorForOp = connector;
937
+ }
938
+ else if (stripped.startsWith("? ") || stripped === "?") {
939
+ kind = "?";
940
+ body = stripped.slice(2).trim();
941
+ }
942
+ else if (stripped.startsWith("@ ") || stripped === "@") {
943
+ kind = "@";
944
+ let tail = stripped.slice(2).trim();
945
+ // Optional output binding: `-> VAR [(fallback: "...")]` at end of line.
946
+ // v0.2.4 Bug F: the trailing `(fallback: ...)` clause is now supported
947
+ // for parity with $/~/> ops — cold authors reach for op-level fallback
948
+ // as a defensive-coding posture and previously hit silent
949
+ // outputVar-not-bound failures.
950
+ const outMatch = /^(.+?)\s+->\s+([A-Za-z_]\w*)(?:\s+\(fallback\s*:\s*(.+?)\))?\s*$/.exec(tail);
951
+ if (outMatch !== null) {
952
+ atOutputVar = outMatch[2];
953
+ if (outMatch[3] !== undefined)
954
+ atFallback = processSetValue(outMatch[3]);
955
+ tail = outMatch[1].trim();
956
+ }
957
+ // `@ unsafe <command>` — `unsafe` as literal first token signals
958
+ // opt-in full-shell exec (vs default structured-spawn sandbox).
959
+ const unsafeMatch = /^unsafe(?:\s+(.*))?$/.exec(tail);
960
+ if (unsafeMatch !== null) {
961
+ atPolicy = "unsafe";
962
+ body = (unsafeMatch[1] ?? "").trim();
963
+ }
964
+ else {
965
+ body = tail;
966
+ }
967
+ }
968
+ else if (stripped.startsWith("! ") || stripped === "!") {
969
+ kind = "!";
970
+ body = stripped.slice(2).trim();
971
+ }
972
+ if (kind !== null) {
973
+ opBucket.push({
974
+ kind,
975
+ body,
976
+ ...(mcpConnectorForOp !== undefined ? { mcpConnector: mcpConnectorForOp } : {}),
977
+ ...(atPolicy !== undefined ? { policy: atPolicy } : {}),
978
+ ...(atOutputVar !== undefined ? { outputVar: atOutputVar } : {}),
979
+ ...(atFallback !== undefined ? { fallback: atFallback } : {}),
980
+ });
981
+ }
982
+ }
983
+ if (result.entryTarget === null && result.targets.size > 0) {
984
+ const names = Array.from(result.targets.keys());
985
+ result.entryTarget = names[names.length - 1] ?? null;
986
+ }
987
+ return result;
988
+ }
989
+ // Toposort moved to compile.ts (semantic analysis). applyFilter moved to
990
+ // filters.ts (predictable filter-add location per ERD §2 modifiability).
991
+ //# sourceMappingURL=parser.js.map