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.
- package/ARCHITECTURE.md +70 -0
- package/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/audit.d.ts +33 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +76 -0
- package/dist/audit.js.map +1 -0
- package/dist/bootstrap.d.ts +69 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +117 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +805 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +88 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +544 -0
- package/dist/compile.js.map +1 -0
- package/dist/connectors/agent-noop.d.ts +23 -0
- package/dist/connectors/agent-noop.d.ts.map +1 -0
- package/dist/connectors/agent-noop.js +43 -0
- package/dist/connectors/agent-noop.js.map +1 -0
- package/dist/connectors/agent.d.ts +54 -0
- package/dist/connectors/agent.d.ts.map +1 -0
- package/dist/connectors/agent.js +21 -0
- package/dist/connectors/agent.js.map +1 -0
- package/dist/connectors/index.d.ts +13 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +17 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/local-model.d.ts +41 -0
- package/dist/connectors/local-model.d.ts.map +1 -0
- package/dist/connectors/local-model.js +106 -0
- package/dist/connectors/local-model.js.map +1 -0
- package/dist/connectors/mcp.d.ts +22 -0
- package/dist/connectors/mcp.d.ts.map +1 -0
- package/dist/connectors/mcp.js +31 -0
- package/dist/connectors/mcp.js.map +1 -0
- package/dist/connectors/memory-store.d.ts +53 -0
- package/dist/connectors/memory-store.d.ts.map +1 -0
- package/dist/connectors/memory-store.js +169 -0
- package/dist/connectors/memory-store.js.map +1 -0
- package/dist/connectors/registry.d.ts +74 -0
- package/dist/connectors/registry.d.ts.map +1 -0
- package/dist/connectors/registry.js +127 -0
- package/dist/connectors/registry.js.map +1 -0
- package/dist/connectors/skill-store.d.ts +38 -0
- package/dist/connectors/skill-store.d.ts.map +1 -0
- package/dist/connectors/skill-store.js +314 -0
- package/dist/connectors/skill-store.js.map +1 -0
- package/dist/connectors/types.d.ts +188 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +35 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/dashboard/server.d.ts +40 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +122 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/dashboard/spa/app.js +375 -0
- package/dist/dashboard/spa/index.html +26 -0
- package/dist/dashboard/spa/styles.css +99 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +187 -0
- package/dist/errors.js.map +1 -0
- package/dist/filters.d.ts +17 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +40 -0
- package/dist/filters.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/lint.d.ts +97 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +990 -0
- package/dist/lint.js.map +1 -0
- package/dist/mcp-server.d.ts +93 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +505 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/metrics.d.ts +51 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +107 -0
- package/dist/metrics.js.map +1 -0
- package/dist/parser.d.ts +160 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +991 -0
- package/dist/parser.js.map +1 -0
- package/dist/provenance.d.ts +43 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +58 -0
- package/dist/provenance.js.map +1 -0
- package/dist/runtime.d.ts +145 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1071 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scheduler.d.ts +121 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +271 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/skill-manager.d.ts +121 -0
- package/dist/skill-manager.d.ts.map +1 -0
- package/dist/skill-manager.js +251 -0
- package/dist/skill-manager.js.map +1 -0
- package/dist/testing/conformance.d.ts +57 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +365 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/trace.d.ts +141 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +226 -0
- package/dist/trace.js.map +1 -0
- package/examples/README.md +56 -0
- package/examples/classify-support-ticket.skill.md +30 -0
- package/examples/cut-release-tag.skill.md +40 -0
- package/examples/doc-qa-with-citations.skill.md +12 -0
- package/examples/feedback-sentiment-scan.skill.md +29 -0
- package/examples/hello.skill.md +9 -0
- package/examples/hello.skill.provenance.json +10 -0
- package/examples/morning-brief.skill.md +24 -0
- package/examples/programmatic-trace-demo.mjs +89 -0
- package/examples/service-health-watch.skill.md +18 -0
- package/package.json +100 -0
- package/scaffold/config.toml +35 -0
- package/scaffold/connectors.json +19 -0
- 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
|