skillscript-runtime 0.26.6 → 0.27.1

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 (27) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/help-content.d.ts +1 -1
  3. package/dist/help-content.d.ts.map +1 -1
  4. package/dist/help-content.js +19 -25
  5. package/dist/help-content.js.map +1 -1
  6. package/dist/runtime.d.ts.map +1 -1
  7. package/dist/runtime.js +39 -0
  8. package/dist/runtime.js.map +1 -1
  9. package/docs/adopter-playbook.md +4 -6
  10. package/docs/configuration.md +1 -0
  11. package/docs/language-reference.md +125 -122
  12. package/examples/connectors/DataStoreTemplate/DataStoreTemplate.ts +14 -0
  13. package/examples/onboarding-scaffold/file-data-store.ts +13 -2
  14. package/examples/onboarding-scaffold/openai-local-model.ts +3 -1
  15. package/examples/onboarding-scaffold/tmux-shell-agent-connector.ts +41 -5
  16. package/examples/skillscripts/classify-support-ticket.skill.provenance.json +10 -0
  17. package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +10 -0
  18. package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +10 -0
  19. package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +10 -0
  20. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  21. package/examples/skillscripts/morning-brief.skill.md +5 -6
  22. package/examples/skillscripts/morning-brief.skill.provenance.json +10 -0
  23. package/examples/skillscripts/queue-length-monitor.skill.provenance.json +10 -0
  24. package/examples/skillscripts/service-health-watch.skill.provenance.json +10 -0
  25. package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +10 -0
  26. package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +10 -0
  27. package/package.json +1 -1
@@ -730,7 +730,7 @@ The `run_id` is the runtime's `trace_id` — adopters paste it into the dashboar
730
730
 
731
731
  ### `# Autonomous: true` for event/cron skills doing mutations
732
732
 
733
- Skills fired by cron OR event have **no interactive author** to confirm mutation ops. The runtime's mutation gate (`$ data_write` / `$ skill_write` / `file_write` / mutating MCP tools) requires explicit authorization in non-interactive contexts. Three authorization paths exist:
733
+ Skills fired by cron OR event have **no interactive author** to confirm mutation ops. The runtime's mutation gate (`$ data_write` / `$ skill_write` / `file_write` / mutating MCP tools) requires explicit authorization in non-interactive contexts. Two authorization paths exist:
734
734
 
735
735
  ```
736
736
  # Option A — skill-level: # Autonomous: true (recommended for cron/event skills)
@@ -757,8 +757,6 @@ m:
757
757
  default: m
758
758
  ```
759
759
 
760
- Option C (preceding `??` / `ask()` in same target) is for interactive contexts (CLI / dashboard) and doesn't apply to cron/event fires — there's no user to ask.
761
-
762
760
  Without one of these, the runtime throws `UnconfirmedMutationError` at the mutation op + the skill fails its fire. Symptom in the trace: `class: "UnconfirmedMutationError"` on the offending op.
763
761
 
764
762
  The mutation gate is identical for cron + event sources — both are non-interactive. Lint surfaces `unconfirmed-mutation` as tier-2 warning at compile time so authors get the signal before the first fire.
@@ -892,7 +890,7 @@ Two surfaces, **one emptiness predicate**. Both fire when the upstream value is
892
890
 
893
891
  ### Op-trailer — `(fallback: "value")`
894
892
 
895
- Trails the `-> R` binding on `$` dispatch ops, `shell(...)`, `file_read(...)`. Binds the fallback value to `R` when the op throws OR produces an empty result.
893
+ Trails the `-> R` binding on every fallible op — `$` dispatch (including the `$ json_parse` intrinsic), `shell(...)`, `file_read(...)`, `execute_skill(...)`. Binds the fallback value to `R` when the op fails, whatever the failure shape — a raised throw (an `execute_skill` child-throw, off-shape `json_parse` input) or an empty result — and records the reason in the result's `fallbacks[]`. Uniform across all fallible ops.
896
894
 
