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
@@ -0,0 +1,1071 @@
1
+ import { tokenizeKeywordArgs, processSetValue } from "./parser.js";
2
+ import { applyFilter } from "./filters.js";
3
+ import { spawn } from "node:child_process";
4
+ import { OpError, ConnectorNotFoundError, OpTimeoutError, InteractiveOpInAutonomousModeError, UnsafeShellDisabledError, UnresolvedVariableError, } from "./errors.js";
5
+ import { TraceBuilder, shouldTraceFire } from "./trace.js";
6
+ /**
7
+ * Execute a parsed skill against the live variable state. Walks targets in
8
+ * the provided order. Each target's ops run sequentially; on failure the
9
+ * chain falls back to `else:` → `# OnError:` → bubble.
10
+ */
11
+ export async function execute(parsed, initialVars, order, ctx) {
12
+ const vars = new Map();
13
+ // Tier-1 ambient refs per language reference §3. Runtime injects these
14
+ // by default; caller-provided initialVars override (e.g., scheduler's
15
+ // dispatchSkill pre-populates EVENT.* and TRIGGER_TYPE for cron/session
16
+ // fires; bare execute() callers still get clock-time defaults so
17
+ // `$(EVENT.fired_at_unix)` resolves uniformly across dispatch paths).
18
+ const nowMs = Date.now();
19
+ const nowSec = Math.floor(nowMs / 1000);
20
+ vars.set("NOW", nowMs);
21
+ vars.set("USER", ctx.agentId ?? "unknown");
22
+ vars.set("SESSION_CONTEXT", "");
23
+ vars.set("TRIGGER_TYPE", "manual");
24
+ vars.set("TRIGGER_PAYLOAD", "");
25
+ vars.set("EVENT.fired_at", nowMs);
26
+ vars.set("EVENT.fired_at_unix", nowSec);
27
+ vars.set("EVENT.fired_at_plus_1h_unix", nowSec + 3600);
28
+ vars.set("EVENT.fired_at_plus_1d_unix", nowSec + 86_400);
29
+ vars.set("EVENT.fired_at_plus_7d_unix", nowSec + 604_800);
30
+ for (const v of parsed.vars) {
31
+ if (v.default !== undefined)
32
+ vars.set(v.name, coerceLiteralValue(v.default));
33
+ }
34
+ for (const [k, val] of Object.entries(initialVars)) {
35
+ vars.set(k, typeof val === "string" ? coerceLiteralValue(val) : val);
36
+ }
37
+ const emissions = [];
38
+ const errors = [];
39
+ let lastBoundVar = null;
40
+ const absoluteTimeoutMs = ctx.absoluteTimeoutMs ?? DEFAULT_RUNTIME_ABSOLUTE_TIMEOUT_MS;
41
+ // Trace recording (per ERD §8). Build when shouldTraceFire returns true;
42
+ // skip entirely when off (the NFR-11 floor — errors still surface via
43
+ // `result.errors[]`).
44
+ const triggerCtx = ctx.triggerCtx ?? { source: "manual", name: "", fired_at_ms: nowMs };
45
+ const triggerId = triggerCtx.trigger_id ?? `${triggerCtx.source}:${triggerCtx.name}`;
46
+ const skillName = parsed.name ?? "(anonymous)";
47
+ const traceBuilder = shouldTraceFire(ctx.trace, triggerId, skillName)
48
+ ? new TraceBuilder(skillName, ctx.skillVersion ?? "unknown", triggerCtx, { agent_id: ctx.agentId })
49
+ : null;
50
+ for (const targetName of order) {
51
+ const target = parsed.targets.get(targetName);
52
+ if (!target)
53
+ continue;
54
+ let targetLastBound = null;
55
+ let targetLastValue = undefined;
56
+ try {
57
+ const r = await execOps(target.ops, vars, emissions, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder);
58
+ targetLastBound = r.lastBoundVar;
59
+ targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
60
+ }
61
+ catch (err) {
62
+ errors.push(buildExecutionError(err, targetName));
63
+ if (target.elseBlock !== undefined) {
64
+ try {
65
+ const r = await execOps(target.elseBlock, vars, emissions, ctx, targetName, parsed.timeout, absoluteTimeoutMs, traceBuilder);
66
+ targetLastBound = r.lastBoundVar;
67
+ targetLastValue = r.lastBoundVar !== null ? vars.get(r.lastBoundVar) : r.lastValue;
68
+ }
69
+ catch (innerErr) {
70
+ errors.push(buildExecutionError(innerErr, targetName, "else"));
71
+ }
72
+ }
73
+ else if (parsed.onError !== null && ctx.fallbackSkillExecutor) {
74
+ try {
75
+ const fbResult = await ctx.fallbackSkillExecutor(parsed.onError, Object.fromEntries(vars));
76
+ for (const em of fbResult.emissions)
77
+ emissions.push(em);
78
+ for (const fe of fbResult.errors)
79
+ errors.push(fe);
80
+ }
81
+ catch (fbErr) {
82
+ errors.push(buildExecutionError(fbErr, parsed.onError, "skill-fallback"));
83
+ }
84
+ break;
85
+ }
86
+ else {
87
+ break;
88
+ }
89
+ }
90
+ vars.set(`${targetName}.output`, targetLastValue);
91
+ if (targetLastBound !== null)
92
+ lastBoundVar = targetLastBound;
93
+ }
94
+ // Outputs map per `# Output:` declarations. Per-kind value semantics:
95
+ // - Human-readable surfaces (`prompt-context:`, `slack:`, `card:`):
96
+ // default to joined emissions. These deliver content for an agent
97
+ // or human to *read*; trailing `>`/`~` JSON values are the wrong shape.
98
+ // - Programmatic surfaces (`text`, `file:`): default to lastBoundVar
99
+ // (structured), fall back to emissions array. Callers consuming
100
+ // `outputs.text` typically want the structured return value.
101
+ // - `none`: no-op marker; value irrelevant.
102
+ // Output payload-shape coercion: when the output kind is text-shaped
103
+ // (joined emissions are the natural delivery payload) we publish the
104
+ // string in `outputs[key]`; otherwise we pass the last bound variable
105
+ // through structurally. Membership here is about payload shape, not
106
+ // semantic destination — `slack` and `card` are listed because their
107
+ // delivery payloads are text, NOT because the runtime knows anything
108
+ // about Slack or card UIs. (v1.x: move this to connector-registered
109
+ // metadata via the EmissionConnector design so adopters can register
110
+ // new text-shaped destinations without a runtime code change.)
111
+ const TEXT_COERCED_OUTPUT_KINDS = new Set(["prompt-context", "template", "slack", "card"]);
112
+ // Agent-bound dispatch uses literal kind checks below so TS can narrow
113
+ // `decl.kind` to the discriminated `DeliveryPayload.kind` automatically;
114
+ // a runtime Set forces a type predicate. Keep the literals colocated
115
+ // with the dispatch loop so the agent-bound semantic set is one-line
116
+ // grep-able.
117
+ const outputDecls = parsed.outputs.length > 0
118
+ ? parsed.outputs
119
+ : [{ kind: "text" }];
120
+ const outputs = {};
121
+ for (const decl of outputDecls) {
122
+ const key = decl.target !== undefined ? `${decl.kind}:${decl.target}` : decl.kind;
123
+ if (TEXT_COERCED_OUTPUT_KINDS.has(decl.kind)) {
124
+ outputs[key] = emissions.join("\n");
125
+ }
126
+ else if (lastBoundVar !== null && vars.has(lastBoundVar)) {
127
+ outputs[key] = vars.get(lastBoundVar);
128
+ }
129
+ else {
130
+ outputs[key] = emissions.slice();
131
+ }
132
+ }
133
+ // Dispatch agent-targeted output decls through AgentConnector.deliver
134
+ // (T7.1). `prompt-context: <agent>` routes as `kind: "augment"`,
135
+ // `template: <agent>` as `kind: "template"`. Skipped in mechanical mode
136
+ // so previews don't deliver placeholder content to real substrates.
137
+ // Connector fallback: Registry.getAgentConnector() returns a transparent
138
+ // NoOpAgentConnector when no adapter is wired, so the dispatch loop
139
+ // never throws on missing-substrate; the no-op logs to stderr.
140
+ const agentDeliveryReceipts = [];
141
+ if (ctx.mechanical !== true) {
142
+ for (const decl of outputDecls) {
143
+ if (decl.target === undefined)
144
+ continue;
145
+ // Agent-bound output kinds: literal `===` so TS narrows decl.kind
146
+ // for the deliver() payload discriminator below.
147
+ if (decl.kind !== "prompt-context" && decl.kind !== "template")
148
+ continue;
149
+ const key = `${decl.kind}:${decl.target}`;
150
+ const body = String(outputs[key] ?? emissions.join("\n"));
151
+ const agent = ctx.registry.getAgentConnector();
152
+ try {
153
+ const receipt = decl.kind === "prompt-context"
154
+ ? await agent.deliver(decl.target, { kind: "augment", content: body })
155
+ : await agent.deliver(decl.target, {
156
+ kind: "template",
157
+ prompt: body,
158
+ ...(parsed.name !== null ? { source_skill: parsed.name } : {}),
159
+ });
160
+ agentDeliveryReceipts.push({ agent_id: decl.target, output_kind: decl.kind, receipt });
161
+ }
162
+ catch (err) {
163
+ // Delivery failure is non-fatal — record alongside other errors so
164
+ // the dashboard surfaces it, but don't propagate. Skill execution
165
+ // already succeeded by this point.
166
+ process.stderr.write(`[agent-deliver] ${decl.kind}:${decl.target} failed: ${err.message}\n`);
167
+ }
168
+ }
169
+ }
170
+ // Persist trace record if recording was active. Write is non-blocking —
171
+ // a failed write logs to stderr but doesn't change the execute() result
172
+ // (per ERD §8 NFR-11 floor: errors in trace persistence shouldn't bubble
173
+ // up as op errors; the trace store is an observability surface, not a
174
+ // dispatch dependency).
175
+ if (traceBuilder !== null && ctx.traceStore !== undefined) {
176
+ const record = traceBuilder.finalize(emissions, outputs, errors);
177
+ try {
178
+ await ctx.traceStore.write(record);
179
+ }
180
+ catch (err) {
181
+ process.stderr.write(`[trace] failed to write record ${record.trace_id}: ${err.message}\n`);
182
+ }
183
+ }
184
+ return {
185
+ finalVars: Object.fromEntries(vars),
186
+ emissions,
187
+ outputs,
188
+ errors,
189
+ targetOrder: order,
190
+ agentDeliveryReceipts,
191
+ };
192
+ }
193
+ async function execOps(ops, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder) {
194
+ let lastBoundVar = null;
195
+ let lastValue = undefined;
196
+ for (const op of ops) {
197
+ const r = await execOp(op, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder);
198
+ if (r.lastBoundVar !== null) {
199
+ lastBoundVar = r.lastBoundVar;
200
+ lastValue = r.lastValue;
201
+ }
202
+ else if (r.lastValue !== undefined) {
203
+ lastValue = r.lastValue;
204
+ }
205
+ }
206
+ return { lastBoundVar, lastValue };
207
+ }
208
+ async function execOp(op, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder) {
209
+ const startMs = traceBuilder !== null ? Date.now() : 0;
210
+ let errored = false;
211
+ try {
212
+ return await execOpInner(op, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder);
213
+ }
214
+ catch (err) {
215
+ errored = true;
216
+ // Default-tag any escaping error with `op.kind`. Explicit makeOpError()
217
+ // tags take precedence. Fixes the case where `~` failures classified as `?`.
218
+ const e = err;
219
+ if (e.opKind === undefined)
220
+ e.opKind = op.kind;
221
+ throw e;
222
+ }
223
+ finally {
224
+ if (traceBuilder !== null) {
225
+ const connector = extractOpConnector(op);
226
+ traceBuilder.recordOp({
227
+ op_kind: op.kind,
228
+ target: targetName,
229
+ body: op.body,
230
+ started_at_ms: startMs,
231
+ duration_ms: Date.now() - startMs,
232
+ errored,
233
+ ...(connector !== undefined ? { connector } : {}),
234
+ });
235
+ }
236
+ }
237
+ }
238
+ /** Extract the connector instance name for $/~/> ops; undefined for others. */
239
+ function extractOpConnector(op) {
240
+ switch (op.kind) {
241
+ case "$": return op.mcpConnector ?? "primary";
242
+ case "~": return op.localModelParams?.model ?? "default";
243
+ case ">": return op.retrievalParams?.connector ?? "primary";
244
+ default: return undefined;
245
+ }
246
+ }
247
+ async function execOpInner(op, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder) {
248
+ switch (op.kind) {
249
+ case "$set": {
250
+ const coerced = coerceLiteralValue(op.setValue);
251
+ vars.set(op.setName, coerced);
252
+ return { lastBoundVar: op.setName, lastValue: coerced };
253
+ }
254
+ case "?": {
255
+ const body = substituteRuntime(op.body, vars);
256
+ emissions.push(`Reason: ${body}`);
257
+ return { lastBoundVar: null, lastValue: undefined };
258
+ }
259
+ case "!": {
260
+ const body = substituteRuntime(op.body, vars);
261
+ emissions.push(body);
262
+ return { lastBoundVar: null, lastValue: undefined };
263
+ }
264
+ case "@": {
265
+ const body = op.policy === "unsafe"
266
+ ? substituteRuntimeUnsafe(op.body, vars)
267
+ : substituteRuntime(op.body, vars);
268
+ const shellTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
269
+ if (ctx.mechanical === true) {
270
+ const label = op.policy === "unsafe" ? "Would run unsafe shell" : "Would run shell";
271
+ emissions.push(`${label}: ${body} (mechanical: true preview).`);
272
+ // Bind a placeholder so downstream `$(VAR)` substitutions resolve.
273
+ // Matches the convention used by `$`/`~`/`>` mechanical-mode binding.
274
+ const flatKey = `${targetName}.output`;
275
+ const placeholder = `[mechanical: would run ${body.slice(0, 40)}${body.length > 40 ? "..." : ""}]`;
276
+ vars.set(flatKey, placeholder);
277
+ if (op.outputVar !== undefined)
278
+ vars.set(op.outputVar, placeholder);
279
+ return {
280
+ lastBoundVar: op.outputVar ?? flatKey,
281
+ lastValue: placeholder,
282
+ };
283
+ }
284
+ let stdout;
285
+ if (op.policy === "unsafe") {
286
+ if (ctx.enableUnsafeShell !== true) {
287
+ throw new UnsafeShellDisabledError(body, targetName);
288
+ }
289
+ stdout = await execShellCommand("bash", ["-c", body], shellTimeoutMs);
290
+ }
291
+ else {
292
+ const tokens = tokenizeShellArgs(body);
293
+ if (tokens.length === 0) {
294
+ throw makeOpError("@", `Empty \`@\` op body in target '${targetName}'.`);
295
+ }
296
+ const [bin, ...args] = tokens;
297
+ stdout = await execShellCommand(bin, args, shellTimeoutMs);
298
+ }
299
+ const flatKey = `${targetName}.output`;
300
+ vars.set(flatKey, stdout);
301
+ if (op.outputVar !== undefined)
302
+ vars.set(op.outputVar, stdout);
303
+ return {
304
+ lastBoundVar: op.outputVar ?? flatKey,
305
+ lastValue: stdout,
306
+ };
307
+ }
308
+ case "??": {
309
+ const promptStr = substituteRuntime(op.body, vars);
310
+ if (ctx.askUser === undefined) {
311
+ // Autonomous mode — no interactive surface wired. Per decision 6 +
312
+ // §6 dispatcher routing, `??` fails fast so dependent targets don't
313
+ // silently fall through.
314
+ throw new InteractiveOpInAutonomousModeError(promptStr, targetName);
315
+ }
316
+ const response = await ctx.askUser(promptStr);
317
+ const outName = op.outputVar;
318
+ if (outName !== undefined)
319
+ vars.set(outName, response);
320
+ // Decline semantics (per Section 2 Ops + §13 Open Q #2 resolution):
321
+ // bind the response AND short-circuit downstream via soft op-error
322
+ // routed through else: / # OnError:. Closes the silent-fall-through
323
+ // security bug pattern (subsequent `apply:` running on a "no").
324
+ if (isDeclineResponse(response)) {
325
+ throw makeOpError("??", `User declined at \`??\` prompt: '${promptStr}' (response: '${response}'). Dependent targets short-circuited.`);
326
+ }
327
+ return {
328
+ lastBoundVar: outName ?? null,
329
+ lastValue: response,
330
+ };
331
+ }
332
+ case "&": {
333
+ // `&` ops are resolved at compile time — data-skill content is
334
+ // inlined; procedural-skill refs compile to a runtime invocation
335
+ // shape (not this op). If we hit `&` at runtime, the executor was
336
+ // handed a raw AST that bypassed compile().
337
+ const skillName = op.ampParams?.skillName ?? "(unknown)";
338
+ throw makeOpError("&", `\`& ${skillName}\` reached the runtime unresolved. The compile() ` +
339
+ `step inlines data-skills and lowers procedural refs to invocation ` +
340
+ `ops; running raw parsed skills bypasses that. Call compile() ` +
341
+ `before execute().`);
342
+ }
343
+ case "$": {
344
+ const body = substituteRuntime(op.body, vars);
345
+ const m = /^([A-Za-z_][\w:-]*)\s*([\s\S]*)$/.exec(body);
346
+ if (m === null) {
347
+ throw makeOpError("$", `Malformed \`$\` op body: '${body}' — expected 'TOOL_NAME key=value ...'`);
348
+ }
349
+ const toolName = m[1];
350
+ const argsStr = m[2] ?? "";
351
+ const args = parseToolArgs(argsStr);
352
+ const connectorLabel = op.mcpConnector !== undefined ? `${op.mcpConnector}.` : "";
353
+ const flatKey = `${targetName}.output`;
354
+ // Mechanical preview, registry-routed, test escape hatch, no-dispatcher.
355
+ if (ctx.mechanical === true) {
356
+ emissions.push(`Would call tool ${connectorLabel}${toolName} with ${JSON.stringify(args)} (mechanical: true preview).`);
357
+ // Bind a placeholder that responds to dotted access (`$(X.field)`)
358
+ // so cold-agent skills using `$ tool -> X` then `$(X.title)` etc.
359
+ // can execute end-to-end without real dispatch.
360
+ const placeholder = makeMechanicalPlaceholder(op.outputVar ?? flatKey);
361
+ vars.set(flatKey, placeholder);
362
+ if (op.outputVar !== undefined)
363
+ vars.set(op.outputVar, placeholder);
364
+ return {
365
+ lastBoundVar: op.outputVar ?? flatKey,
366
+ lastValue: placeholder,
367
+ };
368
+ }
369
+ const connectorName = op.mcpConnector ?? "primary";
370
+ let rawResult;
371
+ let dispatched = false;
372
+ const timeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
373
+ // Op-level fallback (per language reference §9, extended to `$` for
374
+ // cold-agent corpus consistency). On dispatch throw, bind the
375
+ // fallback value to the output var; on missing connector with
376
+ // fallback present, ditto.
377
+ const dollarFallback = op.fallback !== undefined ? coerceLiteralValue(op.fallback) : undefined;
378
+ try {
379
+ if (ctx.registry.hasMcpConnector(connectorName)) {
380
+ const connector = ctx.registry.getMcpConnector(connectorName);
381
+ rawResult = await dispatchWithTimeout(() => connector.call(toolName, args, ctx.agentId !== undefined ? { agentId: ctx.agentId } : undefined), timeoutMs, "$");
382
+ dispatched = true;
383
+ }
384
+ else if (op.mcpConnector === undefined && ctx.toolDispatch) {
385
+ rawResult = await dispatchWithTimeout(() => ctx.toolDispatch(toolName, args), timeoutMs, "$");
386
+ dispatched = true;
387
+ }
388
+ else if (op.mcpConnector !== undefined) {
389
+ throw new ConnectorNotFoundError(connectorName, "mcp_connector", "$", targetName);
390
+ }
391
+ }
392
+ catch (err) {
393
+ if (dollarFallback !== undefined) {
394
+ vars.set(flatKey, dollarFallback);
395
+ if (op.outputVar !== undefined)
396
+ vars.set(op.outputVar, dollarFallback);
397
+ return { lastBoundVar: op.outputVar ?? flatKey, lastValue: dollarFallback };
398
+ }
399
+ throw err;
400
+ }
401
+ if (!dispatched) {
402
+ emissions.push(`Would call tool ${connectorLabel}${toolName} with ${JSON.stringify(args)} (no dispatcher wired).`);
403
+ vars.set(flatKey, null);
404
+ if (op.outputVar !== undefined)
405
+ vars.set(op.outputVar, null);
406
+ return {
407
+ lastBoundVar: op.outputVar ?? flatKey,
408
+ lastValue: null,
409
+ };
410
+ }
411
+ // c580de5: surface inner-tool `isError: true` as an op error. Otherwise
412
+ // the error text gets bound silently to the output var and the skill
413
+ // continues. Throw so the outer execOps catch records this in
414
+ // `result.errors[]` and the else/OnError fallback machinery can fire.
415
+ if (rawResult !== null &&
416
+ typeof rawResult === "object" &&
417
+ rawResult.isError === true) {
418
+ const innerText = extractToolErrorText(rawResult);
419
+ throw makeOpError("$", `tool ${connectorLabel}${toolName} returned isError: ${innerText}`);
420
+ }
421
+ const bindValue = unwrapToolResult(rawResult);
422
+ vars.set(flatKey, bindValue);
423
+ if (op.outputVar !== undefined)
424
+ vars.set(op.outputVar, bindValue);
425
+ return {
426
+ lastBoundVar: op.outputVar ?? flatKey,
427
+ lastValue: bindValue,
428
+ };
429
+ }
430
+ case ">": {
431
+ const p = op.retrievalParams;
432
+ const querySub = substituteRuntime(p.query, vars);
433
+ const extraSub = {};
434
+ for (const [k, v] of Object.entries(p.extra)) {
435
+ extraSub[k] = substituteRuntime(v, vars);
436
+ }
437
+ if (ctx.mechanical === true) {
438
+ // Bind a 1-element array of placeholders so foreach M in $(RESULTS)
439
+ // iterates once with a dotted-accessible M (matches common author
440
+ // patterns like `$(M.id)`, `$(M.summary)`). Authors expecting empty
441
+ // result sets test the empty case in unit tests, not mechanical mode.
442
+ const mechanicalValue = p.fallback !== undefined
443
+ ? p.fallback
444
+ : [makeMechanicalPlaceholder(`${op.outputVar}[0]`)];
445
+ emissions.push(`Would query MemoryStore \`${p.connector}\` with mode=${p.mode}, ` +
446
+ `query="${querySub}", limit=${p.limit} (mechanical: true preview). ` +
447
+ `Binding $(${op.outputVar}) = placeholder result set.`);
448
+ vars.set(op.outputVar, mechanicalValue);
449
+ return { lastBoundVar: op.outputVar, lastValue: mechanicalValue };
450
+ }
451
+ const store = ctx.registry.getMemoryStore(p.connector);
452
+ const limitResolved = resolveIntParam(p.limit, vars, "limit");
453
+ const filters = {
454
+ query: querySub,
455
+ mode: p.mode,
456
+ limit: limitResolved,
457
+ ...extraSub,
458
+ };
459
+ const retrievalTimeoutMs = resolveOpTimeoutMs(undefined, skillTimeoutSec, absoluteTimeoutMs, vars);
460
+ // Op-level fallback (per language reference §9): on throw OR empty
461
+ // result, bind the fallback value and continue. Without a fallback,
462
+ // throws propagate to `else:` / `# OnError:` / target error.
463
+ // coerceLiteralValue parses array-shaped literals (`[]`, `[a, b]`)
464
+ // into actual arrays so downstream `foreach M in $(VAR)` iterates
465
+ // correctly on the empty/sentinel case.
466
+ const coercedFallback = p.fallback !== undefined ? coerceLiteralValue(p.fallback) : undefined;
467
+ let results;
468
+ try {
469
+ results = await dispatchWithTimeout(() => store.query(filters), retrievalTimeoutMs, ">");
470
+ if (coercedFallback !== undefined && Array.isArray(results) && results.length === 0) {
471
+ results = coercedFallback;
472
+ }
473
+ }
474
+ catch (err) {
475
+ if (coercedFallback !== undefined) {
476
+ results = coercedFallback;
477
+ }
478
+ else {
479
+ throw err;
480
+ }
481
+ }
482
+ vars.set(op.outputVar, results);
483
+ return { lastBoundVar: op.outputVar, lastValue: results };
484
+ }
485
+ case "~": {
486
+ const p = op.localModelParams;
487
+ const promptSub = substituteRuntime(p.prompt, vars);
488
+ if (ctx.mechanical === true) {
489
+ const modelName = p.model ?? "default";
490
+ const placeholder = `[mechanical: would call LocalModel ${modelName} with prompt='${promptSub}']`;
491
+ emissions.push(`Would invoke LocalModel \`${modelName}\` (mechanical: true preview). Binding $(${op.outputVar}) = ${placeholder}`);
492
+ vars.set(op.outputVar, placeholder);
493
+ return { lastBoundVar: op.outputVar, lastValue: placeholder };
494
+ }
495
+ let model;
496
+ try {
497
+ model = ctx.registry.getLocalModel(p.model);
498
+ }
499
+ catch (err) {
500
+ if (p.fallback !== undefined) {
501
+ vars.set(op.outputVar, p.fallback);
502
+ return { lastBoundVar: op.outputVar, lastValue: p.fallback };
503
+ }
504
+ throw err;
505
+ }
506
+ const runOpts = {};
507
+ if (p.maxTokens !== undefined) {
508
+ runOpts.maxTokens = resolveIntParam(p.maxTokens, vars, "maxTokens");
509
+ }
510
+ const tildeTimeoutMs = resolveOpTimeoutMs(p.timeoutSeconds, skillTimeoutSec, absoluteTimeoutMs, vars);
511
+ // Op-level fallback (per language reference §9): on throw OR empty
512
+ // (trimmed) response, bind the fallback value.
513
+ let response;
514
+ try {
515
+ response = await dispatchWithTimeout(() => model.run(promptSub, runOpts), tildeTimeoutMs, "~");
516
+ if (p.fallback !== undefined && response.trim() === "") {
517
+ response = p.fallback;
518
+ }
519
+ }
520
+ catch (err) {
521
+ if (p.fallback !== undefined) {
522
+ response = p.fallback;
523
+ }
524
+ else {
525
+ throw err;
526
+ }
527
+ }
528
+ vars.set(op.outputVar, response);
529
+ return { lastBoundVar: op.outputVar, lastValue: response };
530
+ }
531
+ case "foreach": {
532
+ const listVal = resolveListExpr(op.foreachList, vars);
533
+ const iterName = op.foreachIter;
534
+ const before = new Set(vars.keys());
535
+ let last = { lastBoundVar: null, lastValue: undefined };
536
+ for (const item of listVal) {
537
+ vars.set(iterName, item);
538
+ last = await execOps(op.foreachBody, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder);
539
+ }
540
+ for (const k of Array.from(vars.keys())) {
541
+ if (!before.has(k))
542
+ vars.delete(k);
543
+ }
544
+ return last;
545
+ }
546
+ case "if": {
547
+ for (const branch of op.ifBranches) {
548
+ if (evalCondition(branch.cond, vars)) {
549
+ return execOps(branch.body, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder);
550
+ }
551
+ }
552
+ if (op.ifElseBody !== undefined) {
553
+ return execOps(op.ifElseBody, vars, emissions, ctx, targetName, skillTimeoutSec, absoluteTimeoutMs, traceBuilder);
554
+ }
555
+ return { lastBoundVar: null, lastValue: undefined };
556
+ }
557
+ }
558
+ return { lastBoundVar: null, lastValue: undefined };
559
+ }
560
+ function makeOpError(opKind, message) {
561
+ const err = new Error(message);
562
+ err.opKind = opKind;
563
+ return err;
564
+ }
565
+ /**
566
+ * Build a structured ExecutionError from a thrown value. Recognizes OpError
567
+ * subclasses (preserves class name + canned remediation); falls back to
568
+ * generic Error inspection (message + opKind tag) per existing convention.
569
+ */
570
+ function buildExecutionError(err, target, opKindOverride) {
571
+ if (err instanceof OpError) {
572
+ const entry = {
573
+ target: err.target ?? target,
574
+ opKind: opKindOverride ?? err.opKind,
575
+ message: err.message,
576
+ class: err.name,
577
+ remediation: err.remediation,
578
+ };
579
+ if (err.innerCause !== undefined)
580
+ entry.innerCause = err.innerCause;
581
+ return entry;
582
+ }
583
+ const e = err;
584
+ return {
585
+ target,
586
+ opKind: opKindOverride ?? e.opKind ?? "?",
587
+ message: e.message,
588
+ class: e.name ?? "Error",
589
+ };
590
+ }
591
+ const DEFAULT_RUNTIME_ABSOLUTE_TIMEOUT_MS = 300_000;
592
+ /**
593
+ * Per-op timeout resolution chain (ERD §6 decision 7) — top wins:
594
+ * 1. Per-op override (`~ ... timeoutSeconds=30 ...`)
595
+ * 2. Skill-level `# Timeout: N` header
596
+ * 3. Connector instance default (v1: not yet declared by impls — collapses
597
+ * to built-in fallback when no per-op or skill-level value is present)
598
+ * 4. Built-in language fallback (`absoluteTimeoutMs`, default 300000ms)
599
+ *
600
+ * Both per-op and skill-level values are in seconds (per author convention)
601
+ * and converted to milliseconds here.
602
+ */
603
+ function resolveOpTimeoutMs(perOpTimeoutSec, skillTimeoutSec, absoluteTimeoutMs, vars) {
604
+ if (perOpTimeoutSec !== undefined) {
605
+ return resolveIntParam(perOpTimeoutSec, vars, "timeoutSeconds") * 1000;
606
+ }
607
+ if (skillTimeoutSec !== null) {
608
+ return resolveIntParam(skillTimeoutSec, vars, "# Timeout:") * 1000;
609
+ }
610
+ return absoluteTimeoutMs;
611
+ }
612
+ /**
613
+ * Race the op against a timer. On timeout, throws `OpTimeoutError`-shaped
614
+ * op-error so the existing else: / # OnError: machinery catches it.
615
+ *
616
+ * v1 caveat: timeout returns control to the executor promptly, but the
617
+ * underlying request may still complete in the background — its result is
618
+ * discarded. v2 should thread AbortSignal through connector contracts so
619
+ * implementations can cancel cleanly.
620
+ */
621
+ async function dispatchWithTimeout(fn, timeoutMs, opKind) {
622
+ let timer;
623
+ const timeoutPromise = new Promise((_, reject) => {
624
+ timer = setTimeout(() => {
625
+ reject(new OpTimeoutError(timeoutMs, opKind));
626
+ }, timeoutMs);
627
+ });
628
+ try {
629
+ return await Promise.race([fn(), timeoutPromise]);
630
+ }
631
+ finally {
632
+ if (timer !== undefined)
633
+ clearTimeout(timer);
634
+ }
635
+ }
636
+ /**
637
+ * Decline detection for `??` interactive responses. A response is declining
638
+ * when trimmed-lowercase matches `no`/`n`/`false`/`0` or is empty. Anything
639
+ * else (including "yes", "y", or any non-empty positive content) is treated
640
+ * as approval.
641
+ */
642
+ /**
643
+ * Tokenize a shell-style command body into binary + args. Respects matching
644
+ * single/double quotes; strips outer quotes. No metachar interpretation —
645
+ * the structural-spawn sandbox forbids shell processing per decision 2.
646
+ */
647
+ function tokenizeShellArgs(body) {
648
+ const tokens = [];
649
+ let current = "";
650
+ let inQuote = null;
651
+ for (let i = 0; i < body.length; i++) {
652
+ const ch = body[i];
653
+ if (inQuote) {
654
+ if (ch === inQuote) {
655
+ inQuote = null;
656
+ }
657
+ else {
658
+ current += ch;
659
+ }
660
+ continue;
661
+ }
662
+ if (ch === '"' || ch === "'") {
663
+ inQuote = ch;
664
+ continue;
665
+ }
666
+ if (/\s/.test(ch)) {
667
+ if (current !== "") {
668
+ tokens.push(current);
669
+ current = "";
670
+ }
671
+ continue;
672
+ }
673
+ current += ch;
674
+ }
675
+ if (current !== "")
676
+ tokens.push(current);
677
+ return tokens;
678
+ }
679
+ /**
680
+ * Spawn a child process and capture stdout. SIGKILL on timeout via the
681
+ * process group (kills child + descendants). Non-zero exit → op-error with
682
+ * stderr preserved per ERD §6 dispatcher routing.
683
+ */
684
+ async function execShellCommand(bin, args, timeoutMs) {
685
+ return new Promise((resolve, reject) => {
686
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
687
+ let stdout = "";
688
+ let stderr = "";
689
+ let killed = false;
690
+ const timer = setTimeout(() => {
691
+ killed = true;
692
+ // Send SIGKILL to the process group on POSIX. Windows lacks process
693
+ // groups; fall back to direct child kill (descendants leak — out of
694
+ // v1 scope to fix).
695
+ if (process.platform !== "win32" && child.pid !== undefined) {
696
+ try {
697
+ process.kill(-child.pid, "SIGKILL");
698
+ }
699
+ catch {
700
+ child.kill("SIGKILL");
701
+ }
702
+ }
703
+ else {
704
+ child.kill("SIGKILL");
705
+ }
706
+ }, timeoutMs);
707
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
708
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
709
+ child.on("error", (err) => {
710
+ clearTimeout(timer);
711
+ reject(makeOpError("@", `Failed to spawn '${bin}': ${err.message}`));
712
+ });
713
+ child.on("close", (code) => {
714
+ clearTimeout(timer);
715
+ if (killed) {
716
+ reject(new OpTimeoutError(timeoutMs, "@"));
717
+ return;
718
+ }
719
+ if (code !== 0) {
720
+ const trimmed = stderr.trim();
721
+ reject(makeOpError("@", `Shell command '${bin}' exited with code ${code}${trimmed ? `: ${trimmed.slice(0, 200)}` : ""}.`));
722
+ return;
723
+ }
724
+ // Strip trailing newline — convention for shell command output.
725
+ resolve(stdout.replace(/\n$/, ""));
726
+ });
727
+ });
728
+ }
729
+ function isDeclineResponse(raw) {
730
+ const t = raw.trim().toLowerCase();
731
+ return t === "" || t === "no" || t === "n" || t === "false" || t === "0";
732
+ }
733
+ /**
734
+ * Resolve an integer parameter that may be a literal number or a string
735
+ * containing a `$(VAR)` ref. Substitutes any refs then parseInts. Throws
736
+ * a clear runtime error if the resolved value isn't a positive integer —
737
+ * the parser deferred validation to here because at parse time the ref
738
+ * couldn't be resolved.
739
+ */
740
+ function resolveIntParam(raw, vars, paramName) {
741
+ if (typeof raw === "number")
742
+ return raw;
743
+ const substituted = substituteRuntime(raw, vars);
744
+ const n = parseInt(substituted, 10);
745
+ if (!Number.isFinite(n) || n <= 0) {
746
+ throw new Error(`'${paramName}' resolved to '${substituted}', which isn't a positive integer.`);
747
+ }
748
+ return n;
749
+ }
750
+ function extractToolErrorText(rawResult) {
751
+ if (rawResult === null || typeof rawResult !== "object")
752
+ return String(rawResult);
753
+ const obj = rawResult;
754
+ if (Array.isArray(obj.content) && obj.content.length > 0) {
755
+ const first = obj.content[0];
756
+ if (first && first.type === "text" && typeof first.text === "string") {
757
+ return first.text;
758
+ }
759
+ }
760
+ try {
761
+ return JSON.stringify(rawResult);
762
+ }
763
+ catch {
764
+ return "(unparseable error envelope)";
765
+ }
766
+ }
767
+ function parseToolArgs(argsStr) {
768
+ const tokens = tokenizeKeywordArgs(argsStr);
769
+ const args = {};
770
+ for (const tok of tokens) {
771
+ const eq = tok.indexOf("=");
772
+ if (eq === -1)
773
+ continue;
774
+ const key = tok.slice(0, eq).trim();
775
+ const rawValue = tok.slice(eq + 1);
776
+ args[key] = processSetValue(rawValue);
777
+ }
778
+ return args;
779
+ }
780
+ function resolveListExpr(expr, vars) {
781
+ const trimmed = expr.trim();
782
+ const ref = /^\$\(([^)]+)\)$/.exec(trimmed);
783
+ if (ref) {
784
+ const val = resolveRef(ref[1], vars);
785
+ if (Array.isArray(val))
786
+ return val;
787
+ if (val === undefined || val === null)
788
+ return [];
789
+ return [val];
790
+ }
791
+ const list = /^\[(.*)\]$/.exec(trimmed);
792
+ if (list) {
793
+ const inner = list[1].trim();
794
+ if (inner === "")
795
+ return [];
796
+ return inner.split(",").map((s) => {
797
+ const t = s.trim();
798
+ if (t.length >= 2) {
799
+ const first = t[0];
800
+ const last = t[t.length - 1];
801
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
802
+ return t.slice(1, -1);
803
+ }
804
+ }
805
+ return t;
806
+ });
807
+ }
808
+ const sub = substituteRuntime(trimmed, vars);
809
+ try {
810
+ const v = JSON.parse(sub);
811
+ if (Array.isArray(v))
812
+ return v;
813
+ }
814
+ catch {
815
+ /* not JSON — wrap */
816
+ }
817
+ return [sub];
818
+ }
819
+ /**
820
+ * Unwrap `CallToolResult`-shaped values into the meaningful payload.
821
+ * Symmetry with `>` (binds `PortableMemory[]`) and `~` (binds the response
822
+ * string) — `$` should bind the *content*, not the wire envelope.
823
+ *
824
+ * Rules:
825
+ * 1. Non-CallToolResult-shaped — bind as-is.
826
+ * 2. `content[0].type === "text"` + JSON-parseable — bind parsed.
827
+ * 3. `content[0].type === "text"` + non-parseable — bind the raw string.
828
+ * 4. Non-text content — bind the content array.
829
+ */
830
+ function unwrapToolResult(result) {
831
+ if (result === null || typeof result !== "object")
832
+ return result;
833
+ const obj = result;
834
+ if (!Array.isArray(obj.content))
835
+ return result;
836
+ const first = obj.content[0];
837
+ if (!first)
838
+ return result;
839
+ if (first.type !== "text" || typeof first.text !== "string") {
840
+ return obj.content;
841
+ }
842
+ try {
843
+ return JSON.parse(first.text);
844
+ }
845
+ catch {
846
+ return first.text;
847
+ }
848
+ }
849
+ /**
850
+ * Coerce a string literal into its natural JS type when the shape is
851
+ * unambiguous. v1: bracket-list `[a, b, c]` → array. Other shapes pass through.
852
+ */
853
+ function coerceLiteralValue(raw) {
854
+ const trimmed = raw.trim();
855
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
856
+ return raw;
857
+ const inner = trimmed.slice(1, -1).trim();
858
+ if (inner === "")
859
+ return [];
860
+ return inner.split(",").map((s) => {
861
+ const t = s.trim();
862
+ if (t.length >= 2) {
863
+ const first = t[0];
864
+ const last = t[t.length - 1];
865
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
866
+ return t.slice(1, -1);
867
+ }
868
+ }
869
+ return t;
870
+ });
871
+ }
872
+ // ─── Substitution and condition evaluation (runtime-side) ─────────────────
873
+ /**
874
+ * Variant for `@ unsafe` op bodies. The `$$(...)` escape lets authors send
875
+ * `$(...)` literally to bash (for bash command-substitution); skillscript
876
+ * substitution sees `$$` and collapses to `$`. `$(NAME)` (single `$`)
877
+ * remains a skillscript variable substitution.
878
+ */
879
+ export function substituteRuntimeUnsafe(text, vars) {
880
+ // Step 1: pull `$$(` escapes out so step 2's regex doesn't see the inner $.
881
+ const ESCAPE = "DOLLAR_DOLLAR_PAREN";
882
+ const escaped = text.replace(/\$\$\(/g, ESCAPE);
883
+ // Step 2: normal skillscript substitution against the de-escaped text.
884
+ const substituted = substituteRuntime(escaped, vars);
885
+ // Step 3: restore the escape as literal `$(` for bash.
886
+ return substituted.replace(new RegExp(ESCAPE, "g"), "$(");
887
+ }
888
+ /**
889
+ * Runtime `$(NAME[|filter])` substitution. At runtime the full variable
890
+ * state is in scope; unresolved refs are a hard error (compile-time leaves
891
+ * them to pass through; runtime can't).
892
+ */
893
+ export function substituteRuntime(text, vars) {
894
+ return text.replace(/\$\(([^|)\s]+)\s*(?:\|\s*([A-Za-z_]\w*))?\s*\)/g, (_match, ref, filter) => {
895
+ const value = resolveRef(ref, vars);
896
+ if (value === undefined) {
897
+ throw new UnresolvedVariableError(ref, "?");
898
+ }
899
+ const s = stringifyValue(value);
900
+ if (!filter)
901
+ return s;
902
+ return applyFilter(s, filter);
903
+ });
904
+ }
905
+ /**
906
+ * Marker symbol for mechanical-mode placeholder objects. Tagged proxies
907
+ * stringify to their label when consumed by `stringifyValue` (used by
908
+ * substituteRuntime), so dotted access like `$(ISSUE.title)` works in
909
+ * mechanical mode even though no real dispatch happened — every property
910
+ * access produces a child placeholder.
911
+ */
912
+ const MECHANICAL_PLACEHOLDER = Symbol.for("skillscript.mechanical_placeholder");
913
+ /**
914
+ * Build a mechanical-mode placeholder. Acts like an object whose properties
915
+ * are also placeholders (recursive), but `stringifyValue` unwraps it to
916
+ * the literal label string. Lets cold-agent skills that use `$(VAR.field)`
917
+ * patterns execute end-to-end in mechanical mode without infrastructure.
918
+ */
919
+ function makeMechanicalPlaceholder(label) {
920
+ const target = { [MECHANICAL_PLACEHOLDER]: label };
921
+ return new Proxy(target, {
922
+ get(target, key) {
923
+ if (key === MECHANICAL_PLACEHOLDER)
924
+ return label;
925
+ // Symbol-keyed access (Symbol.iterator, Symbol.toPrimitive, etc.):
926
+ // return the target's own value so JS internals see a plain object.
927
+ if (typeof key === "symbol")
928
+ return Reflect.get(target, key);
929
+ // String-keyed access: synthesize a deeper placeholder.
930
+ return makeMechanicalPlaceholder(`${label}.${String(key)}`);
931
+ },
932
+ });
933
+ }
934
+ function isMechanicalPlaceholder(v) {
935
+ return v !== null && typeof v === "object" && v[MECHANICAL_PLACEHOLDER] !== undefined;
936
+ }
937
+ /**
938
+ * Resolve `$(NAME)` or `$(NAME.path)` against the variable map. Two strategies:
939
+ * 1. Flat-key match (full ref including dots). Handles `targetname.output`.
940
+ * 2. Dot-path traversal — split, descend.
941
+ * Returns `undefined` when unresolved.
942
+ */
943
+ export function resolveRef(ref, vars) {
944
+ if (vars.has(ref))
945
+ return vars.get(ref);
946
+ const path = ref.split(".");
947
+ const root = path[0];
948
+ if (!vars.has(root))
949
+ return undefined;
950
+ let cur = vars.get(root);
951
+ for (let i = 1; i < path.length; i++) {
952
+ if (cur === null || cur === undefined)
953
+ return undefined;
954
+ if (typeof cur !== "object")
955
+ return undefined;
956
+ cur = cur[path[i]];
957
+ }
958
+ return cur;
959
+ }
960
+ /**
961
+ * Render a value for inline substitution. Scalars stringify naturally;
962
+ * objects/arrays JSON-serialize. `null` renders as the literal `"null"` so
963
+ * authors can distinguish bound-to-null from unresolved.
964
+ */
965
+ export function stringifyValue(v) {
966
+ if (typeof v === "string")
967
+ return v;
968
+ if (typeof v === "number" || typeof v === "boolean")
969
+ return String(v);
970
+ if (v === null)
971
+ return "null";
972
+ if (isMechanicalPlaceholder(v))
973
+ return v[MECHANICAL_PLACEHOLDER];
974
+ return JSON.stringify(v);
975
+ }
976
+ const TRUTHY = /^\s*\$\(([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?:\s*\|\s*([A-Za-z_]\w*))?\)\s*$/;
977
+ const EQ = /^\s*\$\(([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?:\s*\|\s*([A-Za-z_]\w*))?\)\s*(==|!=)\s*"([^"]*)"\s*$/;
978
+ /** Ref-vs-ref equality (per language reference §5 + 2026-05-21 grammar extension). Filter + dotted-field-access permitted on either side. */
979
+ const 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*$/;
980
+ const 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*$/;
981
+ export function evalCondition(cond, vars) {
982
+ const t = TRUTHY.exec(cond);
983
+ if (t) {
984
+ const val = resolveRef(t[1], vars);
985
+ const filter = t[2];
986
+ const filtered = filter && val !== undefined ? applyFilter(stringifyValue(val), filter) : val;
987
+ return isTruthy(filtered);
988
+ }
989
+ const e = EQ.exec(cond);
990
+ if (e) {
991
+ const [, ref, filter, op, lit] = e;
992
+ const val = resolveRef(ref, vars);
993
+ const valStr = val === undefined ? "" : stringifyValue(val);
994
+ // Filter applies BEFORE comparison so `if $(COLOR|trim) == "yellow"` matches
995
+ // values that local models return with trailing whitespace.
996
+ const final = filter !== undefined ? applyFilter(valStr, filter) : valStr;
997
+ return op === "==" ? final === lit : final !== lit;
998
+ }
999
+ const eRef = EQ_REF.exec(cond);
1000
+ if (eRef) {
1001
+ const [, lhsRef, lhsFilter, op, rhsRef, rhsFilter] = eRef;
1002
+ // Both sides resolve via the same path as `EQ` LHS — undefined → ""
1003
+ // (matches the existing tolerance for unresolved refs in conditions).
1004
+ const lhsVal = resolveRef(lhsRef, vars);
1005
+ const rhsVal = resolveRef(rhsRef, vars);
1006
+ const lhsStr = lhsVal === undefined ? "" : stringifyValue(lhsVal);
1007
+ const rhsStr = rhsVal === undefined ? "" : stringifyValue(rhsVal);
1008
+ const lhsFinal = lhsFilter !== undefined ? applyFilter(lhsStr, lhsFilter) : lhsStr;
1009
+ const rhsFinal = rhsFilter !== undefined ? applyFilter(rhsStr, rhsFilter) : rhsStr;
1010
+ return op === "==" ? lhsFinal === rhsFinal : lhsFinal !== rhsFinal;
1011
+ }
1012
+ const i = IN.exec(cond);
1013
+ if (i) {
1014
+ const [, lhsRef, lhsFilter, notKey, rhsRef] = i;
1015
+ let rhsVal = resolveRef(rhsRef, vars);
1016
+ if (rhsVal === undefined) {
1017
+ throw new Error(`Runtime error in \`in\` condition: RHS \`$(${rhsRef})\` is unresolved`);
1018
+ }
1019
+ // Cold-agent corpus tolerance: model responses (`~` op) are strings;
1020
+ // when the author prompts for a JSON array and uses it as `in` RHS,
1021
+ // auto-parse the string to its array form. Matches how foreach's
1022
+ // resolveListExpr tolerates JSON-string list expressions. Strings
1023
+ // that don't JSON-parse to an array still error below as before.
1024
+ //
1025
+ // Mechanical-mode special-case: placeholder strings ("[mechanical:...]")
1026
+ // are treated as single-element arrays so `in` checks execute
1027
+ // structurally without false errors during dry-run validation.
1028
+ if (typeof rhsVal === "string") {
1029
+ if (rhsVal.startsWith("[mechanical:")) {
1030
+ rhsVal = [rhsVal];
1031
+ }
1032
+ else {
1033
+ try {
1034
+ const parsed = JSON.parse(rhsVal);
1035
+ if (Array.isArray(parsed))
1036
+ rhsVal = parsed;
1037
+ }
1038
+ catch {
1039
+ /* not JSON — fall through to the array-check error */
1040
+ }
1041
+ }
1042
+ }
1043
+ if (!Array.isArray(rhsVal)) {
1044
+ const got = rhsVal === null ? "null" : typeof rhsVal;
1045
+ throw new Error(`Runtime error in \`in\` condition: RHS \`$(${rhsRef})\` must be an array (got ${got})`);
1046
+ }
1047
+ const lhsVal = resolveRef(lhsRef, vars);
1048
+ if (lhsVal === undefined)
1049
+ return false;
1050
+ const lhsStr = lhsFilter !== undefined
1051
+ ? applyFilter(stringifyValue(lhsVal), lhsFilter)
1052
+ : stringifyValue(lhsVal);
1053
+ const found = rhsVal.some((item) => stringifyValue(item) === lhsStr);
1054
+ return notKey !== undefined ? !found : found;
1055
+ }
1056
+ throw new Error(`Invalid runtime condition (parser should have rejected): ${cond}`);
1057
+ }
1058
+ function isTruthy(v) {
1059
+ if (v === undefined || v === null)
1060
+ return false;
1061
+ if (typeof v === "string")
1062
+ return v.length > 0;
1063
+ if (typeof v === "number")
1064
+ return v !== 0;
1065
+ if (typeof v === "boolean")
1066
+ return v;
1067
+ if (Array.isArray(v))
1068
+ return v.length > 0;
1069
+ return true;
1070
+ }
1071
+ //# sourceMappingURL=runtime.js.map