skillscript-runtime 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -1
  3. package/dist/cli.js +6 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/errors.js +2 -2
  6. package/dist/errors.js.map +1 -1
  7. package/dist/help-content.js +5 -5
  8. package/docs/adopter-playbook.md +4 -6
  9. package/docs/configuration.md +1 -0
  10. package/docs/language-reference.md +127 -156
  11. package/examples/connectors/DataStoreTemplate/DataStoreTemplate.ts +14 -0
  12. package/examples/onboarding-scaffold/file-data-store.ts +13 -2
  13. package/examples/onboarding-scaffold/openai-local-model.ts +3 -1
  14. package/examples/onboarding-scaffold/tmux-shell-agent-connector.ts +41 -5
  15. package/examples/skillscripts/classify-support-ticket.skill.provenance.json +10 -0
  16. package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +10 -0
  17. package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +10 -0
  18. package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +10 -0
  19. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  20. package/examples/skillscripts/morning-brief.skill.md +5 -6
  21. package/examples/skillscripts/morning-brief.skill.provenance.json +10 -0
  22. package/examples/skillscripts/queue-length-monitor.skill.provenance.json +10 -0
  23. package/examples/skillscripts/service-health-watch.skill.provenance.json +10 -0
  24. package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +10 -0
  25. package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +10 -0
  26. package/package.json +1 -1
@@ -301,7 +301,7 @@ Closed list of language-intrinsic ops the runtime knows directly. Each is a func
301
301
  | `emit` | `emit(text="...")` | none | Append to the skill's emission stream; consumed by the configured `# Output:` delivery channel. |
302
302
  | `notify` | `notify(agent="...", message="...", [event_type=...], [correlation_id=...]) -> ACK` | optional | Mid-skill agent alert; synchronous send via configured AgentConnector. |
303
303
  | `inline` | `inline(skill="<data-skill-name>")` | none | Compile-time inline of an Approved `# Type: data` skill. Resolves at compile, records `content_hash` in provenance. |
304
- | `execute_skill` | `execute_skill(skill_name="...", inputs=\{...}) -> R` | optional | Composition primitive. Runtime-resolved. See Composition section. |
304
+ | `execute_skill` | `execute_skill(name="...", inputs=\{...}) -> R` | optional | Composition primitive. Runtime-resolved. `skill_name=` accepted as back-compat alias. See Composition section. |
305
305
  | `shell` | `shell(command="...") -> R` / `shell(argv=[...]) -> R` / `shell(command="...", unsafe=true) -> R` | optional | Structural spawn (default), explicit-argv spawn (`argv=[...]`, no tokenizer), or full-shell exec (`unsafe=true`, gated by `runtime.enable_unsafe_shell`). Binary gated by the operator allowlist (see below). stdout binds. |
306
306
  | `file_read` | `file_read(path="...") -> R` | required | Read a file at `path`; binds string contents. Optional `encoding="utf8"\|"base64"` kwarg (default `utf8`). |
307
307
  | `file_write` | `file_write(path="...", content="...")` | none | Write `content` to `path`. `mkdir -p` semantics for parent directories. Mutation-classified. |
@@ -358,7 +358,7 @@ Returns ACK `{agent, dispatched: [{connector, ok, error?}]}` — fire-and-forget
358
358
 
359
359
  Three forms.
360
360
 
361
- **1. `shell(command="...")` — structural spawn (default).** The command string is whitespace-tokenized and quote-stripped, then one binary is spawned with the resulting tokens. No shell, no metacharacters, no pipes/redirects. The tokenizer is **quote-aware**: quotes are respected during the whitespace split, so a literal `'hello world'` stays one token (the surrounding quotes are then stripped). stdout binds; non-zero exit → op-error routed through `else:` / `# OnError:`.
361
+ **1. `shell(command="...")` — structural spawn (default).** The command string is whitespace-tokenized and quote-stripped, then one binary is spawned with the resulting tokens. No shell, no metacharacters, no pipes/redirects. The tokenizer is **quote-aware**: quotes are respected during the whitespace split, so a literal `'hello world'` stays one token (the surrounding quotes are then stripped). stdout binds; non-zero exit → op-error routed through the target's `else:` handler.
362
362
 
363
363
  ```
364
364
  shell(command="curl -s 'wttr.in/${LOCATION|url}?format=j1'") -> RAW
@@ -437,10 +437,10 @@ See Composition section for the distinction between `inline` (compile-time), `ex
437
437
 
438
438
  ```
439
439
  classify:
440
- execute_skill(skill_name="classifier", inputs={"text": "${INPUT}"}) -> VERDICT
440
+ execute_skill(name="classifier", inputs={"text": "${INPUT}"}) -> VERDICT
441
441
  ```
442
442
 
443
- Runtime-resolved against the SkillStore. Recursion-depth-guarded (default 10).
443
+ Runtime-resolved against the SkillStore. Recursion-depth-guarded (default 10). Canonical kwarg is `name=`; `skill_name=` is an accepted back-compat alias.
444
444
 