897
895
  ```
898
896
  $ ticketing.search query="open" -> ISSUES (fallback: [])
@@ -1198,9 +1196,9 @@ See `examples/custom-bootstrap.example.ts` for a worked walkthrough.
1198
1196
  - **`SqliteDataStore` is a deliberately minimal reference impl.** It satisfies the contract (`query` / `write` / `get` / `staticCapabilities` / `manifest`) with FTS-style tag/text retrieval. It does NOT support semantic retrieval, pinning, decay scoring, or thread-status filtering (the relevant `supports_*` flags are all false). Deployments that need richer query semantics fork `examples/connectors/DataStoreTemplate/` and wire their backing substrate.
1199
1197
  - **SkillStore and DataStore have different lifecycle models — by design.** SkillStore is mutable / versioned / named CRUD (Draft→Approved→Disabled→Delete with audit trail). DataStore is append-only with query/get (no per-record lifecycle in the contract). If you back both onto one substrate, you're serving both lifecycle models at once. Substrates that conflate "data record expiry" with "skill expiry" silently break authored code; the contract doesn't enforce this, you handle it impl-side.
1200
1198
  - **Durability is implementer's responsibility.** The typed contracts assume durable storage. Neither interface declares "writes live forever" — but the runtime + lint + dashboard all behave as if writes persist indefinitely. If your substrate has GC / TTL / decay scoring, build adopter-side guards (pin-rules, retention policies, periodic re-pin sweeps) or pick a substrate posture that satisfies "durable forever." Silent staleness is the failure mode the contract won't catch.
1201
- - **Mutation ops require runtime-enforced authorization.** `$ data_write` / `file_write` / `$ <mutating-name-tool>` (write/update/delete/etc.) fire `UnconfirmedMutationError` at the runtime boundary unless the skill carries `# Autonomous: true` (cron/agent-fired) OR a preceding `??` / `ask(...)` confirms in the same target OR the op carries `approved="reason"` per-op kwarg. This fires regardless of how the skill was invoked — `execute_skill({name})` AND `execute_skill({source})` honor the gate identically; lint stays advisory. Adopters running unattended skills programmatically should set `# Autonomous: true` at the header.
1199
+ - **Mutation ops require runtime-enforced authorization.** `$ data_write` / `file_write` / `$ <mutating-name-tool>` (write/update/delete/etc.) fire `UnconfirmedMutationError` at the runtime boundary unless the skill carries `# Autonomous: true` (cron/agent-fired) OR the op carries `approved="reason"` per-op kwarg. This fires regardless of how the skill was invoked — `execute_skill({name})` AND `execute_skill({source})` honor the gate identically; lint stays advisory. Adopters running unattended skills programmatically should set `# Autonomous: true` at the header.
1202
1200
  - **In-skill writes have asymmetric trust models.** `$ skill_write` lands its child as `# Status: Draft` regardless of body declaration — the bridge forces it. Authoring an executable artifact has unbounded blast radius (the child fires arbitrarily many times in arbitrary contexts); the Draft default keeps autonomously-written skills out of the immediate execution loop. `$ data_write` writes verbatim — one bad data row is bounded blast radius. SkillStore impls receive the body already Draft-stamped; DataStore impls receive entries as authored.
1203
- - **Your `DataStore.write()` is never called if the mutation gate rejects the skill.** The runtime gates `$ data_write` (and other mutation ops) upstream of the bridge — substrates only see authorized writes. If your own probes hit `UnconfirmedMutationError`, that's a skill-body issue (missing `# Autonomous: true` / `??` / `approved=`), not a substrate issue.
1201
+ - **Your `DataStore.write()` is never called if the mutation gate rejects the skill.** The runtime gates `$ data_write` (and other mutation ops) upstream of the bridge — substrates only see authorized writes. If your own probes hit `UnconfirmedMutationError`, that's a skill-body issue (missing `# Autonomous: true` / `approved=`), not a substrate issue.
1204
1202
  - **Filter scope is enforced at the bridge.** `DataStoreMcpConnector` rejects every filter key outside the substrate's declared `manifest().supported_filters` set, throwing `UnsupportedFilterError`. This prevents silent scope leaks where unsupported filters get dropped without the caller knowing. Per-call opt-out: `permissive_filters: true` acknowledges "unknown keys are advisory; substrate may ignore them." Substrate implementors: declare every filter your `query()` actually honors so the bridge validates against your truth, not a guess.
1205
1203
  - **FTS matching strictness varies by substrate.** The `DataStore.query()` contract names the modes (`fts` / `semantic` / `rerank`) but doesn't pin down matching semantics within each mode — token-OR, phrase-tokens, fuzzy, exact, FTS5-syntax-passthrough, etc. are all conformant. The bundled `data-store-roundtrip` demo asserts `N ≥ 1` (a successful round-trip) rather than a specific count, which works across any FTS-supporting substrate. For adopters who need deterministic exact-count reads (round-trip tests, idempotency checks, exact-record-matched fetches), the portable strict-match path is `domain_tags=[...]` filtering — the bridge enforces tag-key against `supported_filters` and substrates declaring `supports_tag_filter: true` honor exact-tag any-of-match per the contract. Use FTS for relevance ranking against open content; use tag filters when you need to be sure you got the specific record you wrote.
