skillscript-runtime 0.27.0 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/help-content.js +1 -1
- package/docs/adopter-playbook.md +4 -6
- package/docs/configuration.md +1 -0
- package/docs/language-reference.md +125 -122
- package/examples/connectors/DataStoreTemplate/DataStoreTemplate.ts +14 -0
- package/examples/onboarding-scaffold/file-data-store.ts +13 -2
- package/examples/onboarding-scaffold/openai-local-model.ts +3 -1
- package/examples/onboarding-scaffold/tmux-shell-agent-connector.ts +41 -5
- package/examples/skillscripts/classify-support-ticket.skill.provenance.json +10 -0
- package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +10 -0
- package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +10 -0
- package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +10 -0
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/examples/skillscripts/morning-brief.skill.md +5 -6
- package/examples/skillscripts/morning-brief.skill.provenance.json +10 -0
- package/examples/skillscripts/queue-length-monitor.skill.provenance.json +10 -0
- package/examples/skillscripts/service-health-watch.skill.provenance.json +10 -0
- package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +10 -0
- package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Each release carries an **Upgrade impact:** line (first in its section) so a bump's requirements are visible at a glance. Tags (closed set): **BREAKING** (a manual change is needed to keep working) · **RE-APPROVE** (secured-mode signature invalidation — skills must be re-approved before they run) · **CONFIG** (`connectors.json` / config edit needed) · **none (additive)** (no action; backward-compatible). Standard from 0.20.0 forward; the pre-0.20 transitions that need action are flagged inline below (0.14.0, 0.18.8, 0.19.0). Full walkthrough: [UPGRADING.md](UPGRADING.md).
|
|
4
4
|
|
|
5
|
+
## 0.27.1 — 2026-07-09 — examples hardening (pre-announcement sweep)
|
|
6
|
+
|
|
7
|
+
**Upgrade impact:** none (additive). Example + help-content fixes; no runtime behavior change.
|
|
8
|
+
|
|
9
|
+
A pre-announcement review ran every shipped example against the current runtime and contracts. Findings, all fixed:
|
|
10
|
+
|
|
11
|
+
- **Four example TypeScript files had drifted off the connector contracts** and no longer compiled — the fork-me `DataStoreTemplate` and the onboarding scaffold's `file-data-store` were missing the v0.13.8 `DataStore.get()`; `file-data-store` / `openai-local-model` / `tmux-shell-agent-connector` used stale feature-flag keys outside the closed per-kind unions; the tmux connector was missing `WakeReceipt.woken` plus the `health_check()` / `request_response()` contract methods. Nothing compiled the example `.ts` files, so the drift was silent — the worst first-copy experience for an adopter forking a template.
|
|
12
|
+
- **`morning-brief` declared `# OnError: morning-brief-degraded`** — a skill that doesn't ship (dangling reference, so the example didn't compile standalone) *and* a mechanism that isn't wired in the runtime. Rewritten to the v0.27.0 pattern it should be teaching: a per-leg `(fallback:)` on every gather leg + a fallback-bound `BRIEF` so the output template always renders, degrading loudly per source.
|
|
13
|
+
- **New `tests/examples-corpus.test.ts`** locks all of this in: every bundled skill **compiles** (with declared inputs), the infra-free skills **execute** end-to-end against the bundled bootstrap, the programmatic trace demo **runs**, and every example `.ts` **typechecks** against the current contracts — the fork-template-drift guard that was missing.
|
|
14
|
+
- **`help({topic:"composition"})` recursion default corrected** — said "depth-5"; the runtime's `DEFAULT_MAX_RECURSION_DEPTH` is 10.
|
|
15
|
+
- **Docs pass (pre-announcement).** `language-reference.md` re-rendered from the canonical atoms (v0.27.0 uniform `(fallback:)` semantics; `# OnError:` flagged parsed-but-not-wired throughout). `adopter-playbook.md`: the retired `??`/`ask()` mutation-authorization path removed (three mentions; retired in v0.16.0) and the `(fallback:)` trailer section updated to the uniform op list. `configuration.md`: `SKILLSCRIPT_SECRET_<NAME>` added to the env-var reference (the one runtime env var missing from the table). `adopter-agent-guide.md`: `help` topic lists updated to the current 7-topic set (incl. `error-handling`). `index.md`: examples bullet no longer advertises the unwired `# OnError:`. `ARCHITECTURE.md` re-synced to shipped reality (LOC ceiling, file inventory incl. `secrets.ts`, canonical parser grammar, ~77 lint rules, `delete` CLI command, v0.27.0 runtime row) and its AMP doc atoms updated to match.
|
|
16
|
+
|
|
5
17
|
## 0.27.0 — 2026-07-06 — runtime: `(fallback:)` now contains a raised throw (uniform trailer semantics)
|
|
6
18
|
|
|
7
19
|
**Upgrade impact:** none (additive; no re-approval). A `(fallback:)` that previously did nothing against a throw now fires — a skill that used to abort now degrades. No skill breaks; see the one behavior note below.
|
package/dist/help-content.js
CHANGED
|
@@ -820,7 +820,7 @@ Lint \`unknown-returns-ref\` (tier-1) catches names declared in \`# Returns:\` t
|
|
|
820
820
|
|
|
821
821
|
## Limits & lint signals
|
|
822
822
|
|
|
823
|
-
- **Recursion**: depth-
|
|
823
|
+
- **Recursion**: depth-10 chain by default (\`ExecuteSkillRecursionError\` if exceeded).
|
|
824
824
|
- **Lint** (\`unknown-skill-reference\`, tier-2): both \`inline(skill="<name>")\` and \`execute_skill(skill_name="<name>", ...)\` validate the child exists in the SkillStore at compile time. Forward references are allowed: missing skills lint as warning (not error), runtime throws \`MissingSkillReferenceError\` if still unresolved at execute. Tier-3 \`deferred-skill-reference\` advisory confirms when the deferred-resolution path is engaged.
|
|
825
825
|
- **Lint** (\`disabled-skill-reference\`, tier-1): any composition primitive pointing at a \`# Status: Disabled\` skill blocks compile.
|
|
826
826
|
|
package/docs/adopter-playbook.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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` /
|
|
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.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
|
1836
|
+
# Status: Approved v3:<signature>
|
|
1808
1837
|
# Description: ...
|
|
1809
1838
|
```
|
|
1810
1839
|
|
|
1811
|
-
If `# Status:` is omitted, the default
|
|
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:**
|
|
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
|
|
1818
|
-
- **Approved** —
|
|
1819
|
-
- **Disabled** — explicitly off. Compile
|
|
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
|
-
|
|
1850
|
+
## Approval by operator signature (secured mode)
|
|
1826
1851
|
|
|
1827
|
-
The runtime verifies
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1862
|
+
## Compile + runtime behavior (secured mode)
|
|
1842
1863
|
|
|
1843
|
-
| State | Compile | Runtime
|
|
1844
|
-
|
|
1845
|
-
| Draft | warn | refuse (
|
|
1846
|
-
| Approved (
|
|
1847
|
-
| Approved (
|
|
1848
|
-
| Disabled | 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1903
|
+
emit(text="retrieval failed: ${ERROR_CONTEXT.message}")
|
|
1896
1904
|
$set RESULT = ""
|
|
1897
1905
|
```
|
|
1898
1906
|
|
|
1899
|
-
|
|
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
|
-
|
|
1909
|
+
## Layer 2: Skill-level `# OnError:` header — PARSED BUT NOT YET WIRED
|
|
1906
1910
|
|
|
1907
|
-
|
|
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
|
-
|
|
1913
|
+
## Layer 3: Op-level `(fallback:)` value — uniform (v0.27.0+)
|
|
1910
1914
|
|
|
1911
|
-
|
|
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
|
-
|
|
1937
|
-
$
|
|
1938
|
-
$
|
|
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
|
-
|
|
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 permissive — bare identifiers, quoted strings, and bracketed array literals all accepted:
|
|
1945
1925
|
|
|
1946
1926
|
```
|
|
1947
|
-
$ data_read mode=fts query="..." -> RESULTS (fallback: [])
|
|
1948
|
-
$ llm prompt="..." -> VERDICT (fallback: unknown)
|
|
1949
|
-
$ slack.post text="..." -> ACK (fallback: "post failed")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1939
|
+
## Propagation
|
|
1965
1940
|
|
|
1966
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
1962
|
+
**Rule 1 — Fallback 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
|
|
1964
|
+
**Rule 2 — In 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
|
-
|
|
1966
|
+
Throw-proof child pattern (the strongest per-op guard — prevents 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
|
|
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;
|
|
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 (
|
|
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
|
-
**
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
supports_embedding: false,
|
|
48
50
|
},
|
|
49
51
|
};
|
|
50
52
|
}
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// with agents running in tmux sessions can wire `# Output: agent: <agent>`
|
|
6
6
|
// end-to-end against this impl.
|
|
7
7
|
//
|
|
8
|
-
// **Scope.** Implements
|
|
9
|
-
// `
|
|
10
|
-
//
|
|
8
|
+
// **Scope.** Implements the full AgentConnector contract: `deliver()` +
|
|
9
|
+
// `list_agents()` + `wake()` + `agent_status()` + `health_check()` +
|
|
10
|
+
// `request_response()` + `manifest()`. `wake()` degrades to a no-op (tmux
|
|
11
|
+
// panes are always live; wake is for harnesses with sleep modes) and
|
|
12
|
+
// `request_response()` throws not-implemented (send-keys is fire-and-forget).
|
|
11
13
|
|
|
12
14
|
import { spawn } from "node:child_process";
|
|
13
15
|
import type {
|
|
@@ -16,6 +18,8 @@ import type {
|
|
|
16
18
|
AgentStatus,
|
|
17
19
|
DeliveryPayload,
|
|
18
20
|
DeliveryReceipt,
|
|
21
|
+
RequestResponseOpts,
|
|
22
|
+
Response,
|
|
19
23
|
WakeOpts,
|
|
20
24
|
WakeReceipt,
|
|
21
25
|
} from "skillscript-runtime/connectors";
|
|
@@ -36,7 +40,10 @@ export class TmuxShellAgentConnector implements AgentConnector {
|
|
|
36
40
|
connector_type: "agent_connector",
|
|
37
41
|
implementation: "TmuxShellAgentConnector",
|
|
38
42
|
contract_version: "1.0.0",
|
|
39
|
-
|
|
43
|
+
// AgentConnector flags are method-presence shape (one per contract
|
|
44
|
+
// method). `wake` is present but degrades to a no-op — see wake()'s
|
|
45
|
+
// `woken: false` receipt.
|
|
46
|
+
features: { deliver: true, wake: true, list_agents: true, agent_status: true },
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -75,10 +82,13 @@ export class TmuxShellAgentConnector implements AgentConnector {
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
async wake(agent_id: string, _opts?: WakeOpts): Promise<WakeReceipt> {
|
|
78
|
-
// tmux panes are always live
|
|
85
|
+
// tmux panes are always live — there is nothing to wake. `woken: false`
|
|
86
|
+
// is the honest receipt: the substrate performed no wake action (the
|
|
87
|
+
// contract reads false as "degraded to deliver-only").
|
|
79
88
|
const session = this.config.sessionMap[agent_id];
|
|
80
89
|
return {
|
|
81
90
|
woken_at: Date.now(),
|
|
91
|
+
woken: false,
|
|
82
92
|
...(session !== undefined ? { session_id: session } : {}),
|
|
83
93
|
};
|
|
84
94
|
}
|
|
@@ -87,6 +97,32 @@ export class TmuxShellAgentConnector implements AgentConnector {
|
|
|
87
97
|
return this.config.sessionMap[agent_id] !== undefined ? "active" : "unknown";
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Substrate liveness: is the `tmux` binary reachable? Invoked at
|
|
102
|
+
* `Registry.registerAgentConnector()` — a false return refuses the wiring
|
|
103
|
+
* early instead of failing on the first deliver.
|
|
104
|
+
*/
|
|
105
|
+
async health_check(): Promise<boolean> {
|
|
106
|
+
try {
|
|
107
|
+
await this.tmux(["-V"]);
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* tmux send-keys is fire-and-forget — there is no reply channel to
|
|
116
|
+
* correlate a response on. Throw not-implemented per the contract's
|
|
117
|
+
* documented pattern (see NoOpAgentConnector) rather than fake a reply.
|
|
118
|
+
*/
|
|
119
|
+
async request_response(agent_id: string, _payload: DeliveryPayload, _opts: RequestResponseOpts): Promise<Response> {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`TmuxShellAgentConnector: request_response(${agent_id}) not implemented — ` +
|
|
122
|
+
`tmux send-keys has no reply channel. Use deliver() for one-way output.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
async manifest(): Promise<ManifestInfo> {
|
|
91
127
|
return {
|
|
92
128
|
capabilities_version: "1",
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
# Skill: morning-brief
|
|
2
2
|
# Status: Draft
|
|
3
|
-
# Description: Compose a daily morning brief from calendar, mailbox, and overnight notes when the cron trigger fires at 7am. Delivers via the agent: lifecycle hook to the receiving agent, who decides whether to surface to Slack / Discord / etc. Requires a `calendar` connector configured in connectors.json (the dotted `$ calendar.*` form). `model=qwen` is a representative LocalModel alias — adopters register one under whatever name fits (the bundled bootstrap registers `default`).
|
|
3
|
+
# Description: Compose a daily morning brief from calendar, mailbox, and overnight notes when the cron trigger fires at 7am. Delivers via the agent: lifecycle hook to the receiving agent, who decides whether to surface to Slack / Discord / etc. Requires a `calendar` connector configured in connectors.json (the dotted `$ calendar.*` form). `model=qwen` is a representative LocalModel alias — adopters register one under whatever name fits (the bundled bootstrap registers `default`). Every leg carries a per-leg `(fallback:)` so one failed source degrades loudly instead of sinking the whole gather; BRIEF itself is fallback-bound so the output template always renders.
|
|
4
4
|
# Vars: AGENT, BRIEF_HORIZON_HOURS=24
|
|
5
5
|
# Triggers: cron: 0 7 * * *
|
|
6
|
-
# OnError: morning-brief-degraded
|
|
7
6
|
# Output: agent: ${AGENT}
|
|
8
7
|
|
|
9
8
|
${BRIEF}
|
|
10
9
|
|
|
11
10
|
calendar:
|
|
12
|
-
$ calendar.list_events horizon_hours=${BRIEF_HORIZON_HOURS} -> EVENTS
|
|
11
|
+
$ calendar.list_events horizon_hours=${BRIEF_HORIZON_HOURS} -> EVENTS (fallback: "(calendar unavailable)")
|
|
13
12
|
|
|
14
13
|
mailbox:
|
|
15
|
-
$ data_read mode=fts query="messages for ${AGENT}" limit=10 -> MAIL
|
|
14
|
+
$ data_read mode=fts query="messages for ${AGENT}" limit=10 -> MAIL (fallback: "(mailbox unavailable)")
|
|
16
15
|
|
|
17
16
|
overnight:
|
|
18
|
-
$ data_read mode=rerank query="overnight notes and writes" limit=15 -> NOTES
|
|
17
|
+
$ data_read mode=rerank query="overnight notes and writes" limit=15 -> NOTES (fallback: "(overnight notes unavailable)")
|
|
19
18
|
|
|
20
19
|
compose: needs: calendar, mailbox, overnight
|
|
21
|
-
$ llm prompt="Compose a concise morning brief. Calendar: ${EVENTS|json}. Mailbox: ${MAIL|json}. Overnight notes: ${NOTES|json}. Three sections, six bullets max each." model=qwen maxTokens=1200 -> BRIEF
|
|
20
|
+
$ llm prompt="Compose a concise morning brief. Calendar: ${EVENTS|json}. Mailbox: ${MAIL|json}. Overnight notes: ${NOTES|json}. Three sections, six bullets max each. A source marked unavailable should be reported as such, not invented." model=qwen maxTokens=1200 -> BRIEF (fallback: "(morning brief unavailable — compose failed; check the llm connector)")
|
|
22
21
|
|
|
23
22
|
default: compose
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillscript-runtime",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Runtime, compiler, lint, CLI, and dashboard for Skillscript — a small declarative language for authoring agent workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Scott Shwarts <scotts@pobox.com>",
|