445
445
  **Returns.** The caller's `-> R` binding receives `outputs` + `transcript` + execution metadata always, plus the child's declared `# Returns:` surface (each declared var accessible as `${R.X}`). Undeclared scratch is filtered — a child with no `# Returns:` yields `final_vars: {}`. This is what keeps composition from compounding (a child's internal state never propagates up). Full contract in Composition § Return contract.
446
446
 
@@ -517,7 +517,7 @@ $ amp.amp_olsen_task task_type="scan" timeout=120 -> DISPATCH
517
517
  $ data_write content="${REPORT}" tags=["oncall"] approved="morning roundup" -> ACK
518
518
  ```
519
519
 
520
- **`(fallback: "value")` trailer** — Fires on dispatch throw (including a `timeout=N` expiry) OR empty bound value (empty string after trim, empty array, null/undefined). Honored on `$` dispatch and on `shell()` (fires on shell op throw or empty stdout). Coerce-on-bind: the fallback value binds to the output var transparently, downstream targets need no conditional. Permissive value parsing — bare identifiers, quoted strings, bracketed array literals all accepted. A fired fallback is recorded in `result.fallbacks[]`.
520
+ **`(fallback: "value")` trailer** — Uniform as of v0.27.0: fires on ANY op failure — a dispatch throw (including a `timeout=N` expiry), an empty bound value (empty string after trim, empty array, null/undefined), OR a raised throw from an `execute_skill` child or a `$ json_parse` off-shape input. Honored on `$` dispatch and on `shell()` (fires on shell op throw or empty stdout). Coerce-on-bind: the fallback value binds to the output var transparently, downstream targets need no conditional. Permissive value parsing — bare identifiers, quoted strings, bracketed array literals all accepted. A fired fallback is recorded in `result.fallbacks[]` (with its `.reason`).
521
521
 
522
522
  Note: an envelope object like `{items: []}` is a non-empty object and does NOT trigger the fallback even though its contained array is empty. To handle envelope-empty downstream, test the contained collection (`if ${R.items|length} == "0":`) or apply a filter (`${R.items|fallback:[]}`).
523
523
 
@@ -599,7 +599,7 @@ $ data_write content="${SUMMARY}" tags=["oncall"] approved="morning roundup, 202
599
599
  $ ticketing.search query="project:INFRA state:Open" limit=20 -> ISSUES
600
600
  ```
601
601
 
602
- Tool args are unconstrained `key=value` pairs — the connector forwards them to the underlying MCP tool. If a dispatched call returns `isError: true`, the executor throws via `makeOpError`, which routes through `else:` / `# OnError:` machinery. The inner tool's error text is preserved in `result.errors[]`.
602
+ Tool args are unconstrained `key=value` pairs — the connector forwards them to the underlying MCP tool. If a dispatched call returns `isError: true`, the executor throws via `makeOpError`, which routes through the target's `else:` handler. The inner tool's error text is preserved in `result.errors[]`.
603
603
 
604
604
  **Substrate-neutrality.** Typed-contract bare ops (`$ llm`, `$ data_read`, `$ data_write`, `$ skill_*`, `$ json_parse`) are not reserved built-ins — they're typed-contract bridges the adopter wires through `substrate.local_model` / `substrate.data_store` / `substrate.skill_store` in `skillscript.config.json`. Substrate-specific tools (`$ amp.*`, `$ github.*`, etc.) are whatever the adopter declares in `connectors.json`. For external MCP servers, three bundled wiring paths cover the common cases:
605
605
 
@@ -669,7 +669,7 @@ deliver:
669
669
  | Runtime-intrinsic | `emit` | `emit(text="...")` or `emit(text="""...""")` for multi-line | none |
670
670
  | Runtime-intrinsic | `notify` | `notify(agent="...", [message=...], [event_type=...], [correlation_id=...]) -> ACK` | optional |
671
671
  | Runtime-intrinsic | `inline` | `inline(skill="<name>")` | none (compile-time) |
672
- | Runtime-intrinsic | `execute_skill` | `execute_skill(skill_name="...", inputs=\{...}) -> R` | optional |
672
+ | Runtime-intrinsic | `execute_skill` | `execute_skill(name="...", inputs=\{...}) -> R` (`skill_name=` back-compat alias) | optional |
673
673
  | Runtime-intrinsic | `shell` | `shell(command="...", [unsafe=true], [approved="..."]) [-> R] [(fallback: "...")]` or `shell(argv=[...], [approved="..."]) [-> R] [(fallback: "...")]` (mutually exclusive forms; binary allowlist applies to both) | optional |
674
674
  | Runtime-intrinsic | `file_read` | `file_read(path="...", [encoding="utf8"\|"base64"]) -> R` | required |
675
675
  | Runtime-intrinsic | `file_write` | `file_write(path="...", content="...", [approved="..."])` | none |
@@ -704,7 +704,7 @@ Injected automatically at runtime; never declared by the author.
704
704
  | `$\{TRIGGER_TYPE}` | What event fired this skill |
705
705
  | `$\{TRIGGER_PAYLOAD}` | Event-specific data |
706
706
  | `$\{EVENT.*}` | Event-payload fields populated by the trigger source |
707
- | `$\{ERROR_CONTEXT}` | In `# OnError:` fallback skills: type + target where failure occurred |
707
+ | `$\{ERROR_CONTEXT}` | Inside a target's `else:` error-handler: kind + message + target of the failure. |
708
708
 
709
709
  Iterator vars from `foreach` and output bindings from runtime-intrinsic / MCP-dispatch ops also pass through ambient at compile time; the runtime substitutes them per iteration / per op completion.
710
710
 
@@ -954,6 +954,8 @@ emit:
954
954
  emit(text="nested: ${ISSUE.customFields.Assignee|fallback:\"unassigned\"}")
955
955
  ```
956
956
 
957
+ **Order-independent in a filter chain (v0.26.2+).** A `|fallback` anywhere in the chain rescues an unresolved base — `${x|trim|fallback:"d"}` and `${x|fallback:"d"|trim}` both degrade an unresolved `x` to `"d"`. Nuance: it rescues a genuinely-unresolved/missing base; a **present-but-empty value still flows through the other filters**, so `${x|length|fallback:"0"}` on `x=" "` returns `"2"`, not `"0"`. See the Robustness & error containment section for the chain semantics.
958
+
957
959
  **Why filter-shape, not ref-level `(fallback:)`.** Op-level `(fallback: ...)` exists on `$` dispatch and `shell()` for **error/empty recovery** (the op ran, then failed or returned empty). Ref-level `|fallback:` is **coalesce** (the lookup itself found nothing — missing, null, or empty). They rhyme but are adjacent concepts. The filter-chain attachment keeps composition clean (`${VAR|json_parse|fallback:"-"}` works as a chain step) and the vocabulary alignment with op-level `(fallback:)` lets cold authors learn "fallback" as the universal concept while the syntax disambiguates the attachment site.
958
960
 
959
961
  **Closes the missing-field strict-error trap**: `${ISSUE.customFields.Assignee}` against an object without that key threw `UnresolvedVariableError` and aborted whole-render. The filter is the per-ref opt-out.
@@ -1018,7 +1020,7 @@ elif ${BODY|length} > 1000:
1018
1020
 
1019
1021
  ## Error handling
1020
1022
 
1021
- Unknown filter on a resolved variable produces a tier-1 `unknown-filter` compile error. Catches both bare (`|unknown`) and colon-positional (`|unknown:"arg"`) shapes. Filter chains that fail at runtime (e.g., `|json` on a non-serializable value, `|length` on a number, `|isodate` on a non-numeric value) produce op errors that route through `else:` / `# OnError:` machinery.
1023
+ Unknown filter on a resolved variable produces a tier-1 `unknown-filter` compile error. Catches both bare (`|unknown`) and colon-positional (`|unknown:"arg"`) shapes. Filter chains that fail at runtime (e.g., `|json` on a non-serializable value, `|length` on a number, `|isodate` on a non-numeric value) produce op errors that route through the target's `else:` handler.
1022
1024
 
1023
1025
  Bare `${NAME}` without a filter is unchanged.
1024
1026
 
@@ -1510,7 +1512,7 @@ The receiving agent reads `event_type` for routing ("911 — surface now" vs "ro
1510
1512
 
1511
1513
  ## Output routing failures
1512
1514
 
1513
- If `# Output: agent: <name>` fires and the wired AgentConnector throws, delivery routes through `else:` / `# OnError:`; the failure is recorded on `agent_delivery_receipts[]` for the scheduler to log.
1515
+ If `# Output: agent: <name>` fires and the wired AgentConnector throws, delivery routes through the target's `else:` handler; the failure is recorded on `agent_delivery_receipts[]` for the scheduler to log.
1514
1516
 
1515
1517
  ## Connectors — substrate routing, the five connector types, agent_id resolution
1516
1518
 
@@ -1751,7 +1753,7 @@ interface PortableMemory {
1751
1753
  score?: number;
1752
1754
 
1753
1755
  // Curated substrate subset — concept-portable, value-substrate-specific.
1754
- // Top-level access via $(MEMORY.field). Connectors populate when the
1756
+ // Top-level access via ${MEMORY.field}. Connectors populate when the
1755
1757
  // concept applies. MUST NOT also be duplicated into metadata.
1756
1758
  thread_status?: string;
1757
1759
  pinned?: boolean;
@@ -1765,7 +1767,7 @@ interface PortableMemory {
1765
1767
  agent_id?: string;
1766
1768
  vault?: string;
1767
1769
 
1768
- // Substrate-specific bag. Accessed via $(MEMORY.metadata.X).
1770
+ // Substrate-specific bag. Accessed via ${MEMORY.metadata.X}.
1769
1771
  metadata?: Record<string, unknown>;
1770
1772
  }
1771
1773
 
@@ -1784,13 +1786,13 @@ interface McpDispatchCtx {
1784
1786
 
1785
1787
  ## Field access semantics
1786
1788
 
1787
- `$(MEMORY.field)` resolves in tiers:
1789
+ `${MEMORY.field}` resolves in tiers:
1788
1790
  1. Core fields (id, summary, detail, score)
1789
1791
  2. Curated substrate subset (thread_status, pinned, etc.)
1790
1792
  3. `metadata.X` for everything else
1791
- 4. Ambient passthrough as literal `$(MEMORY.field)` if unresolved
1793
+ 4. Ambient passthrough as literal `${MEMORY.field}` if unresolved
1792
1794
 
1793
- **Connector duplication is a contract violation.** If a field is in the curated subset, the connector populates it at top-level only — `metadata.<same_name>` MUST be absent. Otherwise `$(M.thread_status)` and `$(M.metadata.thread_status)` can return different values (silent data divergence). Connectors enforce.
1795
+ **Connector duplication is a contract violation.** If a field is in the curated subset, the connector populates it at top-level only — `metadata.<same_name>` MUST be absent. Otherwise `${M.thread_status}` and `${M.metadata.thread_status}` can return different values (silent data divergence). Connectors enforce.
1794
1796
 
1795
1797
  ## Why connector abstraction matters
1796
1798
 
@@ -1798,224 +1800,193 @@ Hard-coupling skills to specific substrates would make information-flow decision
1798
1800
 
1799
1801
  ## Lifecycle and status — # Status: header, three canonical states (Draft / Approved / Disabled), compile + runtime enforcement
1800
1802
 
1801
- Skillscripts carry an explicit lifecycle state via the `# Status:` header. The compiler and runtime enforce status — a Disabled skillscript cannot fire under any path, regardless of who invokes it.
1803
+ Skillscripts carry an explicit lifecycle state via the `# Status:` header. The compiler and runtime enforce it — a Disabled skill cannot fire under any path, and (in secured mode) an unapproved skill cannot perform effectful ops under any path.
1802
1804
 
1803
1805
  ## Header syntax
1804
1806
 
1805
1807
  ```
1806
1808
  # Skill: support-response-draft
1807
- # Status: Approved v1:a1b2c3d4
1809
+ # Status: Approved v3:<signature>
1808
1810
  # Description: ...
1809
1811
  ```
1810
1812
 
1811
- If `# Status:` is omitted, the default state is **Draft**. This forces authors to explicitly promote a skillscript through its lifecycle rather than relying on "newly written = ready for use."
1813
+ If `# Status:` is omitted, the default is **Draft** authors must explicitly promote through the lifecycle rather than treating "newly written" as "ready."
1812
1814
 
1813
- **Case normalization:** Status values are accepted case-insensitively on input and stored as canonical form. `# Status: draft`, `# Status: Draft`, `# Status: DRAFT` all parse to canonical `Draft`. This principle applies across all enumerated frontmatter value spaces (see Overview section on Lexical conventions).
1815
+ **Case normalization:** the state word is accepted case-insensitively and stored canonical (`draft` / `Draft` / `DRAFT` `Draft`).
1814
1816
 
1815
1817
  ## The three canonical states
1816
1818
 
1817
- - **Draft** — being authored or under revision; not ready for production use. Compile warns; runtime refuses unless explicitly invoked with `--force-draft` for the author's own testing. Triggers don't fire under default dispatch.
1818
- - **Approved** — passed authoring + lint and is ready to fire. The canonical "in use" state. Compile is clean; runtime allows everywhere; declared triggers fire freely. **Requires a hash-token stamp** (see below).
1819
- - **Disabled** — explicitly off. Compile rejects; runtime rejects; triggers don't fire. Source and version history preserved, but the skillscript cannot execute under any path.
1820
-
1821
- These three states have crisp, universal operational meaning across every deployment. Every operator understands what each state means; no judgment calls about edge-case distinctions.
1822
-
1823
- ## Hash-token approval for Approved
1819
+ - **Draft** — being authored/revised; not production. Compile warns; the runtime refuses effectful execution unless explicitly force-run for the author's own testing; declared triggers are held non-firing.
1820
+ - **Approved** — reviewed and cleared to fire. In secured mode this requires a valid operator **signature** (below). Compile clean; runtime allows; declared triggers fire.
1821
+ - **Disabled** — explicitly off. Compile + runtime reject; triggers don't fire. Source and version history preserved, but the skill cannot execute under any path.
1824
1822
 
1825
- `# Status: Approved` requires a stamped version-hash token: `# Status: Approved v1:<token>` where `<token>` is `f(skill_body)`. Naked `# Status: Approved` (without the stamp) refuses to execute at runtime.
1823
+ ## Approval by operator signature (secured mode)
1826
1824
 
1827
- The runtime verifies the stamp on every execution path:
1828
- - Trigger-fired dispatch
1829
- - MCP `execute_skill` invocation
1830
- - In-skill `$ execute_skill` composition
1831
- - Compile-time `inline(skill=...)` references
1825
+ In **secured mode**, `# Status: Approved` requires a valid **Ed25519 signature** over the skill body: `# Status: Approved v3:<signature>`. The signature is applied **operator-side** (the human's key); the runtime **verifies it on every execution path** — trigger dispatch, MCP `execute_skill`, in-skill `$ execute_skill` composition, compile-time `inline(skill=...)`. **The runtime never holds the signing key.** An unsigned, invalidly-signed, or tampered body is **inert** — it cannot perform effectful ops no matter how it's dispatched.
1832
1826
 
1833
- The stamp closes the gate against tampered or Draft-promoted-without-review skill bodies. Three paths produce a stamped Approved skill:
1827
+ Two paths produce a signed Approved skill:
1828
+ 1. **Dashboard approval** — a human reviews and approves; the dashboard signs.
1829
+ 2. **`skillfile approve` CLI** — signs the body operator-side.
1834
1830
 
1835
- 1. **Dashboard approval flow** — human reviewer approves; dashboard stamps the token.
1836
- 2. **`skill_write` MCP tool auto-stamp** — when an agent writes a skill body declaring `# Status: Approved`, the SkillStore auto-stamps the token on persist (headless-adopter convenience).
1837
- 3. **Manual stamp** — for unusual cases; details in the adopter playbook.
1831
+ **An agent cannot self-approve.** In secured mode, `skill_write` with an agent-declared `# Status: Approved` is **forced to Draft on persist** — a write can't grant approval; only the operator's signature can. Editing a signed body invalidates the signature (it no longer matches the changed body), so a tampered Approved skill refuses to execute; re-approval (re-sign) is required after any edit. (This is the closure of the two self-approval vectors: the token is a real signature the agent can't forge, and the write path can't stamp itself Approved. Verified by adversarial red-team — no forgery path.)
1838
1832
 
1839
- Tampered bodies (someone edits the source post-stamp) re-derive a different token and refuse to execute. The hash-token check is the structural lock; the dashboard / `skill_write` flows are the discipline layer.
1833
+ In **unsecured mode** (secured is a deliberate on/off choice made at onboarding), signature enforcement is off and Approved fires without a signature. Secured mode is the posture for any deployment where untrusted or fully-autonomous authoring happens.
1840
1834
 
1841
- ## Compile + runtime behavior table
1835
+ ## Compile + runtime behavior (secured mode)
1842
1836
 
1843
- | State | Compile | Runtime invocation | Test harness | Default trigger fire |
1844
- |-------|---------|-------------------|--------------|---------------------|
1845
- | Draft | warn | refuse (unless `--force-draft`) | allow (with flag) | refuse |
1846
- | Approved (stamped) | OK | allow | allow | allow |
1847
- | Approved (unstamped) | warn `approved-without-stamp` | refuse | refuse | refuse |
1848
- | Disabled | refuse | refuse | refuse | refuse |
1837
+ | State | Compile | Runtime (effectful) | Default trigger fire |
1838
+ |-------|---------|---------------------|----------------------|
1839
+ | Draft | warn | refuse (author force-run only) | refuse |
1840
+ | Approved (valid signature) | OK | allow | allow |
1841
+ | Approved (unsigned / invalid) | warn | refuse | refuse |
1842
+ | Disabled | refuse | refuse | refuse |
1849
1843
 
1850
1844
  ## Trigger registry interaction
1851
1845
 
1852
- The trigger registry respects status. A skillscript in Draft or Disabled state has its declared triggers held in a non-firing state — the trigger is registered (visible via `listTriggers`) but the scheduler skips dispatch. This lets authors register triggers while still in Draft mode without risking accidental production fires.
1853
-
1854
- When a skillscript transitions to Approved (with valid stamp), its triggers activate. When it transitions to Disabled, its triggers deactivate.
1846
+ The trigger registry respects status: a Draft/Disabled skill's declared triggers are registered (visible via `list_triggers`) but the scheduler skips dispatch. On transition to a valid Approved, triggers activate; on Disabled, they deactivate. This lets authors register triggers while still in Draft without risking accidental production fires.
1855
1847
 
1856
1848
  ## State transitions
1857
1849
 
1858
- Status transitions are freeform any author with write authority on the skillscript can flip the status by editing the header. Future versions may add transition rules (Draft Approved with lint-pass requirement; Disabled requiring admin-level permission) once a real authorship-permissions story is in place.
1850
+ In **secured mode**, promotion to Approved is not a freeform header edit it requires the operator signature (dashboard / `skillfile approve`). Demotion to Draft or Disabled by any author with write authority is fine. In unsecured mode, transitions are freeform header edits.
1859
1851
 
1860
1852
  ## Audit trail
1861
1853
 
1862
- Status changes are visible via the storage substrate's versioning. For SqliteSkillStore-backed skills, each status transition appends a row to `skill_versions` (see SqliteSkillStore docs). For FilesystemSkillStore-backed skills, status changes show up in git history. The audit trail is part of the substrate, not part of the language.
1854
+ Status changes are visible via the storage substrate's versioning a `skill_versions` row for SqliteSkillStore, git history for FilesystemSkillStore. The audit trail is part of the substrate, not the language.
1863
1855
 
1864
1856
  ## States considered but not implemented
1865
1857
 
1866
- Three additional states were considered and deferred. Each is cheap to add later when justified by real operational need:
1867
-
1868
- - **Test** — distinct "passed compile but not production-ready" state. Today's Draft covers this case (same behavior — refuse to fire under default dispatch). If authors find Draft and Test are operationally distinct in practice, Test ships then.
1869
- - **Deployed** — distinct "currently shipping" state separate from Approved. Today's Approved + active triggers IS deployed; no operational difference. If a deployment finds Approved-vs-Deployed meaningfully different (e.g., a release-gating workflow that distinguishes "ready" from "live"), Deployed ships then.
1870
- - **Deprecated** — soft-warn state for "still works but new authoring should use a successor." Deprecation is currently carried in metadata (`deprecated: true` in frontmatter) + a lint warning at invocation sites. When deprecated skills accumulate enough that the metadata pattern is awkward, Deprecated promotes to a first-class state.
1871
-
1872
- Adding states is additive — existing skills with the three-state model continue to work when new states are added.
1858
+ Test, Deployed, and Deprecated were considered and deferred — today's Draft/Approved cover their cases, and adding states later is additive. (Deprecation is currently carried as metadata + a lint warning at invocation sites.)
1873
1859
 
1874
1860
  ## Why this matters
1875
1861
 
1876
- The lifecycle states are the language's answer to operational safety at scale. A traditional "all skillscripts compile and run" model relies on author discipline to keep broken or untested work out of production. Status states enforce the discipline at the language level — a Disabled skill cannot fire even if every author downstream forgets it's broken. The hash-token approval mechanism extends this to: an Approved skill cannot fire if its body was tampered post-approval. The constraint IS the safety story, here as elsewhere.
1877
-
1878
- ## Open questions
1879
-
1880
- - **Status + composition.** When a procedural skill references a data skill via `inline(skill=...)`, what happens if the data skill is Disabled? Probable answer: compile-time error if any referenced skill is Disabled.
1881
- - **Bulk status operations.** "Disable all skills tagged with project:legacy" is a useful operational primitive. May add a `skillscript bulk-status <pattern> <state>` CLI affordance later.
1862
+ Lifecycle states are the language's operational-safety answer. A Disabled skill can't fire even if every author forgets it's broken; in secured mode, an Approved skill can't fire if its body was tampered post-signature, and an agent can't approve its own work. The constraint IS the safety story, here as elsewhere.
1882
1863
 
1883
1864
  ## Error handling — else: blocks, # OnError: fallback, op-level fallback values
1884
1865
 
1885
- Skillscript provides three layers of error handling, working from local to global.
1866
+ Skillscript has no try/catch error handling is authored. Two runtime mechanisms (local to global) plus a structural discipline that prevents failure outright.
1886
1867
 
1887
- ## Layer 1: Target-level `else:` block
1868
+ ## Layer 1: Target-level `else:` block — the throw-container
1888
1869
 
1889
- Runs if any op in the target's primary body errors. Local to the failing target. Downstream targets that depend on this one can still proceed using whatever the `else:` branch produced.
1870
+ Runs if any op in the target's primary body throws. Local to the failing target; downstream targets that depend on it can still proceed with whatever the `else:` branch produced. `${ERROR_CONTEXT}` (`.kind` / `.message` / `.target`) is available inside it. This is the way to contain a raised throw — including an `execute_skill` child-throw and a `$ json_parse` on malformed input (both verified v0.27.0).
1890
1871
 
1891
1872
  ```
1892
1873
  fetch:
1893
1874
  $ data_read mode=fts query=${TOPIC} limit=5 -> RESULT
1894
1875
  else:
1895
- emit(text="retrieval failed, falling back to empty result")
1876
+ emit(text="retrieval failed: ${ERROR_CONTEXT.message}")
1896
1877
  $set RESULT = ""
1897
1878
  ```
1898
1879
 
1899
- ### Distinguished from conditional `else:`
1900
-
1901
- The keyword `else:` is shared between two purposes:
1902
- - Conditional `else:` — appears after `if:` / `elif:` chain inside a target body
1903
- - Target `else:` — appears as a sibling block after a target's primary body, as an error handler
1880
+ Distinguished from conditional `else:` (which appears after an `if:`/`elif:` chain inside a body) by the parser's scope-stack; both can coexist in a target. An `else:` block may not declare its own error handler.
1904
1881
 
1905
- The parser's scope-stack discriminates at parse time. Both kinds coexist in the same target.
1882
+ ## Layer 2: Op-level `(fallback:)` value uniform (v0.27.0+)
1906
1883
 
1907
- ### Constraint
1908
-
1909
- `else:` blocks may not declare their own error handlers (no nested catch). If an `else:` block fails, the whole target fails through `# OnError:` if present.
1910
-
1911
- ## Layer 2: Skill-level `# OnError:` header
1912
-
1913
- Names a fallback skill to invoke if anything in the skill fails — including target-level errors that aren't caught by `else:`, compile errors, or the executing context running out of resources.
1884
+ Inline fallback on the op line. As of **v0.27.0 it is uniform: it contains ANY failure of that op** — a raised throw OR an empty/missing result. It works on `$` (MCP dispatch), `shell()`, `file_read`, and — since v0.27.0 — `execute_skill` (child throw) and `$ json_parse` (malformed input). On failure the fallback value binds to the output var via the same path as a success (coerce-on-bind), so downstream sees it transparently and needs no "did this fail?" check. A fired fallback is recorded in `result.fallbacks[].reason` (not `result.errors[]`) — degrade-loud.
1914
1885
 
1915
1886
  ```
1916
- # Skill: morning-brief
1917
- # OnError: morning-brief-degraded
1887
+ weather:
1888
+ shell(command="curl -s wttr.in/${LOC}?format=j1") -> RAW (fallback: "{}")
1889
+ $ json_parse ${RAW} -> W (fallback: "{}")
1890
+ $ llm prompt="Summarize: ${W}" -> SUMMARY (fallback: "summary unavailable")
1918
1891
  ```
1919
1892
 
1920
- Compile-time existence check — fails clean if the referenced fallback doesn't exist. The fallback skill is itself a skill (same compilation, same execution model) and can do real work (file an issue, post an ack, write a degraded result, etc.).
1921
-
1922
- The fallback skill receives:
1923
- - The same inputs as the failing skill
1924
- - An additional `${ERROR_CONTEXT}` ambient ref containing the error type and the target where it failed
1925
-
1926
- ### Constraint
1927
-
1928
- Nested `# OnError:` is *not* supported. If `# OnError: degraded-skill` fires and `degraded-skill` itself errors, the runtime hard-exits with no further fallback. Spec is explicit on this.
1929
-
1930
- ## Layer 3: Op-level fallback values
1931
-
1932
- Inline fallback declared on the op line. Used when the call fails or returns empty. Supported on `$` (MCP dispatch) ops and on `shell()` with coerce-on-bind semantics. For `shell()` the fallback fires on op throw OR empty stdout (e.g. `gh pr list` against a repo with no open PRs binds the fallback string).
1893
+ Fallback-value parsing is permissive bare identifiers, quoted strings, and bracketed array literals all accepted:
1933
1894
 
1934
1895
  ```
1935
- weather:
1936
- $ data_read mode=fts query="weather ${LOCATION}" limit=1 -> CURRENT (fallback: "weather unavailable")
1937
- $ llm prompt="Summarize: ${CURRENT}" -> SUMMARY (fallback: "summary unavailable")
1938
- $ slack.post channel=${CHANNEL} text=${SUMMARY} (fallback: "post failed silently") -> ACK
1939
- shell(argv=["gh","pr","list","--repo","acme/foo"]) -> PRS (fallback: "No open PRs")
1896
+ $ data_read mode=fts query="..." -> RESULTS (fallback: []) # array literal
1897
+ $ llm prompt="..." -> VERDICT (fallback: unknown) # bare identifier
1898
+ $ slack.post text="..." -> ACK (fallback: "post failed") # quoted string
1940
1899
  ```
1941
1900
 
1942
- Same pattern as the `# Requires:` cascade's `(fallback: ...)` syntax consistent across compile-time (`# Requires:`) and runtime (`$` dispatch and `shell()`).
1901
+ ## Structural guardprevent the failure
1943
1902
 
1944
- **Fallback value parsing.** Permissive: bare identifiers, quoted strings, and bracketed array literals all accepted. Matches the `# Requires:` cascade convention.
1903
+ Pre-bind defaults (`$set`) for every var the template/downstream reads, then gate the risky op behind a `contains`/shape check (`if ${RAW|contains:"expected_key"}:`) so the throw never happens. Most robust of all — no error to contain because none is raised. See *Robustness & error containment* for the full discipline.
1945
1904
 
1946
- ```
1947
- $ data_read mode=fts query="..." -> RESULTS (fallback: []) # array literal
1948
- $ llm prompt="..." -> VERDICT (fallback: unknown) # bare identifier
1949
- $ slack.post text="..." -> ACK (fallback: "post failed") # quoted string
1950
- ```
1905
+ ## Choosing a mechanism
1951
1906
 
1952
- **Coerce-on-bind semantics.** On op throw or empty-result, the fallback value is bound to the outputVar via the same path as a successful result. Downstream targets see the fallback transparently — they don't need conditional checks to detect "did this op fail?" The op-level fallback IS the default-on-failure value.
1907
+ - **`(fallback:)`** "any failure of this op this default value." Uniform; the per-op opt-out. Reach for it first.
1908
+ - **`else:`** — target-level; when you need the error's details (`${ERROR_CONTEXT}`) to branch, or to contain a throw spanning several ops.
1909
+ - **Structural guard** — when you can cheaply validate the shape before the risky op; prevents the failure entirely.
1953
1910
 
1954
- ## Error propagation rules
1911
+ ## Deprecated: `# OnError:` — parsed but not wired
1955
1912
 
1956
- - Op error caught by `else:` if present, otherwise propagates to target
1957
- - Target error → caught by `# OnError:` if present, otherwise propagates to caller
1958
- - Caller can still catch via standard exception handling on compile / runtime invocation APIs
1959
- - `else:` blocks are not allowed to declare their own error handlers
1960
- - If an `else:` block itself fails, the whole target fails through `# OnError:` (if present)
1913
+ A skill-level `# OnError: <fallback-skill>` header parses, but it is **not wired in the current runtime: the named fallback never fires.** A skill that relies on it has, in effect, no error handling so don't use it. Use a target-level `else:` handler (or an op-level `(fallback:)`) instead. Removal of the header is planned for a future release (v0.28); until then it is inert, not a compile error. To recover at the whole-skill level, wrap the body in a target with an `else:` that dispatches your recovery skill:
1961
1914
 
1962
- ## Visibility into errors
1915
+ ```
1916
+ run:
1917
+ execute_skill(name="do-the-work") -> R
1918
+ else:
1919
+ execute_skill(name="recovery", inputs={"REASON": "${ERROR_CONTEXT.message}"}) -> R
1920
+ ```
1963
1921
 
1964
- Open spec question: should `${ERROR}` be ambient inside `else:` blocks (same shape as `${ERROR_CONTEXT}` in `# OnError:` fallbacks)? Current lean: yes. Useful for telemetry skills that need to know what failed before falling back. Not yet specified or shipped.
1922
+ ## Propagation
1965
1923
 
1966
- ## The fallback pattern is consistent across scopes
1924
+ - Op throw → contained by a `(fallback:)` on that op if present; else by the target's `else:`; else propagates to the caller (surfaces in `result.errors[]`).
1925
+ - A fired op-level `(fallback:)` is recorded in `result.fallbacks[]`, not `result.errors[]`.
1926
+ - Under the hood: when a `$` op returns `isError: true` the executor throws via `makeOpError` rather than binding the error text — that throw is what routes to `(fallback:)` / `else:` and surfaces in `result.errors[]`. Op-level `(fallback:)` intercepts it and binds the fallback value instead.
1927
+ - **Composition caveat:** a child skill's failures do NOT bubble to the parent's top-level `result.errors[]` — they nest in the bound `${R.errors}`. A top-level `errors: []` does not mean nothing failed downstream. Inspect `${R.errors}` or rely on `(fallback:)`/`else:`, which fire regardless of nesting depth. (See Composition.)
1967
1928
 
1968
- Same idea at every scope:
1969
- - Compile-time: `# Requires: ... (fallback: value)`
1970
- - Runtime op: `$ dispatch ... (fallback: value)` and `shell(...) ... (fallback: value)`
1971
- - Runtime target: `else:` block
1972
- - Whole skill: `# OnError:` header
1929
+ ## Robustness & error containment — best practices (no try/catch by design)
1973
1930
 
1974
- Authors composing complex skills use these in combination op-level for transient errors, target-level for cohesive error paths, skill-level for last-resort degradation.
1931
+ **Skillscript has no try/catch by design. Robustness is authored, not caught.** An unguarded fallible op that fails aborts the whole target and in a fan-out, aborts every sibling that hadn't run yet. You contain failures explicitly.
1975
1932
 
1976
- ## Connection to runtime observability
1933
+ **One uniform rule (v0.27.0+): `(fallback:)` contains ANY op failure.** A `-> VAR (fallback: "<default>")` trailer catches whatever goes wrong with that op — a `$`/`shell` dispatch error, a `shell` spawn-fail/timeout, an empty result, AND a raised throw (`$ json_parse` on off-shape input, `execute_skill` whose child throws). The op degrades to the fallback value and the target continues. (History: before v0.27.0, `execute_skill` and `$ json_parse` were runtime intercepts that threw before the fallback wiring, so `(fallback:)` did NOT catch their throws — you needed `else:` or a structural guard. v0.27.0 made the intercepts consult `op.fallback` like `file_read`, retiring that split. Legacy note for ≤0.26.x authors at the end.)
1977
1934
 
1978
- Per-op error contract is what makes cascading fallbacks work. When `$` returns `isError: true`, the executor throws via `makeOpError` rather than binding the error text to the output var. The throw routes through `else:` / `# OnError:` machinery and surfaces in `result.errors[]` for the scheduler to log. Without this discipline, op-level failures wouldn't propagate to the fallback layers and silent-fail would be the default. Op-level `(fallback:)` is distinct: it intercepts the throw/empty-result at the op and binds the fallback value rather than propagating — a fired fallback is recorded in `result.fallbacks[]`, not `result.errors[]`.
1935
+ **The containment tools pick by how much you want to handle:**
1936
+ | Tool | Catches | Scope |
1937
+ |---|---|---|
1938
+ | `(fallback: "…")` op trailer | ANY failure of that op — dispatch error / spawn-fail / timeout / empty result / raised throw | the single op |
1939
+ | `$\{ref\\|fallback:"x"}` filter | a missing / unresolved / empty value at use-time | the single reference |
1940
+ | `else:` block | a raised throw anywhere in the target body, WITH error context (`$\{ERROR_CONTEXT.kind/.message/.target}`) | the whole target |
1941
+ | structural guard (pre-bind defaults + a `contains`/shape check before the risky op) | PREVENTS the failure — the op isn't reached on bad input, so it can't fail | the risky op |
1942
+ | `# OnError: <skill>` | INERT in the current runtime (fallbackSkillExecutor never wired) — do NOT rely on it; prefer `else:` | — |
1979
1943
 
1980
- ## Robustness & error containment best practices (no try/catch by design)
1944
+ `(fallback:)` = "any failure → this default." `else:` = "I need the error's DETAILS to branch." Structural guard = "I'd rather this never fail" (most robust the failure never reaches a handler).
1981
1945
 
1982
- **Skillscript has no try/catch by design.** The simplicity is deliberate: no exception-handling construct, no stack unwinding to catch. Robustness is *authored*, not caught. An unguarded fallible op propagates its failure up and aborts the whole target — and in a composition, aborts every sibling op that hadn't run yet. Containment uses two primitives (defined in the *Error handling* section): op-level `(fallback: <value>)` and the `|fallback:\"<value>\"` pipe filter. This section is the discipline for applying them.
1946
+ **Rule 1Fallback every fallible op whose failure shouldn't be fatal.** `shell`, `$` dispatch, `file_read`, `execute_skill`, `$ json_parse` can all fail; give each `-> VAR (fallback: "<default>")` unless its failure SHOULD abort the skill (leaving it bare is how you mark it critical).
1983
1947
 
1984
- **Rule 1Fallback every fallible op whose failure shouldn't be fatal.** `shell(...)`, external `$ connector.tool` dispatch, `execute_skill(...)`, `file_read(...)`anything reaching outside the runtime can fail. A bare fallible op with no `(fallback:)` turns a transient upstream hiccup into a hard target abort. Give it `-> VAR (fallback: \"<sensible default>\")`.
1948
+ **Rule 2In a fan-out, fallback EACH leg.** A gather running N independent legs (weather + mailbox + calendar + …) must fallback each, or the first leg to fail takes the rest down — later ops never run, every output empties. One non-critical leg must never sink its siblings. For a leg whose child can throw, ALSO throw-proof the child (below) — belt and suspenders, and it keeps the failure legible at the leg. (Observed 2026-07-05: an unguarded weather leg threw and aborted a 7-leg morning brief, six healthy legs lost. Fix that held: per-leg fallback + a throw-proof weather child.)
1985
1949
 
1986
- **Rule 2 — In a fan-out, fallback EACH leg independently.** A gather/compose skill running N independent legs (weather + mailbox + calendar + …) must fallback each leg, or the first leg to fail takes the rest down — the later ops never run and every output returns empty. One non-critical leg must never be able to sink the critical ones. (Observed 2026-07-05: an unguarded weather `execute_skill` leg threw on a transient upstream response and aborted an entire 7-leg morning-brief gather six healthy legs lost to one. The fix is a per-leg fallback so weather degrades to a placeholder and mailbox/Olsen/board still run.)
1950
+ Throw-proof child pattern (the strongest per-op guardprevents rather than catches):
1951
+ ```
1952
+ fetch:
1953
+ $set AREA = "(weather unavailable)" # degraded default — always bound
1954
+ if ${RAW|contains:"current_condition"}: # guard BEFORE the risky op
1955
+ $ json_parse ${RAW} -> W
1956
+ $set AREA = "${W.nearest_area|fallback:\"(weather unavailable)\"}"
1957
+ default: fetch
1958
+ ```
1959
+ Bad input skips the `if`, the default stands, the child never throws — so a parent gather can't be sunk by it (independent of the parent's own fallback).
1987
1960
 
1988
- **Rule 3 — a `|fallback` anywhere in a filter chain rescues an unresolved reference (v0.26.2+).** `${x|fallback:\"d\"}`, `${x|fallback:\"d\"|trim}`, and `${x|trim|fallback:\"d\"}` all degrade an unresolved/undefined `x` to the fallback — on an undefined base the chain skips the intervening filters and lets `|fallback` catch. Position no longer matters: as long as a `|fallback` is present, the chain won't throw on an unresolved base. Two edges to keep straight:
1989
- - A chain with NO `|fallback` still throws on an unresolved ref (`${x|trim}` on undefined `x` throws) — that's your signal to add one.
1990
- - `|fallback` only rescues the genuinely UNRESOLVED case. A present-but-empty value (e.g. `\" \"`) still flows through the filters, so `${x|length|fallback:\"0\"}` on `x=\" \"` returns `\"2\"`, not `\"0\"`.
1961
+ **Rule 3 — a `fallback` filter anywhere in a chain rescues an unresolved reference** (order-independent, v0.26.2+; also rescues a missing dotted/numeric path on a present object — `${W.a.0.b|fallback:"d"}` when `W` lacks `a` → `"d"`). A chain with NO `fallback` still throws on an unresolved ref that's your signal to add one. It rescues only genuinely-unresolved refs; a present-but-empty value (`" "`) still flows through the other filters, so `${x|length|fallback:"0"}` on `" "` is `"2"`.
1991
1962
 
1992
- **Rule 4 — A body-text output template must not reference a var a fallible step might leave unset.** The template renders after the target runs; if a fallible `$set` was skipped by a throw upstream, its var is unset and the template itself hard-fails (this is how an upstream data hiccup surfaces as `Unresolved variable reference: $(AREA)` at the template). Either set every template var to a default before the fallible step, or source each with a `|fallback` at `$set` time so it is always bound.
1963
+ **Rule 4 — A body-text output template must not reference a var a fallible step might leave unset.** The template renders after the target runs; an unset var hard-fails the render (`Unresolved variable reference: $(AREA)`) and that is exactly how a child skill throws OUT to its `execute_skill` parent. Pre-bind every template var to a default before the fallible step (this doubles as the throw-prevention structure in Rule 2), or source each with a `|fallback`.
1993
1964
 
1994
- **Rule 5 — Degrade LOUD, not silent.** A fallback value should be a visible marker (\"unavailable\", \"—\", \"n/a\"), never empty and never a plausible-but-wrong value. A degraded run must be *diagnosable* surface that a leg degraded, don't silently ship a blank or a fake reading. Clean-throw-then-clean-degrade beats both a hard abort and a silent lie: the failure stays contained AND legible.
1965
+ **Rule 5 — Degrade LOUD, not silent.** A fallback value should be a visible marker ("unavailable", "", "n/a") never empty, never a plausible-but-wrong value. A degraded run must be diagnosable: the throw's reason is preserved in `result.fallbacks[].reason`, so a degraded leg stays traceable. Don't silently ship a blank or a fake reading. Clean-degrade beats both a hard abort and a silent lie.
1995
1966
 
1996
- **Where the mechanisms live:** see *Error handling* (`else:` blocks, `# OnError:` skill-level fallback, op-level `(fallback:)`) for the primitives; this section is when/where to reach for them.
1967
+ **Legacy note (≤0.26.x runtimes):** before v0.27.0, `(fallback:)` did NOT catch a raised throw from `execute_skill` or `$ json_parse` (they bypassed op.fallback). On an older runtime, contain those two with `else:` or a structural guard a `(fallback:)` on them is a no-op against a throw. v0.27.0+ unifies it, so this only matters if you must support an old runtime.
1997
1968
 
1998
1969
  ## Composition — skills calling skills
1999
1970
 
2000
1971
  Skillscript supports skill-to-skill composition via the runtime's public composition primitive. A parent skill invokes a child skill, optionally passes inputs, optionally binds the child's result. The runtime threads variable state, propagates errors, and enforces a recursion-depth guard.
2001
1972
 
2002
- Composition is exposed as a runtime-intrinsic op: `execute_skill(skill_name="...", inputs={...}) -> R`. Symmetric with `compile_skill` and `lint_skill` — same surface, same naming convention, no external-namespace dependency.
1973
+ Composition is exposed as a runtime-intrinsic op: `execute_skill(name="...", inputs={...}) -> R`. Symmetric with `compile_skill` and `lint_skill` — same surface, same naming convention, no external-namespace dependency. (`skill_name=` is an accepted back-compat alias for `name=`; prefer `name=` in new skills.)
2003
1974
 
2004
1975
  ## Surface
2005
1976
 
2006
1977
  ```
2007
1978
  parent:
2008
- execute_skill(skill_name="child") -> RESULT
1979
+ execute_skill(name="child") -> RESULT
2009
1980
  emit(text="Child returned: ${RESULT}")
2010
1981
  ```
2011
1982
 
2012
- The runtime resolves `skill_name` against the configured SkillStore at dispatch time, runs the child to completion against the runtime's wired connectors, and binds the result.
1983
+ The runtime resolves `name` against the configured SkillStore at dispatch time, runs the child to completion against the runtime's wired connectors, and binds the result.
2013
1984
 
2014
1985
  ## Tool signature
2015
1986
 
2016
1987
  ```
2017
1988
  execute_skill({
2018
- skill_name: string, // required — resolves via SkillStore
1989
+ name: string, // required — resolves via SkillStore (skill_name= accepted as back-compat alias)
2019
1990
  inputs?: Record<string,string>, // optional — Vars override map
2020
1991
  mechanical?: boolean // optional — dry-run mode (default false)
2021
1992
  })
@@ -2062,7 +2033,7 @@ default: fetch
2062
2033
 
2063
2034
  ## Semantics
2064
2035
 
2065
- **Skill resolution.** Missing skills produce a clean structured error (`MissingSkillReferenceError extends OpError`) — the parent's `(fallback: ...)` discipline applies if specified, otherwise the parent skill's `# OnError:` fallback fires if declared, otherwise the parent fails with the error propagated through.
2036
+ **Skill resolution.** Missing skills produce a clean structured error (`MissingSkillReferenceError extends OpError`) — the parent's `(fallback: ...)` discipline applies if specified, otherwise a target-level `else:` handler catches it if declared, otherwise the parent fails with the error propagated through.
2066
2037
 
2067
2038
  **Input override.** `inputs` map keys must match the child's `# Vars:` declarations. Undeclared keys are ignored. Required vars without defaults must be supplied or dispatch fails before the child starts.
2068
2039
 
@@ -2076,28 +2047,27 @@ default: fetch
2076
2047
 
2077
2048
  Two non-obvious behaviors when reading a child's failures from the parent (both confirmed by the v1.0 runtime-semantics test battery):
2078
2049
 
2079
- - **Nested errors do NOT surface at the top level.** When a child op fails — e.g. the recursion-depth guard fires — the structured error nests inside the child's `R.errors`, which nests inside *its* parent's `R.errors`, and so on up the chain. It does **not** bubble to the caller's top-level `result.errors`. So a top-level `errors: []` does **not** mean nothing failed downstream. To detect a child failure, inspect the bound `${R.errors}` (and deeper), or rely on `(fallback: ...)` / `# OnError:` — those fire on the structured error regardless of nesting depth.
2050
+ - **Nested errors do NOT surface at the top level.** When a child op fails — e.g. the recursion-depth guard fires — the structured error nests inside the child's `R.errors`, which nests inside *its* parent's `R.errors`, and so on up the chain. It does **not** bubble to the caller's top-level `result.errors`. So a top-level `errors: []` does **not** mean nothing failed downstream. To detect a child failure, inspect the bound `${R.errors}` (and deeper), or rely on `(fallback: ...)` / `else:` — those fire on the structured error regardless of nesting depth. As of v0.27.0 the op-level `(fallback: ...)` trailer is uniform: it contains a raised `execute_skill` child-throw (recursion-guard fire, missing-skill, or any error nested in the child), so `execute_skill(...) -> R (fallback: "...")` reliably degrades on a child failure regardless of nesting depth, and the fired fallback lands in `result.fallbacks[].reason`.
2080
2051
  - **`# Returns: R` is load-bearing for observability.** If the parent binds a child via `-> R` but does not declare `# Returns: R`, the `# Returns:` filter strips `R` from the parent's `final_vars` — and any nested child errors disappear from the parent's MCP-wire response along with it. A composition skill that wants its child's failures observable from *its own* caller must declare the binding (`# Returns: R`) explicitly. Observability of child failure is opt-in, the same way value export is.
2081
2052
 
2082
2053
  ## Forward-reference resolution
2083
2054
 
2084
- Skill references (`inline(skill=...)`, `execute_skill(skill_name=...)`) are validated at compile time but allow forward references with tier-2 advisories — making it possible to author sibling skills together (chicken-and-egg).
2055
+ Skill references (`inline(skill=...)`, `execute_skill(name=...)`) are validated at compile time but allow forward references with tier-2 advisories — making it possible to author sibling skills together (chicken-and-egg).
2085
2056
 
2086
2057
  **Lint behavior:**
2087
2058
  - `unknown-skill-reference` (tier-2) — covers `inline()` and `execute_skill()` with missing targets
2088
2059
  - `deferred-skill-reference` (tier-3 advisory) — teaching message: *"Skill 'X' referenced via `<op>` is not currently in the SkillStore. Will resolve at execute time if the skill exists by then, or throw `SkillNotFoundError` if not. If this is a typo, fix it now; if it's a forward reference, this advisory will clear once you store 'X'."*
2089
2060
 
2090
- **Runtime behavior:** when a deferred reference still can't resolve at execute time, the runtime throws `MissingSkillReferenceError extends OpError` with structured fields (`missingSkillName`, `viaOp` for the op kind, inherited `target` and `opKind`). The error flows through `# OnError:` fallback chain naturally.
2061
+ **Runtime behavior:** when a deferred reference still can't resolve at execute time, the runtime throws `MissingSkillReferenceError extends OpError` with structured fields (`missingSkillName`, `viaOp` for the op kind, inherited `target` and `opKind`). The error is containable by a target-level `else:` handler (or an op-level `(fallback: ...)`) naturally.
2091
2062
 
2092
2063
  **Stronger contracts kept tier-1:**
2093
- - `# OnError: <missing>` — error-handler missing-at-runtime is the worst possible UX moment to discover a missing reference; explicit at compile is the right call.
2094
2064
  - `disabled-skill-reference` — pointing at a Disabled skill is a stronger contract than "missing yet to be authored"; explicit at compile.
2095
2065
 
2096
2066
  ## When to use composition vs other primitives
2097
2067
 
2098
2068
  Three distinct cases that look similar but have different intents:
2099
2069
 
2100
- 1. **Get a value back from another skill.** Use `execute_skill(skill_name="...") -> RESULT` and use `${RESULT}` locally — reaching declared returns (`${RESULT.X}`) or the output stream (`${RESULT.outputs.text}`). This is the composition primitive case.
2070
+ 1. **Get a value back from another skill.** Use `execute_skill(name="...") -> RESULT` and use `${RESULT}` locally — reaching declared returns (`${RESULT.X}`) or the output stream (`${RESULT.outputs.text}`). This is the composition primitive case.
2101
2071
 
2102
2072
  2. **Delegate work to an agent as a task.** Use `# Output: template: <agent>` to route a compiled artifact through AgentConnector. The receiving agent acts on the prompt. *This is the Template-skill story* — uses compile-as-delivery, not execute-and-bind.
2103
2073
 
@@ -2125,7 +2095,7 @@ default: greet
2125
2095
  # Status: Approved
2126
2096
 
2127
2097
  call_greeting:
2128
- execute_skill(skill_name="greeting") -> GREETING_RESULT
2098
+ execute_skill(name="greeting") -> GREETING_RESULT
2129
2099
  emit(text="Greeting skill said: ${GREETING_RESULT.outputs.text}")
2130
2100
 
2131
2101
  default: call_greeting
@@ -2154,7 +2124,7 @@ default: classify
2154
2124
  # Vars: INPUT="some text"
2155
2125
 
2156
2126
  call_with_inputs:
2157
- execute_skill(skill_name="classifier", inputs={"TEXT": "${INPUT}"}) -> R
2127
+ execute_skill(name="classifier", inputs={"TEXT": "${INPUT}"}) -> R
2158
2128
  emit(text="Classified as: ${R.VERDICT}")
2159
2129
 
2160
2130
  default: call_with_inputs
@@ -2167,17 +2137,19 @@ default: call_with_inputs
2167
2137
  # Status: Approved
2168
2138
 
2169
2139
  call_maybe_missing:
2170
- execute_skill(skill_name="might-not-exist") -> RESULT (fallback: "child unavailable")
2140
+ execute_skill(name="might-not-exist") -> RESULT (fallback: "child unavailable")
2171
2141
  emit(text="Result: ${RESULT.outputs.text}")
2172
2142
 
2173
2143
  default: call_maybe_missing
2174
2144
  ```
2175
2145
 
2146
+ As of v0.27.0 the `(fallback:)` trailer is uniform, so it contains an `execute_skill` child-throw (missing-skill, recursion-guard fire, or any error raised inside the child) as well as a missing/empty bind — `RESULT` degrades to `"child unavailable"` on any of those, and the fired fallback is recorded in `result.fallbacks[].reason`.
2147
+
2176
2148
  **TestFlight preview (from the runtime caller, not from inside a skill):**
2177
2149
 
2178
2150
  ```
2179
2151
  execute_skill({
2180
- skill_name: "parent",
2152
+ name: "parent",
2181
2153
  mechanical: true
2182
2154
  })
2183
2155
  ```
@@ -2195,7 +2167,7 @@ For *data skills* (skills marked `# Type: data`), the compile-time inline primit
2195
2167
  - Treat composition as a real cost. Each `execute_skill()` dispatch incurs the child's full execution time + side effects. Don't compose for trivial cases that could be inlined.
2196
2168
  - Declare `# Returns:` when a caller needs structured access to a child's variables. Leave it off for emit-only skills whose consumers read `.outputs.text` — the default-empty filter keeps scratch from propagating.
2197
2169
  - Want a child's failures visible to your caller? Declare the child binding in `# Returns:` — nested errors are filtered out with the binding otherwise (see Error surfacing in composition), and don't trust a top-level `errors: []`.
2198
- - Pair composition with `(fallback: ...)` when the child skill might fail and the parent has a sensible degraded path.
2170
+ - Pair composition with `(fallback: ...)` when the child skill might fail and the parent has a sensible degraded path. As of v0.27.0 the `(fallback:)` catches a raised child throw, not just a missing bind.
2199
2171
  - Use mechanical mode to TestFlight any multi-skill chain before shipping it as a Headless skill on a cron trigger.
2200
2172
  - Forward references work — author sibling skills in any order, validate independently. The tier-2 warning surfaces the deferred-resolution path; runtime catches genuine misses.
2201
2173
  - Recursion is legal but bounded. If your design requires deeper recursion than the configured limit, reshape the workflow — almost always a sign of an iteration that should be expressed as `foreach` rather than recursion.
@@ -2324,7 +2296,6 @@ Normal `prompt` / `prose` compilation ignores the `# Tests:` section entirely
2324
2296
  ### Runtime assertions (for `format: "test"` execution)
2325
2297
 
2326
2298
  - `target_else_executed: "<target_name>"` — verifies the `else:` branch ran
2327
- - `onerror_invoked: "<fallback_skill>"` — verifies the `# OnError:` skill was called
2328
2299
  - `op_fallback_used: "<target.op_index>"` — verifies an op-level fallback value was substituted
2329
2300
  - `result_value: "<expected_string>"` — the skill's final output value
2330
2301
 
@@ -2567,12 +2538,12 @@ Scopes: skill-local (persists across fires of this skill, not visible to other s
2567
2538
 
2568
2539
  ## Time as first-class primitives
2569
2540
 
2570
- Currently `$(NOW)` (wall-clock). Planned relative-time primitives:
2541
+ Currently `${NOW}` (wall-clock). Planned relative-time primitives:
2571
2542
 
2572
2543
  ```
2573
- $(SECONDS_SINCE_LAST_USER_MESSAGE)
2574
- $(MINUTES_SINCE_SESSION_START)
2575
- $(SECONDS_SINCE_LAST_FIRE_OF.<skill-name>)
2544
+ ${SECONDS_SINCE_LAST_USER_MESSAGE}
2545
+ ${MINUTES_SINCE_SESSION_START}
2546
+ ${SECONDS_SINCE_LAST_FIRE_OF.<skill-name>}
2576
2547
  ```
2577
2548
 
2578
2549
  Most "right time" reasoning is relative, not wall-clock.
@@ -2586,7 +2557,7 @@ Most "right time" reasoning is relative, not wall-clock.
2586
2557
  - **Cross-skill pub-sub** — `# Publishes: signal.X` / `# Subscribes: signal.Y` decoupling
2587
2558
  - **Confidence/threshold gating** — `# RequiresConfidence: classifier >= 0.8` / `# RequiresThreshold:`
2588
2559
  - **Invocation-control axis** — `# Invocable-By: user | agent | trigger` (sensitive ops shouldn't leak across invocation boundaries)
2589
- - **Introspection primitives** — `$(PROMPT_CONTEXT.size)`, `$(SKILLS_FIRED_RECENTLY.last-1h)`, `$(SELF.confidence-trend)`
2560
+ - **Introspection primitives** — `${PROMPT_CONTEXT.size}`, `${SKILLS_FIRED_RECENTLY.last-1h}`, `${SELF.confidence-trend}`
2590
2561
 
2591
2562
  ## When the language extends, this section shrinks
2592
2563
 
@@ -2594,5 +2565,5 @@ When any of these primitives ship, the relevant grammar moves into its canonical
2594
2565
 
2595
2566
  ---
2596
2567
 
2597
- *Rendered from `skillscript/skillscript-language-reference` — 2026-07-06 10:00 EDT*
2568
+ *Rendered from `skillscript/skillscript-language-reference` — 2026-07-09 18:31 EDT*
2598
2569
  *Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*