1206
1204
  - **Durable-forever opt-in via `expires_at: null`.** `DataWrite.expires_at` accepts a unix timestamp for finite expiry, `null` to opt into "durable forever" (the portable verb for substrates with default TTL — AMP memory vaults, Redis with default expiry, hosted memory APIs), or omitted (substrate's default lifecycle, may be durable or may have decay). Substrates that are durable-by-default (the bundled `SqliteDataStore`) treat `null` as a no-op. Substrates with default sweep should map `null` to their pin / no-decay flag.
@@ -56,6 +56,7 @@ The CLI auto-loads `$SKILLSCRIPT_HOME/.env` at startup and populates `process.en
56
56
  | `SKILLSCRIPT_ENABLE_UNSAFE_SHELL=true` | Permit `shell(unsafe=true)` ops (syntax-scope axis) | env > config > default `false` |
57
57
  | `SKILLSCRIPT_SHELL_ALLOWLIST=curl,git,jq` | Comma-separated list of binaries reachable via `shell(...)` ops (binary-scope axis). **Default-deny** when unset — run `skillfile shell-audit` to discover your corpus's set. Honored on both CLI and programmatic-`bootstrap()` paths; explicit `bootstrap({ shellAllowlist: [...] })` (including `[]` deny-all) wins over env. See [adopter-playbook](adopter-playbook.md) § "Programmatic bootstrap path" for the precedence table. | `bootstrap()` opt > env > config > default-deny |
58
58
  | `SKILLSCRIPT_FS_ALLOWLIST=/srv/work,/var/events` | Comma-separated roots under which `file_read` / `file_write` may operate (path-scope axis). **Default-deny** when unset — every file op refused. Canonicalized before the check, so `..` / symlink escapes are closed. **Keep secret / key directories OUT.** | env > config > default-deny |
59
+ | `SKILLSCRIPT_SECRET_<NAME>=<value>` | Provisions the secret that `{{secret.NAME}}` resolves to (declared via `# Requires: secret.NAME`). Use-only: the value is injected at a sink (`shell` / `$` dispatch) and never binds to a skill variable, a trace, or an error message. The `SKILLSCRIPT_SECRET_` prefix scopes which env is secret-reachable — `{{secret.PATH}}` reads `SKILLSCRIPT_SECRET_PATH`, never `$PATH`. See [adopter-playbook](adopter-playbook.md) § "Secrets". | env (`EnvSecretProvider`; vault providers via `bootstrap({ secretProvider })`) |
59
60
  | `SKILLSCRIPT_SECURED_MODE=true` | Enforce the approval boundary: only Ed25519-signed skills perform effectful ops; unapproved or tampered skills are refused regardless of dispatch path (CLI / cron / `/event` / MCP / composition). Default `false` — a bare `# Status: Approved` is sufficient (unkeyed). See [adopter-playbook](adopter-playbook.md) § "Approval + secured mode". | `bootstrap()` opt > env > config > default `false` |
60
61
  | `SKILLSCRIPT_APPROVAL_KEY_FILE=<path>` | Operator **private** signing key — read only by the approve flow, never on the execution hot path. Default `~/.config/skillscript/approval.key` (deliberately outside `SKILLSCRIPT_HOME`). Auto-provisioned `0600` on first secured-mode start if absent. | env > default |
61
62
  | `SKILLSCRIPT_APPROVAL_PUBLIC_KEY_FILE=<path>` | Operator **public** verification key (non-secret) — read by the runtime to verify signatures on every execution. Default `~/.config/skillscript/approval.pub`. | env > default |
@@ -676,6 +676,16 @@ deliver:
676
676
  | External MCP — substrate-specific | `$ <connector>.<tool>` | `$ <connector>.<tool> kwarg=value, ... [timeout=N] [approved="..."] [-> R] [(fallback: "...")]` | optional |
677
677
  | External MCP — typed-contract | `$ <tool>` | `$ <tool> kwarg=value, ... [timeout=N] [approved="..."] [-> R] [(fallback: "...")]` (typed-contract ops only: `data_*`, `skill_*`, `json_parse`, `llm`) | optional |
678
678
 
679
+ ---
680
+
681
+ ## Current-runtime corrections (v0.27.0)
682
+
683
+ A few points above predate the 0.26/0.27 error-handling changes — read them with these corrections:
684
+
685
+ - **`(fallback:)` is now UNIFORM (v0.27.0).** Beyond `$` dispatch and `shell()`, the op-level `(fallback:)` trailer now also contains a raised throw from `execute_skill` (a child skill that throws) and `$ json_parse` (malformed input). It contains ANY failure of the op — a raised throw OR an empty/missing result — on every fallible op. A fired fallback is recorded in `result.fallbacks[].reason`.
686
+ - **`# OnError:` is parsed but NOT wired** in the current runtime — the named fallback skill never fires. Wherever the text above says an op-error "routes through `else:` / `# OnError:`", read it as `else:` only. Use `else:` (or an op-level `(fallback:)`, or a structural guard) for containment. See the Error handling and Robustness sections.
687
+ - **`execute_skill` canonical kwarg is `name=`** (`skill_name=` is an accepted silent back-compat alias). Prefer `execute_skill(name="...", inputs={...}) -> R` in new skills; the `skill_name=` examples above still work but aren't canonical.
688
+
679
689
  ## Variable resolution — ${VAR} canonical, substitution + ambient refs + # Requires: cascade
680
690
 
681
691
  Skillscript supports four tiers of variables, each with distinct resolution timing and scope. Substitution uses **`${VAR}` as the canonical form**.
@@ -801,6 +811,10 @@ Missing-ref in the RHS produces a tier-1 runtime error.
801
811
  - `foreach IDENT in EXPR:` iterator vars are loop-local — `$set` bindings inside the loop don't persist after the loop ends
802
812
  - Target outputs (`${target.output}`) are accessible after the target completes
803
813
 
814
+ ## Current-runtime correction (v0.27.0)
815
+
816
+ **`${ERROR_CONTEXT}` is surfaced in `else:` error-handler blocks, not `# OnError:`.** The Tier-1 ambient table above describes `${ERROR_CONTEXT}` as available "In `# OnError:` fallback skills" — that is stale. `# OnError:` is parsed but inert (never fires). The working error-handler is the target-level `else:` block (runs when any op in the target body throws), and `${ERROR_CONTEXT}` (failure type + target) is populated inside that `else:` block. Read the table row as: "In an `else:` error-handler: type + target where failure occurred." See the Error handling and Conditionals sections for `else:`-handler semantics.
817
+
804
818
  ## Secrets — secret.NAME references, {{secret.NAME}} sink markers, SKILLSCRIPT_SECRET_ provisioning, use-only enforcement
805
819
 
806
820
  ## Secrets
@@ -1047,6 +1061,13 @@ Filters are pure functions (input → output, no side effects). Stay small and o
1047
1061
 
1048
1062
  `length`, `fallback:`, `isodate`, and `contains:` were all added in response to cold-author harness signal — authored skills demonstrated the gap was load-bearing before each filter shipped. `contains:` is also notable as the first filter to operate on structured types; filter-as-conditional-primitive is the design line that warranted the cross.
1049
1063
 
1064
+ ---
1065
+
1066
+ ## Current-runtime corrections
1067
+
1068
+ - **`|fallback` is 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.
1069
+ - The "Error handling" note above ("Filter chains that fail at runtime … route through `else:` / `# OnError:` machinery") should read **`else:`** — `# OnError:` is parsed but not wired in the current runtime.
1070
+
1050
1071
  ## Conditionals & iteration — if/elif/else, foreach, supported operators
1051
1072
 
1052
1073
  Skillscript supports narrow conditionals and bounded iteration. Both are deliberately constrained — composition over expressiveness.
@@ -1512,6 +1533,10 @@ The receiving agent reads `event_type` for routing ("911 — surface now" vs "ro
1512
1533
 
1513
1534
  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.
1514
1535
 
1536
+ ## Current-runtime correction (v0.27.0)
1537
+
1538
+ **Output routing failures land in `else:`, not `# OnError:`.** The "Output routing failures" section says a throwing AgentConnector "routes through `else:` / `# OnError:`." `# OnError:` is parsed but inert (never fires) — read that as `else:` only. The target-level `else:` handler catches the delivery throw; the failure is still recorded on `agent_delivery_receipts[]` for the scheduler regardless.
1539
+
1515
1540
  ## Connectors — substrate routing, the five connector types, agent_id resolution
1516
1541
 
1517
1542
  The substrate-routing ops (`$ connector.tool`, `$ data_read`, `$ llm`) and the agent-bound `# Output:` kinds (`agent:`, `template:`) don't call any specific backend directly. They route through thin connector interfaces. Skill source persistence follows the same pattern via a dedicated contract. This is the programmable surface through which authors compose information topology per skill and per moment. Skills are portable across substrates because the language doesn't bake substrate identity into the source.
@@ -1796,204 +1821,166 @@ interface McpDispatchCtx {
1796
1821
 
1797
1822
  Hard-coupling skills to specific substrates would make information-flow decisions infrastructural rather than skill-authored, defeating the point of skills as the agent's programming language. The connector layer is what lets the same skill body run against substrate A today and run against substrate B tomorrow without rewriting.
1798
1823
 
1824
+ ## Notation correction
1825
+
1826
+ The "Portable shapes" and "Field access semantics" sections write memory-field access as `$(MEMORY.field)` / `$(M.thread_status)` / `$(MEMORY.metadata.X)`. That is the wrong sigil. Skillscript's canonical substitution form is `${...}` (brace), not `$(...)` (paren) — the paren form is deliberately reserved to avoid collision with bash `$(command)` inside `shell(command=..., unsafe=true)` (see the Variable resolution section). Read every `$(MEMORY.field)` here as `${MEMORY.field}`. The tiered field-access resolution (core → curated subset → `metadata.X` → ambient passthrough) is otherwise accurate; only the sigil is wrong.
1827
+
1799
1828
  ## Lifecycle and status — # Status: header, three canonical states (Draft / Approved / Disabled), compile + runtime enforcement
1800
1829
 
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.
1830
+ 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
1831
 
1803
1832
  ## Header syntax
1804
1833
 
1805
1834
  ```
1806
1835
  # Skill: support-response-draft
1807
- # Status: Approved v1:a1b2c3d4
1836
+ # Status: Approved v3:<signature>
1808
1837
  # Description: ...
1809
1838
  ```
1810
1839
 
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."
1840
+ If `# Status:` is omitted, the default is **Draft** authors must explicitly promote through the lifecycle rather than treating "newly written" as "ready."
1812
1841
 
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).
1842
+ **Case normalization:** the state word is accepted case-insensitively and stored canonical (`draft` / `Draft` / `DRAFT` `Draft`).
1814
1843
 
1815
1844
  ## The three canonical states
1816
1845
 
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
1846
+ - **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.
1847
+ - **Approved** — reviewed and cleared to fire. In secured mode this requires a valid operator **signature** (below). Compile clean; runtime allows; declared triggers fire.
1848
+ - **Disabled** — explicitly off. Compile + runtime reject; triggers don't fire. Source and version history preserved, but the skill cannot execute under any path.
1824
1849
 
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.
1850
+ ## Approval by operator signature (secured mode)
1826
1851
 
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
1852
+ 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
1853
 
1833
- The stamp closes the gate against tampered or Draft-promoted-without-review skill bodies. Three paths produce a stamped Approved skill:
1854
+ Two paths produce a signed Approved skill:
1855
+ 1. **Dashboard approval** — a human reviews and approves; the dashboard signs.
1856
+ 2. **`skillfile approve` CLI** — signs the body operator-side.
1834
1857
 
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.
1858
+ **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
1859
 
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.
1860
+ 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
1861
 
1841
- ## Compile + runtime behavior table
1862
+ ## Compile + runtime behavior (secured mode)
1842
1863
 
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 |
1864
+ | State | Compile | Runtime (effectful) | Default trigger fire |
1865
+ |-------|---------|---------------------|----------------------|
1866
+ | Draft | warn | refuse (author force-run only) | refuse |
1867
+ | Approved (valid signature) | OK | allow | allow |
1868
+ | Approved (unsigned / invalid) | warn | refuse | refuse |
1869
+ | Disabled | refuse | refuse | refuse |
1849
1870
 
1850
1871
  ## Trigger registry interaction
1851
1872
 
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.
1873
+ 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
1874
 
1856
1875
  ## State transitions
1857
1876
 
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.
1877
+ 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
1878
 
1860
1879
  ## Audit trail
1861
1880
 
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.
1881
+ 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
1882
 
1864
1883
  ## States considered but not implemented
1865
1884
 
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.
1885
+ 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
1886
 
1874
1887
  ## Why this matters
1875
1888
 
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.
1889
+ 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
1890
 
1883
1891
  ## Error handling — else: blocks, # OnError: fallback, op-level fallback values
1884
1892
 
1885
- Skillscript provides three layers of error handling, working from local to global.
1893
+ Skillscript has no try/catch error handling is authored. Three mechanisms, local to global. **Note each one's current wiring status.**
1886
1894
 
1887
- ## Layer 1: Target-level `else:` block
1895
+ ## Layer 1: Target-level `else:` block — the reliable throw-container
1888
1896
 
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.
1897
+ 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
1898
 
1891
1899
  ```
1892
1900
  fetch:
1893
1901
  $ data_read mode=fts query=${TOPIC} limit=5 -> RESULT
1894
1902
  else:
1895
- emit(text="retrieval failed, falling back to empty result")
1903
+ emit(text="retrieval failed: ${ERROR_CONTEXT.message}")
1896
1904
  $set RESULT = ""
1897
1905
  ```
1898
1906
 
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
1907
+ 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
1908
 
1905
- The parser's scope-stack discriminates at parse time. Both kinds coexist in the same target.
1909
+ ## Layer 2: Skill-level `# OnError:` header PARSED BUT NOT YET WIRED
1906
1910
 
1907
- ### Constraint
1911
+ `# OnError: <fallback-skill>` is accepted and existence-checked at compile time, but it is **NOT wired in the current runtime — the named fallback skill never fires.** Do not rely on it for containment; use `else:` (or a structural guard) instead. (Tracked: wire it or remove the header in a future release.)
1908
1912
 
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.
1913
+ ## Layer 3: Op-level `(fallback:)` value uniform (v0.27.0+)
1910
1914
 
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.
1914
-
1915
- ```
1916
- # Skill: morning-brief
1917
- # OnError: morning-brief-degraded
1918
- ```
1919
-
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).
1915
+ 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.
1933
1916
 
1934
1917
  ```
1935
1918
  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")
1919
+ shell(command="curl -s wttr.in/${LOC}?format=j1") -> RAW (fallback: "{}")
1920
+ $ json_parse ${RAW} -> W (fallback: "{}")
1921
+ $ llm prompt="Summarize: ${W}" -> SUMMARY (fallback: "summary unavailable")
1940
1922
  ```
1941
1923
 
1942
- Same pattern as the `# Requires:` cascade's `(fallback: ...)` syntax consistent across compile-time (`# Requires:`) and runtime (`$` dispatch and `shell()`).
1943
-
1944
- **Fallback value parsing.** Permissive: bare identifiers, quoted strings, and bracketed array literals all accepted. Matches the `# Requires:` cascade convention.
1924
+ Fallback-value parsing is permissivebare identifiers, quoted strings, and bracketed array literals all accepted:
1945
1925
 
1946
1926
  ```
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
1927
+ $ data_read mode=fts query="..." -> RESULTS (fallback: []) # array literal
1928
+ $ llm prompt="..." -> VERDICT (fallback: unknown) # bare identifier
1929
+ $ slack.post text="..." -> ACK (fallback: "post failed") # quoted string
1950
1930
  ```
1951
1931
 
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.
1953
-
1954
- ## Error propagation rules
1955
-
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)
1932
+ ## Choosing a mechanism
1961
1933
 
1962
- ## Visibility into errors
1934
+ - **`(fallback:)`** "any failure of this op → this default value." Uniform; the per-op opt-out.
1935
+ - **`else:`** — target-level; when you need the error's details (`${ERROR_CONTEXT}`) to branch, or to contain a throw spanning several ops.
1936
+ - **Structural guard** (pre-bind defaults + a `contains`/shape check before the risky op) — prevents the failure entirely; most robust. See *Robustness & error containment* for the discipline.
1937
+ - **`# OnError:`** — not currently wired (Layer 2); don't depend on it.
1963
1938
 
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.
1939
+ ## Propagation
1965
1940
 
1966
- ## The fallback pattern is consistent across scopes
1941
+ - 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[]`).
1942
+ - A fired op-level `(fallback:)` is recorded in `result.fallbacks[]`, not `result.errors[]`.
1943
+ - 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.
1967
1944
 
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
1945
+ ## Robustness & error containment — best practices (no try/catch by design)
1973
1946
 
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.
1947
+ **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
1948
 
1976
- ## Connection to runtime observability
1949
+ **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
1950
 
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[]`.
1951
+ **The containment tools pick by how much you want to handle:**
1952
+ | Tool | Catches | Scope |
1953
+ |---|---|---|
1954
+ | `(fallback: "…")` op trailer | ANY failure of that op — dispatch error / spawn-fail / timeout / empty result / raised throw | the single op |
1955
+ | `$\{ref\\|fallback:"x"}` filter | a missing / unresolved / empty value at use-time | the single reference |
1956
+ | `else:` block | a raised throw anywhere in the target body, WITH error context (`$\{ERROR_CONTEXT.kind/.message/.target}`) | the whole target |
1957
+ | 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 |
1958
+ | `# OnError: <skill>` | INERT in the current runtime (fallbackSkillExecutor never wired) — do NOT rely on it; prefer `else:` | — |
1979
1959
 
1980
- ## Robustness & error containment best practices (no try/catch by design)
1960
+ `(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
1961
 
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.
1962
+ **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
1963
 
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>\")`.
1964
+ **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
1965
 
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.)
1966
+ Throw-proof child pattern (the strongest per-op guardprevents rather than catches):
1967
+ ```
1968
+ fetch:
1969
+ $set AREA = "(weather unavailable)" # degraded default — always bound
1970
+ if ${RAW|contains:"current_condition"}: # guard BEFORE the risky op
1971
+ $ json_parse ${RAW} -> W
1972
+ $set AREA = "${W.nearest_area|fallback:\"(weather unavailable)\"}"
1973
+ default: fetch
1974
+ ```
1975
+ 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
1976
 
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\"`.
1977
+ **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
1978
 
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.
1979
+ **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
1980
 
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.
1981
+ **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
1982
 
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.
1983
+ **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
1984
 
1998
1985
  ## Composition — skills calling skills
1999
1986
 
@@ -2203,6 +2190,18 @@ For *data skills* (skills marked `# Type: data`), the compile-time inline primit
2203
2190
  ## Session isolation — a skill can't mutate the calling agent's session
2204
2191
  A skill executes in its OWN session; agent session state (context, persona) is session-local. Calling `amp_set_session_context` (or similar) inside a skill sets it for the SKILL's session, not the caller's — the caller sees no change. The contract: **skills assemble and RETURN data; the agent owns its SESSION STATE.** To "enter a project," a skill returns the instruction bodies and the AGENT applies its own session context.
2205
2192
 
2193
+ ## Current-runtime corrections (v0.27.0)
2194
+
2195
+ This section predates the v0.27.0 error-containment model. Corrections to how it describes failure handling:
2196
+
2197
+ 1. **`# OnError:` is parsed but INERT — it never fires.** Everywhere this section says a "`# OnError:` fallback fires if declared" (skill resolution, `MissingSkillReferenceError` handling, the deferred-reference runtime path, the fallback-chain in Error surfacing), that is aspirational, not current behavior. The `fallbackSkillExecutor` is declared but never wired. For reliable throw-containment of a child failure, use the target-level `else:` handler (`${ERROR_CONTEXT}` available) or the op-level `(fallback: ...)` trailer. Read every "`# OnError:`" in this section as "`else:`".
2198
+
2199
+ 2. **`(fallback: ...)` is uniform (v0.27.0+).** The op-trailer now contains ANY op failure — a missing/unresolved value OR a raised throw — including an `execute_skill` child-throw (recursion-guard fire, missing-skill, or any error nested in the child). So `execute_skill(...) -> R (fallback: "child unavailable")` reliably degrades on a child failure regardless of nesting depth, and the fired fallback lands in `result.fallbacks[].reason`. This strengthens the "Pair composition with `(fallback: ...)`" guidance: it now works against raised child errors, not just a missing bind.
2200
+
2201
+ 3. **`execute_skill` canonical kwarg is `name=`.** Examples here use `skill_name=`, which remains a back-compat alias and still works. New authoring should prefer `execute_skill(name="child", inputs={...})`.
2202
+
2203
+ The "Nested errors do NOT surface at the top level" observation remains accurate and important — inspect the bound `${R.errors}` or rely on `(fallback:)`/`else:`; a top-level `errors: []` does not mean nothing failed downstream.
2204
+
2206
2205
  ## Static vs Dynamic — skill execution model
2207
2206
 
2208
2207
  Orthogonal to the three skill categories (Headless / Augmenting / Template, which describe the skill's relationship to the frontier agent), every skill has an *execution model* that describes its relationship to the Skillscript runtime.
@@ -2592,7 +2591,11 @@ Most "right time" reasoning is relative, not wall-clock.
2592
2591
 
2593
2592
  When any of these primitives ship, the relevant grammar moves into its canonical section (Ops reference, Variables, Triggers, etc.) and the entry here is removed. This section stays alive as a continuous staging area for the next horizon of unshipped work.
2594
2593
 
2594
+ ## Notation correction
2595
+
2596
+ The planned time-primitive and introspection examples in this section are written with the `$(NAME)` paren sigil (`$(NOW)`, `$(SECONDS_SINCE_LAST_USER_MESSAGE)`, `$(PROMPT_CONTEXT.size)`, etc.). Skillscript's substitution sigil is `${...}` (brace) — the paren form is reserved to avoid bash `$(command)` collision (see Variable resolution). When these primitives ship they will use the brace form (`${NOW}`, `${SECONDS_SINCE_LAST_USER_MESSAGE}`), consistent with the Future-grammar section which already writes them correctly. Read the paren examples here as brace form.
2597
+
2595
2598
  ---
2596
2599
 
2597
- *Rendered from `skillscript/skillscript-language-reference` — 2026-07-06 10:00 EDT*
2600
+ *Rendered from `skillscript/skillscript-language-reference` — 2026-07-09 10:21 EDT*
2598
2601
  *Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*
@@ -165,4 +165,18 @@ export class DataStoreTemplate implements DataStore {
165
165
  // - Return { id, created_at }
166
166
  throw new Error("TODO: write() — persist record; return { id, created_at }.");
167
167
  }
168
+
169
+ /**
170
+ * Direct lookup by substrate-assigned id (v0.13.8 — lets `data_read`
171
+ * inspect a record without a query roundtrip). Return `null` when the id
172
+ * isn't found — do NOT throw; null-on-miss is the DataStore convention
173
+ * (contrast SkillStore, which throws `SkillNotFoundError`). Substrates
174
+ * without a native by-id lookup can implement via a filtered query, but
175
+ * the null semantics must hold.
176
+ */
177
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
178
+ async get(_id: string): Promise<PortableData | null> {
179
+ // TODO — fetch one record by id from your substrate; null when missing.
180
+ throw new Error("TODO: get() — return the record for this id, or null.");
181
+ }
168
182
  }
@@ -5,7 +5,7 @@
5
5
  // their concrete substrate (e.g., swap the JSON file for a Postgres
6
6
  // table, the substring match for actual full-text search, etc.).
7
7
  //
8
- // **Scope.** Implements `query()` + `write()` per the DataStore contract.
8
+ // **Scope.** Implements `query()` + `write()` + `get()` per the DataStore contract.
9
9
 
10
10
  import { readFileSync, existsSync, writeFileSync } from "node:fs";
11
11
  import { randomUUID } from "node:crypto";
@@ -35,7 +35,9 @@ export class FileDataStore implements DataStore {
35
35
  implementation: "FileDataStore",
36
36
  contract_version: "1.0.0",
37
37
  features: {
38
- supports_fts: true,
38
+ // FTS is the baseline query mode — declared via `manifest().supported_modes`,
39
+ // not a feature flag. Flags are the closed `DataStoreFeature` set.
40
+ supports_writes: true,
39
41
  supports_semantic: false,
40
42
  supports_rerank: false,
41
43
  },
@@ -96,11 +98,20 @@ export class FileDataStore implements DataStore {
96
98
  return { id, created_at };
97
99
  }
98
100
 
101
+ /**
102
+ * Direct lookup by id (v0.13.8 DataStore contract). Null-on-miss —
103
+ * never throws for an unknown id.
104
+ */
105
+ async get(id: string): Promise<PortableData | null> {
106
+ return this.loadFile().find((r) => r.id === id) ?? null;
107
+ }
108
+
99
109
  async manifest(): Promise<ManifestInfo> {
100
110
  return {
101
111
  capabilities_version: "1",
102
112
  manifest: {
103
113
  kind: "file-data-store",
114
+ supported_modes: ["fts"],
104
115
  file_path: this.config.filePath,
105
116
  record_count: this.loadFile().length,
106
117
  supports_write: true,
@@ -43,8 +43,10 @@ export class OpenAILocalModel implements LocalModel {
43
43
  implementation: "OpenAILocalModel",
44
44
  contract_version: "1.0.0",
45
45
  features: {
46
+ supports_max_tokens: true,
47
+ supports_timeout: false,
46
48
  supports_streaming: false,
47
- supports_token_count: false,
49
+ supports_embedding: false,
48
50
  },
49
51
  };
50
52
  }