Imported from alliecatowo/shoal (
plugin/skills/shoal/SKILL.md). Install upstream withnpx skills add alliecatowo/shoal --skill shoal. Copyright stays with the author.
shoal — the language card
shoal is a typed value graph over one session kernel, not a text-stream router. You never pipe
bytes between processes and re-parse them; you get back structured values with addressable refs,
and you drill into them by field path. This card is derived from the stable Zola sources under
site/content/docs/ and site/content/internals/, the 1,310-case corpus across 77 suites at
spec/cases/*.toml, and the current shoal-mcp/shoal-proto/shoal-kernel source. When prose and
the corpus disagree, the corpus is the behavioral authority.
The one rule above all others: never parse shoal's own rendered text. Every value you need is
already structured on the wire. Reach for structuredContent / value.get / shoal_get, never for
content[0].text or the human render string.
Current surface snapshot. The MCP facade ships 13 tools, including six PTY tools; resources, templates, reads, and subscriptions are live.
resources/unsubscribeis acknowledged but does not stop its dedicated forwarding connection/thread; cleanup is scoped to MCP process exit.user.*channels bridge in both directions; background cancellation and timeout-to-task conversion work; and render/text previews are capped at 64 KiB. Kernel autostart is on unless a non-emptySHOAL_NO_AUTOSTARTdisables it. Scoped child processes use the strongest available filesystem sandbox (Landlock on Linux, Seatbelt on macOS), while unsupported dimensions are reported rather than implied. Spawn-hash pinning is enforced only when the principal has opted into a non-emptyproc_spawnallowlist. CAS-backed captures expose recoverableval:blake3:…refs.
0. How you talk to shoal
You do not have a bash tool here. You have 13 MCP tools: seven structured-execution tools and
six interactive-PTY tools. They forward to a shoal-kernel over newline-delimited JSON-RPC 2.0 on
a Unix socket. MCP resources are the read/subscribe side of the same session.
The bridge probes the socket and autostarts a detached shoal-kernel by default. It reuses a
live listener and waits up to about five seconds for a new daemon. Set any non-empty
SHOAL_NO_AUTOSTART when a service manager or human owns kernel lifecycle. Autostart still requires
shoal-kernel on PATH; a failed best-effort spawn surfaces as the real connection error.
Every tool result comes back as an MCP tools/call result shaped:
{"content":[{"type":"text","text":"<bounded human render, or pretty JSON when no render exists>"}],
"structuredContent": <the same JSON value, structured>,
"isError": false}
When the result is addressable, content also includes a resource_link item for its shoal://
URI.
Always read structuredContent.value, not render or content[0].text, for data. content[0].text
is a pretty-printed dump of the result for surfaces that only render text; it and the nested render
field are now both size-capped at 64 KiB with a …(N more lines, fetch via <uri>) truncation
marker (see §4 rule 14 — this used to be a real unbounded-wall-of-bytes gap, now closed both at the
MCP boundary and at the kernel wire layer). Headless/MCP renders have ANSI control sequences
stripped before bounding; an actual TTY attachment may preserve them. Either way, render/text
are not reliably field-addressable. Reach for structuredContent.value/shoal_get for anything
you intend to parse or branch on; treat render/text as a human-only preview.
On success, structuredContent is the tool's own result object. On failure (isError: true),
structuredContent is the raw JSON-RPC error object: {"code": <int>, "message": <string>, "data": {...}}.
code here is a JSON-RPC transport code (e.g. -32002), not a shoal language error code — the
shoal error code (type_error, div_zero, ...) lives at data.code for evaluation errors, and is
absent for parse errors. See §5 for the exact table; do not assume data.code is always present.
0.1 shoal_exec — run source, get a ref + a structured value
Params (from the tool's actual JSON Schema, verified in crates/shoal-mcp/src/tools.rs;
additionalProperties: false): {src: string (required), mode?: "run"|"plan", position?: "stmt"|"value", background?: bool, timeout_ms?: int (≥1), elide?: {max_bytes?, max_rows?, max_items?}}. The old dead capture/timeout params are gone — every field above is
forwarded to the kernel and real:
background: true→ the call returns immediately with{"task": "task:<n>", "events": "task.<n>"}(both plain strings); the command keeps running as a kernel task. Cancel withshoal_cancel {task}; watch via thetask.<n>events channel (§0.8).timeout_msdoes not kill anything: a synchronous run that outlives the deadline is converted to a background task and you get back{"task": …, "events": …, "timed_out": true}(verified inhandlers_exec.rs) — the command is still running; treat it exactly like abackground:trueresult.elideis the per-call elision budget (tighten/loosen §1's defaults;max_bytesclamps at the 64 KiB hard cap).- Kernel-side, the wire field for
backgroundis namedasync(serde aliasbackground—shoal-proto'sExecParams), and the kernel'sexecadditionally acceptsplan_refwithmode: "approved"— that mode isplan.apply's re-entry, not a caller-assertable privilege: the kernel verifies the named plan is approved for the calling session/principal and carries the same source before skipping the leash verdict, and the MCP tool neither exposes nor forwardsplan_ref, so through this surface you always goshoal_plan→ (shoal_cap_request) →shoal_apply.
If you omit position, the MCP facade defaults it to "value" (note: this differs from the
raw kernel's own default of "stmt" — the MCP default is the one that matters to you).
What position actually controls (read this carefully — it is the sharpest edge in this
surface): with "value", the kernel executes every statement before the last with ordinary
statement semantics, then evaluates a final expression in value position. A non-OK external command
in that final expression is returned as an inspectable outcome (.ok == false) instead of raised.
An earlier non-OK command still raises. A final declaration/control statement has no special value
reading and uses normal statement evaluation. Language/builtin errors such as div_zero and
index_range raise in either position; they are errors, not failed command outcomes. With
"stmt", every non-OK command raises cmd_failed.
# Captured: the final expression is in value position.
let x = 1
sh { exit 3 }
# Raised before the final expression is reached.
sh { exit 3 }
x + 1
Result (ExecResult): {"ref": "out:<n>", "value": <$-tagged wire value, elided if large>, "render": "<bounded human string>"}.
refis a session-scoped transcript ref like"out:12"— hand this toshoal_getlater. There is always anout:<n>at the top level. A large CAS-backed byte capture can additionally expose a content ref such asval:blake3:<hash>inside the value/render; fetch that throughshoal://val/blake3:<hash>or the corresponding blob path without replaying the command.valueis the real payload,$-tagged, elided per the rule in §1 if large.renderis a human string, size-capped at 64 KiB with a fetch hint on overflow. Headless/MCP sessions have ANSI control sequences stripped before the cap is applied. It is not field-addressable: readvalue, notrender, for data.- A raised error now DOES mint a ref (verified in
handlers_exec.rs): the structured error value is stored in the transcript atout:<n>, and the JSON-RPC error'sdata.ref/data.uripoint at it —shoal_get {ref: data.ref}fetches the full{code, msg, span, hint, stderr}error value after the fact, so a failed call is no longer a dead end.
Worked example (kernel test unix_stream_session_roundtrip, crates/shoal-kernel/src/lib.rs):
// call
{"name":"shoal_exec","arguments":{"src":"[1,2,3]","position":"value"}}
// structuredContent
{"ref":"out:1","value":{"$":"list","v":[{"$":"int","v":1},{"$":"int","v":2},{"$":"int","v":3}]},"render":"[1, 2, 3]"}
Every nested primitive is $-tagged too ({"$":"int","v":1}); expect the tag at every depth.
A command's outcome, corpus-grounded (spec/cases/outcome.toml, case outcome-echo-out, echo hi
evaluated at value position → .out is "hi"; rendered bare it is outcome(status: 0, ok: true)):
{"ref":"out:2","value":{"$":"outcome","status":0,"ok":true,"signal":null,
"out":{"$":"str","v":"hi"},"err":"","dur_ns":123456,"pid":4242,"cmd":"echo hi",
"span":{"start":0,"end":7}},
"render":"outcome(status: 0, ok: true)"}
Spawned command outcomes carry the invocation span. Outcomes synthesized without a source site
(for example a journal reconstruction or some builtin wrappers) omit span rather than fabricating
one.
0.2 shoal_get — drill into a transcript value without re-executing
Params: {ref: string (required), path?: string, slice?: [int, int], elide?: {max_bytes?, max_rows?, max_items?}} (slice is exactly 2 integers). The elide budget is now exposed and
forwarded to the kernel's value.get (verified in crates/shoal-mcp/src/tools.rs) — tighten or
loosen per call; max_bytes still clamps at the 64 KiB hard cap.
Path grammar, exactly as implemented (resolve_value_path in
crates/shoal-kernel/src/wire.rs): dotted field names, bracketed non-negative integer indices, and
half-open bracketed ranges — out[3], rows[0].name, out.status, rows[0..5].
path is always evaluated from the root value bound to ref, never relative to whatever was
already elided — so if .out inside an outcome elided, you still pass path: "out[3]" against the
original ref, not some new sub-ref. Ranges are half-open and clamp to the collection length;
they work on lists and tables. There are no negative indices ([-1] works at the language
level — corpus case list-index-negative — but not inside a value.get/shoal_get path string,
whose indices parse as usize). The separate top-level slice: [start, end] applies after path:
it slices lists and tables by element, strings by Unicode scalar value, and bytes/CAS-backed bytes
by byte. A slice on an unordered or scalar value is an explicit -32005 error.
Result: {"ref": "<ref>", "value": <wire value, elided if large>}. No render field here.
Worked example — drilling into an elided ls result (kernel test
big_table_exec_elides_then_drills_by_path, a real 150-file directory):
// exec: {"src": "ls /some/dir/with/150/files"} (position defaults to "value" via MCP)
// structuredContent.value:
{"$":"outcome","status":0,"ok":true,"out":
{"$":"ref","uri":"shoal://out/2?path=out","of":"table","n":150,
"cols":{"name":"path", "...":"..."},
"preview":{"$":"table","cols":{"...":"first 5 rows..."},"n":5},
"render_head":"name ...\n(first 10 lines)"}, "...":"..."}
// follow-up: {"name":"shoal_get","arguments":{"ref":"out:2","path":"out[3]"}}
// structuredContent:
{"ref":"out:2","value":{"$":"record","v":{"name":{"$":"path","v":"f0003.txt"},"...":"..."}}}
Note the elided out field's embedded uri is shoal://out/2?path=out. This is directly
fetchable via resources/read (§0.8, DONE) — prefer that over the manual translation below, which
remains only as a fallback for the rare URI that still 404s: the part before ?path= gives you the
short ref (out:2), the part after ?path= gives you the path argument to pass to shoal_get
instead.
0.3 shoal_plan — derive effects without spawning anything
Params: {src: string (required)}. Internally forced to mode: "plan", position: "value" — you
cannot change those. Result (PlanResult): {"plan_ref": "plan:<16 hex chars>", "effects": [...], "reversibility": <see below>, "verdict": "allow"|"deny"|"approval_required", "approval_pending": bool}.
reversibility is now a real, computed signal (DONE — verified directly in source). The kernel's
reversibility_from_effects (crates/shoal-kernel/src/lib.rs) derives it from the plan's own
concrete effects rather than trusting shoal-leash's coarser signal or returning the old hard-coded
"unknown": "irreversible" if any effect is opaque (T0/sh{} — unresolvable, so assume the
worst), net_connect, or net_listen; "reversible" otherwise. fs_delete (from rm/mv) is
now correctly classified "reversible", not "irreversible" — shoal's default rm moves files
into a journaled trash and mv's source-clearing step is journaled too, so shoal_apply's effects
fully recover through the journal's undo inverses (UndoInverse::TrashMove/MoveBack/
RestoreBytes); a plain sh { rm -rf ... } is structurally opaque instead (never fs_delete) and
stays "irreversible", correctly, since that path has no trash/undo record at all. Known caveat:
fs_delete carries no field distinguishing shoal's trash-backed rm from a hypothetical
rm --permanent (genuinely irreversible) — the effect type doesn't carry that distinction across the
crate boundary yet, so don't read "reversible" as an ironclad guarantee for every conceivable delete
path, only the default one.
Effects are $-free plain JSON, tagged by a "kind" field (from shoal-leash's Effect enum,
#[serde(tag="kind", rename_all="snake_case")]): fs_read{paths}, fs_write{paths},
fs_delete{paths}, proc_spawn{bin_hash, argv0}, net_connect{host, port}, net_listen{port},
env_read{names}, env_write{names}, secret_use{names}, session_write, journal_read, time,
opaque (T0/sh{}'s ⊤; unresolvable effects, spawns nothing when planned). Grounded directly from
shoal-eval's own test suite (crates/shoal-eval/src/lib.rs):
// {"name":"shoal_plan","arguments":{"src":"git push origin main"}}
{"plan_ref":"plan:8f2c...","verdict":"allow","approval_pending":false,
"reversibility":"irreversible", // net_connect present → irreversible (see note above)
"effects":[{"kind":"fs_read","paths":["/abs/cwd"]},
{"kind":"net_connect","host":"origin","port":443},
{"kind":"proc_spawn","bin_hash":"...","argv0":"git"}]}
With the shipped default (maximally permissive) kernel policy, verdict is almost always "allow" —
you will only see "deny"/"approval_required" if the kernel was started with a stricter
--policy file (§ leash, below). Planning never spawns anything — sh { touch marker } planned
produces effects: [{"kind":"opaque"}] and the file is never created (shoal-eval test
planning_unknown_and_sh_are_opaque_and_spawn_nothing).
0.4 shoal_apply — execute a previously derived plan
Params: {plan_ref: string (required)}. Re-runs the exact original source that produced that
plan, as the same principal, in the same session. The kernel validates the stored plan's
session/principal/source binding and requires either its recorded approval (auto-allow or
shoal_cap_request) or a currently allow policy verdict; the internal mode: "approved" cannot
be asserted directly to bypass those checks. Result: identical shape to shoal_exec's
({ref, value, render}). Fails with a JSON-RPC error if the plan_ref is unknown, belongs to a
different session/principal, or is still approval_pending.
0.5 shoal_journal — query what already happened
Params (tool schema, additionalProperties: false): {since?: int, until?: int, principal?: string, ok?: bool, effects?: string[], head?: string, limit?: int (>=1)}.
Every filter above is now real (verified: the schema in crates/shoal-mcp/src/tools.rs exposes
all seven and forwards them verbatim; the kernel's JournalQueryParams has since, until, principal, head, ok, effects, limit). until is an upper time bound (ns since epoch, filtered
kernel-side); ok filters by success; effects keeps only entries whose effect set contains
every listed effect kind (e.g. ["fs_write"] — a kernel-side post-filter). The old
schema/kernel mismatch (until/effects dropped, ok unpassable) is fixed.
Result: an array of journal entries: {id, session, principal, ts, dur_ns, cwd, src, ast, effects, status, ok, opaque, outputs: [{kind, hash, len}]} — one row per past exec. Two budget
notes: each entry carries the full canonical AST (ast), so rows are heavy — keep limit small
and filter server-side rather than paging everything into context. head compares the first
whitespace-separated word of the raw src against your string — it usefully selects command
statements (head: "git" matches every git ... invocation) but nothing structural (a let-headed
src has head "let"). This is how you answer "what actually ran" without re-executing or scraping a
transcript.
0.6 shoal_cap_request — unstick a plan awaiting approval
Params: {plan_ref: string (required), effects?: array}. effects now genuinely scopes the
grant (updated — verified in handle_cap_request, crates/shoal-kernel/src/handlers_task.rs): if
you name effect kinds (strings, or {kind: ...} objects), the plan is only approved when the
request covers every effect the plan needs; otherwise you get back {"grant": "approval_pending", "why": "requested effect scope does not cover the plan", "uncovered_effects": [...]} and the plan stays pending — an approval can never silently widen past what was asked for.
An empty/omitted effects approves the whole plan. Result on success:
{"grant":"approved","plan_ref":"...","enforced":<bool>,"granted_effects":[...]}. enforced
uses the same honest host/principal-specific truth as session.attach: it is true only when a real
Landlock/Seatbelt backend exists and this principal resolves to a scoped sandbox. Use this only after a
shoal_plan/shoal_exec came back approval_required/approval_pending; call shoal_apply
afterward to actually run it.
0.7 shoal_cancel — stop a running/background task
Params: {task: string (required)} — a task ref like "task:7" (verified in
crates/shoal-mcp/src/tools.rs's tools(); forwards to the kernel's task.cancel,
additionalProperties: false).
The whole background loop is now reachable through this plugin: shoal_exec {background: true} (or
a timeout_ms conversion — §0.1) hands you {"task": "task:<n>", "events": "task.<n>"}; watch the
task.<n> channel (§0.8) for started and then a terminal completed/failed/cancelled record
carrying the result ref; shoal_cancel {task} requests cancellation. Note task.suspend is still
unimplemented (always -32020, even over raw JSON-RPC).
0.8 MCP resources — fetch, browse, and subscribe
Facade::handle() (crates/shoal-mcp/src/lib.rs) dispatches resources/list, resources/read, and
resources/subscribe, and initialize advertises capabilities.resources.subscribe = true —
confirmed against source and by the live e2e test
crates/shoal-mcp/tests/live_kernel.rs. Every elided value's embedded shoal://... uri is a live
fetch target, not a dead end. §4 rule 15 keeps the manual shoal_get+URI-translation fallback
documented anyway — it still works and is your escape hatch if a particular URI 404s.
resources/listenumerates the stable rootsshoal://journal,shoal://jobs,shoal://session/cwd,shoal://session/env,shoal://session/reef, andshoal://pty, plus this session's open tasks, stored plans, and open PTYs. It does not enumerate recentout:ntranscript values — those are only fetchable by URI if you already have one (from anExecResult's elidedRefor a prior call), never discoverable by listing. Don't expect to browse your way to an arbitrary pastout:n.resources/read {uri}on a value URI (e.g.shoal://out/12?path=.rows[3].name) returnsstructuredContent— the$-tagged (or further-elided) value at that path/slice, without re-executing anything. This is the primary way to drill into an elidedRef— prefer it over the §0.2/§4-rule-15 manualref+pathtranslation.resources/templates/listadvertises parameterized transcript values, CAS values, tasks/task output, plans, session views, PTY screens, journal queries, and event channels.resources/subscribe {uri}onshoal://events/{channel}orshoal://task/{id}[/out]starts a push subscription; the server sendsnotifications/resources/updatedwith{uri, seq, payload}as events occur. Never poll a resource you could instead subscribe to.resources/unsubscribe {uri}currently returns success without stopping the dedicated kernel connection or forwarding thread created byresources/subscribe. Subscribe once per URI in a long-lived facade; cleanup occurs when the MCP process exits.- The language-channel→kernel-bus bridge now works, both directions,
user.*-scoped (fixed — this card previously and wrongly called this a gap). Verified live: an in-languagechannel("user.x").emit(v)(evaluated insidesrc) is forwarded to the kernel's wire bus and does reach aresources/subscribeonshoal://events/user.x(crates/shoal-kernel/src/ session.rs'sset_event_forwarder); the reverse direction also works — a wireevents.publishonuser.xis mirrored back into that session's in-languagechannel("user.x")(crates/shoal-kernel/src/eventbus.rs'shandle_events_publish→lang_bus.inject). Onlyuser.*channels cross in either direction — kernel-owned semantic channels (task.*,session.transcript,journal,approval) stay kernel-only and are not writable from language code. Cross-principal signaling viauser.*channels is a real, working substrate now, not a gap to route around. - Query params on any value-bearing URI:
?path=<fieldpath>&slice=<a>..<b>&format=json|render|raw. Paths accept fields, non-negative indices, and half-open[a..b]ranges; the separateslicequery is also half-open. Negative indices are not accepted here because both forms ultimately use unsigned bounds.
Resources are your preferred path for drilling into elided values and for subscribing to
task.{id}/session.transcript/journal/user.* channels instead of re-calling
shoal_journal/shoal_get in a loop — polling a tool result is always wrong here.
0.9 Interactive PTYs — real terminal programs without raw escape-byte walls
shoal_exec is a headless structured evaluation path. Use these six tools when the program's
terminal behavior is the task: an editor, installer, debugger, REPL, password prompt, or full-screen
TUI.
shoal_pty_open {cmd, args?, cols?, rows?, env?}starts a real PTY in the session cwd and environment, layering any string-valuedenvoverrides. Dimensions default to 80×24 and are clamped to 1…1000. The result is{pty_id, pid, cols, rows, cmd}.shoal_pty_read {pty_id}returns{screen, cursor, changed, alive, exit, ...}.screenis an array of rendered text rows bounded by the terminal grid. It never contains a raw ANSI stream.shoal_pty_send {pty_id, input}accepts a literal string, an object with one ofkey,text, or base64bytes, or an array mixing those forms. Named keys include Enter, Tab, Escape, Backspace, Delete, arrows, Home/End, PageUp/PageDown, F1–F12, andCtrl-<letter>.shoal_pty_resize {pty_id, cols, rows}updates the child window size and emulator grid.shoal_pty_list {}recovers this session's open PTYs without returning every screen. The same list is readable atshoal://pty;shoal://pty/{id}reads one screen.shoal_pty_close {pty_id}terminates and reaps the child. Always close a PTY you no longer need.
A good edit workflow is:
{"name":"shoal_pty_open","arguments":{"cmd":"vim","args":["note.txt"],"cols":100,"rows":30}}
{"name":"shoal_pty_send","arguments":{"pty_id":"pty:1","input":["i","hello",{"key":"Escape"},":wq",{"key":"Enter"}]}}
{"name":"shoal_pty_read","arguments":{"pty_id":"pty:1"}}
{"name":"shoal_pty_close","arguments":{"pty_id":"pty:1"}}
PTY IDs are session-scoped; another session sees an opaque unknown-PTY error. pty.open goes
through the same conditional spawn-pin gate and Leash filesystem sandbox lowering as other external
spawns. It is not a bypass around approval or confinement.
1. The 60-second model
- Everything is a typed value. Numbers, strings, lists, records, tables, paths, durations,
sizes, outcomes, errors — every type in the stable language contract renders unambiguously and never
degrades to "just text." A
tableislist<record>, structurally. - Composition is the dot-chain, not the pipe.
ls.where(.size > 1mb).map(.name)— no|anywhere, ever, outsidesh { }or amatchalternation pattern. - Commands are values too. Running
git statusproduces anoutcomevalue ({status, ok, out, err, dur, pid, cmd}); an unknown field/method on an outcome forwards to.out, sogit_log.subjectreads a field of the parsed log row, not a string you'd need to regex. (Qualification, verified against the binary: a few builtins return a bare value instead of an outcome —pwdyields apathdirectly. And an outcome's stderr accessor isbytes, notstr—.str()it first.) fnIS a command.fn deploy(env: str, dry: bool = false) { ... }is immediately callable asdeploy staging --dry— no separate "make this a CLI" step.- No ambient ("invisible") state.
cwd/envare explicit session state, mutated only at session top level (never inside afnbody) or scoped dynamically withwith cwd:/with env: { ... }, which always restores on exit — including through an error. - No truthiness, ever.
if/&&/||accept onlyboolor a commandoutcome(success = true). Everything else in a condition position is atype_error. - Every exec result gets a transcript ref. Large values arrive elided (shape + small preview +
fetch URI); you fetch more with
shoal_get, surgically, never by re-running the command. Plan, journal, task, and PTY control results use their own documented identities/shapes.
2. Translating from bash
Every method named below is pinned by the intercrate and language contracts plus the central method registry.
Rows marked (corpus) have a direct, exact spec/cases/*.toml example — check the named case
yourself if you want the ground truth. Unmarked rows use a pinned-but-not-individually-corpus-exercised
method; treat the signature as authoritative per site/content/internals/intercrate-protocol-contracts.md but verify empirically if a call surprises you.
| bash | shoal | why / grounding |
|---|---|---|
ls | grep x |
ls.where(.name.contains("x")) |
| is a hard parse error with a teaching message, verified against the binary: "shoal has no pipe operator", hint "data composes with . (try ls.where(.size > 1mb)); raw byte plumbing is .feed(cmd); verbatim POSIX lives in sh { … }" (site/content/internals/language-conformance-contract.md; corpus literals.toml:parse-pipe-teaching). The same curated error now fires in infix EXPR positions too — 1 | 2 and let c = a | b teach identically (verified), not just command-position pipes. |
grep ERROR file |
path("file").read.lines().where(.contains("ERROR")) |
.read reads the file as a str (a field-reachable path accessor, site/content/internals/intercrate-protocol-contracts.md — there is no .read_str(); that spelling is field_missing, verified against the binary). .lines() (corpus strings.toml:str-lines-strips-crlf); substring test via in is (corpus operators.toml:op-in-string-substring, "ell" in "hello" → true) — prefer "ERROR" in line over .contains if you want a corpus-nailed-down spelling. |
$VAR, $HOME |
env.VAR |
$ is illegal everywhere: "shoal variables have no sigil" (site/content/internals/language-conformance-contract.md; corpus core.toml:parse-dollar, src="$HOME" → parse_error, "no sigil"). Reading: env.NAME or (env NAME).out; writing at session top level: env.NAME = "v" (corpus reef.toml:reef-env-assign-writes-session-env-for-a-child). |
$(cmd) command substitution |
(cmd) |
CMD grammar's arg = ... | "(" expr ")" — a full EXPR embeds as one word/argument; no special substitution syntax needed. A parenthesized command used as a value: (corpus outcome.toml:outcome-echo-out, (echo hi).out → "hi"). |
`cmd` backticks |
(cmd) or sh { cmd } |
Backtick is illegal, error points at sh { }/re"..."/t"..." (site/content/internals/language-conformance-contract.md; corpus core.toml:parse-backtick). |
*.txt glob |
*.txt (bare, CMD position) or glob("*.txt") |
Word containing unquoted */?/[...]/** lexes as a glob literal; expansion happens at the callee, never at the shell (site/content/internals/language-conformance-contract.md). Explicit constructor (corpus literals.toml:lit-glob-constructor-render, glob("*.rs") renders *.rs); unexpanded pattern bound to a glob-typed param (corpus coercion.toml:word-bind-glob-not-expanded). |
| glob matches nothing | (silently an empty list) | Nullglob by construction — never a literal * string (site/content/internals/language-conformance-contract.md); a statement-level lint additionally flags a glob that matched nothing. |
find . -name '*.rs' -size +1M |
ls.where(.size > 1mb) |
This exact phrase is site/content/internals/language-conformance-contract.md's own canonical pipe-replacement example — the size unit is a first-class literal, not a flag to parse (1mb, corpus literals.toml:lit-size-mb-frac). |
cmd > file, cmd >> file |
cmd > file, cmd >> file (kept!) |
Muscle-memory sugar, CMD-mode only, desugars to .save(file)/.append(file) on stdout bytes (site/content/internals/language-conformance-contract.md). The modern, canonical form is calling .save/.append directly: (cmd).save(file). |
cmd < file |
cmd < file (kept) |
Sole stdin sugar; desugars to StdinSpec::File directly (site/content/internals/values-streams-execution.md). No numeric variant, no here-string variant. |
cmd <<EOF ... EOF (heredoc) |
forbidden, permanently — use an interpreter block | Curated parse error, verified against the binary: "shoal has no heredocs", hint "feed a string or multiline literal instead: value.feed(cmd), or use an interpreter block: python { … }" (site/content/internals/values-streams-execution.md). Interpreter blocks are IMPLEMENTED and this is the answer: python { import json; print(json.dumps(...)) }.out runs the program and auto-parses its stdout to a structured value; sh { ... } (site/content/internals/language-conformance-contract.md) and a multiline """...""" literal also work. |
cmd <<< "text" (here-string) |
"text".feed(cmd args…) (works) |
Curated parse error, verified against the binary: "shoal has no here-strings", hint "feed the value instead: "text".feed(cmd)" (site/content/internals/values-streams-execution.md). .feed IS implemented, args and all: "text".feed(grep "foo").out, "text".feed(sort -r).out. Blocks also work: "text".feed(sh { grep foo }) / .feed(jq { … }). |
cmd 2>file, cmd 2>&1, cmd &>file |
forbidden | Curated parse errors, verified against the binary. Glued fd forms (2>file, 2>&1): "shoal has no fd-numbered redirects", hint "stderr is structured — (cmd).stderr, or try { cmd } catch e { e.stderr }; a statement-position PTY run already merges the streams". &>file: "shoal has no stream-merging redirect", hint "capture is structured: (cmd).out / (cmd).stderr; a statement-position PTY run already merges the streams". .stderr is bytes — .str() it before string methods. A live PTY run (statement position) already merges stdout/stderr by construction — honest PTY semantics, not a missing flag. |
cmd1 | cmd2 raw byte plumbing |
value.feed(cmd args…) / cmd.feed(value) |
The one asylum the pipe error names for genuine byte plumbing. IMPLEMENTED, including args/flags: ["b","a","c"].feed(sort -r).out, data.feed(grep "foo").out, {a:1}.feed(jq ".a").out. The inverted cmd.feed(value) form works too. Interpreter/sh blocks are also valid feed targets: .feed(sh { sort -r }), .feed(jq { .a }). |
cmd1 && cmd2, cmd1 || cmd2 |
kept, unchanged | &&/` |
cmd & (background) |
cmd & (kept) |
Desugars to spawn { cmd }, prints a task handle (site/content/internals/language-conformance-contract.md). Over MCP, shoal_exec now exposes background/timeout_ms (verified in tools()'s schema — §0.1), and shoal_cancel (§0.7) stops a task once you have its ref. |
for f in *.txt; do ...; done |
for f in glob("*.txt") { ... } or glob("*.txt").each(f => ...) |
for binds a pattern over any iterable (EBNF "for" pattern "in" expr block); basic range form is (corpus closures.toml:for-loop-break-stops-early, core.toml:for-range-sum). |
while [ cond ]; do ...; done |
while cond { ... } |
Direct — (corpus core.toml:while-basic). cond must be bool/outcome, never a bare list/string (no truthiness). |
if [ -n "$x" ]; then ... fi (truthiness) |
if x.is_empty() { } else { } / if x != null { } |
No truthiness anywhere: if [1] { 1 } is type_error, "no truthiness" (site/content/internals/language-conformance-contract.md; corpus core.toml:no-truthiness). .is_empty() (corpus core.toml:method-is-empty); .is_some()/!= null are named in site/content/internals/language-conformance-contract.md for nullable values (not individually corpus-exercised). |
grep/regex extraction |
.matches(re"..."), .match(re"...") |
(corpus strings.toml:str-matches-regex-all-occurrences, str-match-regex-first-occurrence) — a regex is a tagged literal, re"[0-9]+", compiled once. |
awk '{print $1}' (field split) |
.words()[0] (whitespace) or .split(",")[i] (delimiter) |
.words() splits on whitespace (corpus strings.toml:str-words-splits-on-whitespace); .split(sep) on an explicit delimiter (corpus strings.toml:str-split-on-separator). |
sed 's/foo/bar/g' |
.replace("foo", "bar") or .replace(re"f.o", "bar") |
Replaces all occurrences (corpus strings.toml:str-replace-all-occurrences); the pattern may be a literal str OR a regex ($1/$name in the replacement expand capture groups) (corpus strings-methods-2.toml:str2-replace-regex-*). No first-occurrence-only variant; slice/index manually for that. |
sed -E 's/(a)(b)/\2\1/' (regex capture) |
.replace(re"(a)(b)", "$2$1") |
Capture-group refs use $1/$name, per the regex crate (corpus str2-replace-regex-capture-groups). |
${str:0:7} (substring) |
str.take(7), str.skip(3), str.skip(2).take(3) |
.take/.skip slice a str by char into a substring (not just collections), so fixed-width fields read cleanly — line.take(7) is a git short hash (corpus strings-methods-2.toml:str2-take-slices-by-char, str2-take-skip-compose-for-substring). |
cut -d, -f1 |
row.split(",")[0] or table.map(r => r.split(",")[0]) |
Same .split grounding as above. |
sort |
.sort() (plain) / .sort_by(f) (key function) |
.sort_by is (corpus collections.toml:list-sort-by-key-function, sorts by .len()); plain .sort() is pinned in site/content/internals/intercrate-protocol-contracts.md but not individually corpus-exercised. |
uniq |
.uniq() |
Preserves first-occurrence order, not a sorted dedup (corpus collections.toml:list-uniq-preserves-first-occurrence-order, [3,1,3,2,1].uniq() → [3, 1, 2]). |
wc -l, wc -c |
.lines().len(), .len() |
(corpus core.toml:method-len, strings.toml:str-len-counts-chars). |
awk '{s+=$1} END{print s}' (fold) |
.reduce(0, (acc, x) => acc + x) (alias .fold) |
Left fold — the general aggregation escape hatch when no named op (.sum/.min/.max/.group) fits; empty list returns the init (corpus list-methods-3.toml:lm3-reduce-*). |
awk '{a[$1]++} END{for (k in a) print k, a[k]}' (group-by) |
.group(keyfn) |
Returns a table whose rows are shaped {key, values} — not {items}/{rows}/{group}. Verified against the binary: [1,2,3,4].group(x => x % 2) renders a two-row table with columns key/values ({key: 1, values: [1, 3]}, {key: 0, values: [2, 4]}); g.map(.key) → [1, 0], g.map(.values) → [[1, 3], [2, 4]]. Guessing .items/.rows on a row (or the table) is a silent-looking but loud field_missing — don't guess the field name, it's key/values. |
jq '. + {c:3}' / build an object |
{a:1}.set("c", 3), r.merge(other) |
Records are immutable values: .set(k, v) inserts/replaces one key (keeping position), .merge(other) layers other's keys over the receiver (right wins). No {...spread} grammar and + on records is a type_error — use these (corpus record-table-methods-2.toml:rt2-set-*, rt2-merge-*). Build from pairs: pairs.reduce({}, (acc, kv) => acc.set(kv[0], kv[1])). |
printf '%.2f' x (round) |
x.round(2), x.floor(2), x.ceil(2) |
Round a float to N decimals (N optional, default 0 → nearest integer); ints pass through (corpus numbers-more.toml:num-round-two-decimals). |
$(( x + 1 )) / str↔int |
"42".parse_int() (str→int); "{n}" (int→str) |
.parse_int/.parse_float are pinned in site/content/internals/intercrate-protocol-contracts.md; int→str is plain interpolation — no cast syntax. Verified against the binary: "42".parse_int() → 42; let n = 7; "{n}" → "7". |
find . -type f |
glob("**/*") or ls (non-recursive) |
ls is a builtin returning a table (list) (corpus collections.toml:table-ls-len-counts-entries, table-ls-where-type-then-map-names); ** recurses, dotfiles excluded unless the pattern starts with . (site/content/internals/language-conformance-contract.md). |
xargs |
.each(f) |
(corpus collections.toml:list-each-side-effect-then-void). For "read lines from a file, run a command per line": path("list.txt").read.lines().each(f => rm f) (chains .read→.lines()→.each, all individually grounded methods). |
which cmd |
which cmd (kept, richer) |
Not forensics — returns a full resolution-chain record, not just a path. .name always echoes the query (corpus reef.toml:reef-which-name-field-echoes-query); unresolved tool's .out is null, not an error (corpus reef-which-unresolved-tool-out-is-null); exactly one tool name — which "a" "b" is arg_error (corpus reef-which-arity-error). |
cd dir (permanent) |
cd dir at session top level |
Legal and journaled at session top level; illegal inside a fn body — error names with cwd: as the fix (corpus reef.toml:reef-cd-inside-fn-body-is-illegal, error custom, contains "with cwd:"). |
(cd dir && cmd) (scoped cd) |
with cwd: "dir" { cmd } |
Restores cwd on any exit path, including an error thrown inside the block (corpus reef.toml:reef-cwd-restores-after-with-block, reef-cwd-restores-after-error-inside-with-block, reef-cwd-nested-with-blocks-restore-outer). |
cd - (OLDPWD) |
cd - (kept) |
Round-trips to the previous cwd via a session-scoped OLDPWD, same top-level-only rule as cd dir; erroring custom with "OLDPWD" in the message if nothing has been recorded yet (corpus dir-stack.toml:cd-dash-round-trips-to-previous-dir, cd-dash-without-oldpwd-errors). |
pushd dir / popd / dirs |
same names, kept | A session-scoped directory stack: pushd dir cds and pushes (no-arg pushd swaps the top two instead), popd pops and cds there (custom error, "empty", on an empty stack), dirs returns the stack as a list<path> with the current dir first — all top-level-only, same fn-body restriction as cd (corpus dir-stack.toml:pushd-deepens-the-stack, pushd-popd-round-trips-to-origin, popd-on-empty-stack-errors, pushd-no-arg-swaps-top-two, pushd-inside-fn-body-is-illegal). |
FOO=bar cmd (scoped env) |
FOO=bar cmd (kept) or with env: {FOO: "bar"} { cmd } |
Leading IDENT=word desugars to with env: {NAME: "value"} { cmd } (site/content/internals/language-conformance-contract.md); explicit block form restores after (corpus reef.toml:reef-env-with-block-sets-var-during, reef-env-with-block-restores-after). |
test -f file, [ -f file ] |
path("file").exists / .is_file / .is_dir |
Zero-arg path accessors, field-reachable (site/content/internals/intercrate-protocol-contracts.md's path-accessor list: .read .read_bytes .lines .exists .is_dir .is_file .size .modified). Verified against the binary: path("Cargo.toml").exists → true. |
docker-compose up (hyphenated command) |
^docker-compose up or run("docker-compose", "up") |
Hyphenated identifiers don't lex in EXPR mode; ^ forces CMD parsing and bypasses non-callable shadows and adapters, reaching external/reef resolution. Session functions and aliases remain callable. run is the fully dynamic alternative. |
alias ll='ls -la' |
alias gs = git status |
AST-level partial application: gs extra appends arguments to the stored call node, never text-splices. Positional and flag forwarding are corpus-pinned in desugar.toml and desugar-more.toml; aliases remain callable even as ^gs. |
| undo the last safe mutation | undo / undo 12 / REPL-only undo out[-1] |
The evaluator replays typed journal inverses for trash-backed removal, overwrite restoration, and moves, refusing stale fingerprints. Bare undo selects the newest reversible entry; undo <id> is host-independent. Only the interactive REPL knows the out[n]→journal-entry map and rewrites literal undo out[n]. |
Format & system namespaces
Eight namespaces live as names in the root env (crates/shoal-eval/src/namespaces.rs): json,
yaml, toml, csv, math, os, http, config. Every call below was verified directly
against the binary unless marked otherwise.
json/yaml/toml/csv— each has.parse(str)and.stringify(value):json.parse("[1,2]")→[1, 2];json.stringify({a:1})→'{"a":1}';yaml.parse("a: 1")→{a: 1};toml.parse(path("Cargo.toml").read)→ a record you drill with field access;csv.parse("a,b\n1,2")→ a table (drive it with.where/.map— indexing a table with[0]is atype_error);csv.stringify([{a:1,b:2}])→"a,b\n1,2\n". This replaces mostjq/yqshell-outs.math— functions take/return floats:math.sqrt(144)→12, pluscbrt sin cos tan asin acos atan atan2 ln log10 log2 log exp floor ceil round trunc abs sign pow min max hypot clamp. Constants are plain field reads:math.pi,math.e,math.tau,math.inf,math.nan,math.sqrt2.os— nullary accessors (passing any arg isarg_error):os.platform()→"linux",os.arch()→"x86_64",os.env()→ the environment as a record (os.env().HOME), plusos.pid() os.hostname() os.username() os.cpus() os.uptime().http—http.get(url)/http.delete(url)(no body) andhttp.post(url, body)/http.put(url, body); non-2xx statuses come back as values, not raises (surface read from source, not exercised live in this pass — it does real network IO).config— reads the project'sshoal.toml:config.all()for the whole record,config.get("key"), or plain field projectionconfig.<key>.
3. The complete syntax
3.1 Lexical structure (site/content/internals/language-conformance-contract.md)
Source is UTF-8. The lexer is modal, switching between CMD mode (command word soup) and EXPR
mode (conventional tokens) based purely on grammar position, never runtime state.
- Comments:
#starts a comment only at token start (after whitespace/line-start/opening delimiter);ver#2in CMD mode is one word, notver+ a comment. - Terminators: newline or
;. A statement continues across a newline when the line ends with a binary operator,,, or an unclosed( [ {; when the next line starts with.(chain continuation) orcatch/else; or after a trailing\. - Strings:
"..."interpolating ({expr}embeds any expression; escapes\n \t \r \0 \\ \" \{ \} \u{1F980});'...'raw, zero escapes, cannot contain'. Triple forms"""..."""/'''...'''are multiline with common-leading-whitespace stripped (corpusstrings.toml:str-triple-double-dedent,str-triple-raw-dedent). Interpolation nests:"answer {6 * 7}"→"answer 42"(corpuscore.toml:string-interp);\{ \}escape braces to suppress interpolation entirely (corpusstrings.toml:str-escape-braces-suppress-interp,"a\{b\}"→"a{b}"). - Numbers:
123,1_000_000,0xFF,0o755,0b1010,3.14,1e9— all (corpusliterals.toml). Maximal munch binds a trailing unit into a single literal:- size: decimal
b kb mb gb tb, binarykib mib gib tib— e.g.1kb,4kib(corpuslit-size-kb,lit-size-kib-binary-renders-decimal). All size units render in decimal form in v1 even when constructed with a binary suffix:1kib→1.02kb,4mib→4.19mb(corpuslit-size-kib-4096,lit-size-mib-frac-renders-decimal). - duration:
ns us ms s m h d w— e.g.250ms,30d→ renders4w2d(corpuslit-duration-weeks),1.5h→1h30m(corpuslit-duration-frac-hour). - time:
10:00am,23:15,10:30:15pmlex as one time literal, always rendering 24h, zero-padded (corpusliterals.toml:lit-time-*).
- size: decimal
- Tagged literals:
re"..."compiles aregexvalue, raw semantics inside (corpuslit-regex-render,lit-regex-render-escaped-dot— the backslash survives verbatim).t"..."is the only spelling for an absolute date/datetime —t"2026-07-09T14:00Z"(corpuslit-datetime-render). - Reserved words:
let var fn alias use export return break continue if else match for in while try catch true false null. - Illegal everywhere, with curated diagnostics: a lone
|outsidematchalternation/sh{}(corpuscore.toml:parse-pipe-teaching);$(corpusparse-dollar); backtick (corpusparse-backtick).
CMD-mode word shapes (site/content/internals/language-conformance-contract.md): a word begins ~/, ./, ../, / → path literal
(bytes-backed, ~ expands now); contains unquoted * ? [...] ** → glob literal; matches
--ident(=...)? or -[A-Za-z0-9]+ → flag; is IDENT=rest at head position → env-prefix;
otherwise → bare word, type str. (expr) embeds a full EXPR expression as one argument. >
>> < are redirects; &&/|| chain; trailing & backgrounds; a trailing { opens a thunk (a
literal-brace argument must be quoted).
EXPR-mode: conventional identifiers [A-Za-z_][A-Za-z0-9_]* — no hyphens. - is always
minus. Bare paths/globs don't lex in EXPR mode — use path("...")/glob("...") constructors or
string coercion.
3.2 The two-mode statement dispatch (site/content/internals/language-conformance-contract.md) — read this before writing any multi-line script
For each statement, look at the first token:
- A reserved word → parse that construct (
let,if,for,fn, ...). - A non-identifier (literal,
(,[,{,-,!, a leading.continuation) → EXPR statement. - An identifier
X. Peek one token:- next is
=/a compound-assign → assignment (Xmust be avar). Xis a bound variable in lexical scope → EXPR statement; the rest of the line lexes EXPR (x - 1is subtraction). A stray bare word right after a variable is a parse error hinting^xfor the command reading.- otherwise → COMMAND statement; the rest of the line lexes CMD.
Xresolves: sessionfn/alias→ builtin/adapter → reef/external executable; unresolved = command-not-found with unified did-you-mean. - refinement:
Ximmediately followed by.then an identifier (no whitespace) → EXPR statement, invoke-then-chain desugar:ls.where(...)≡ls().where(...).
- next is
- Escape hatches:
^X ...forces CMD parsing, bypasses a non-callablelet/varshadow, and bypasses adapter dispatch so the external/reef-resolved command receives raw argv. A session function or alias namedXremains callable even when careted.run("name", args...)is the fully dynamic form. Shadowing a resolvable command is legal and linted, never fatal.
3.3 Grammar reference (normative EBNF, site/content/internals/language-conformance-contract.md)
statement = decl | ctrl | command | expr ;
decl = ("let" | "var") pattern [":" type] "=" expr
| "fn" IDENT "(" [params] ")" ["->" type] block
| "alias" IDENT "=" command
| "use" mod_path | "export" decl ;
ctrl = "return" [expr] | "break" | "continue"
| "for" pattern "in" expr block | "while" expr block ;
command = { ENVPREFIX } head { arg } { redirect } ["&"] [trailing] ;
expr = assign ;
assign = lvalue ("=" | "+=" | "-=" | "*=" | "/=") assign | coalesce ;
coalesce = orx { "??" orx } ; orx = andx { "||" andx } ; andx = cmp { "&&" cmp } ;
cmp = rng { ("=="|"!="|"<"|"<="|">"|">="|"in") rng } ; (* non-assoc: no chaining *)
rng = add [ (".." | "..=") add ] ; add = mul { ("+"|"-") mul } ;
mul = unary { ("*"|"/"|"%") unary } ; unary = ("!" | "-") unary | postfix ;
postfix = primary { "." IDENT [call] [trailing] | "?." IDENT [call] | "[" expr "]" | call [trailing] } ;
primary = literal | IDENT | "(" expr ")" | list | rec_or_blk
| lambda | ifx | matchx | tryx | "sh" RAWBLOCK | "spawn" block ;
matchx = "match" expr "{" { arm TERM } "}" ; arm = pat { "|" pat } ["if" expr] "=>" (expr | block) ;
pat = literal | rangepat | "_" | IDENT | type IDENT | "{" fieldpats "}" | "[" listpats "]" ;
tryx = "try" block "catch" [pat] block ;
Precedence, tight → loose: . ?. [] () → unary ! - → * / % → + - → .. ..= →
== != < <= > >= in → && → || → ?? → catch (postfix) → =. Comparisons do not chain —
1 < 2 < 3 is a parse error with a fix-it (corpus core.toml:parse-comparison-chain, operators.toml:op-cmp-chain-le-lt-is-error,
message contains "do not chain").
3.4 Desugaring table (site/content/internals/language-conformance-contract.md — what you write vs. what actually runs)
| Sugar | Canonical |
|---|---|
git push origin main |
call(cmd:"git", [w"push", w"origin", w"main"]) |
NAME=v cmd ... |
with(env: {NAME: "v"}) { call(...) } |
cmd ... & |
spawn { call(...) } |
cmd ... > f / >> f / < f |
.save(f) / .append(f) / stdin-from-file |
f(a) { ... } |
f(a, () => { ... }) |
.field <op> e (arg position) |
x => x.field <op> e |
.method(args) (arg position) |
x => x.method(args) |
IDENT.foo where IDENT resolves to a command |
IDENT().foo (invoke-then-chain) |
e catch h |
try { e } catch { h } |
x?.f |
if x == null { null } else { x.f } (corpus operators.toml:op-safenav-null-short-circuits, op-safenav-nonnull-accesses-field) |
3.5 Types (site/content/internals/language-conformance-contract.md)
null bool int(i64) float(f64) str path glob regex size(u64 bytes) duration(i64 ns) datetime time bytes list<T> record table stream<T> error outcome task plan cmd secret.
pathis bytes-backed (OsString) —path → stris fallible (.str()errors on invalid UTF-8;.display()is lossy-with-replacement).secretis opaque: renderssecret(NAME), cannot be interpolated into astr(type error), injected by the kernel at spawn time — only its name ever reaches the journal or the wire.outcome:{status, ok, out, err, dur, pid, cmd}..outis structurally parsed lazily. Unknown field/method access on an outcome forwards to.out— a real subprocess outcome auto-upgrades[/{-shaped stdout to a structured list/record; a builtin's outcome (likeecho) does not re-parse its own bytes —.outis the builtin's ownValueverbatim, so(echo '[1,2,3]').outstays the string"[1,2,3]", not a list (corpusoutcome.toml:outcome-echo-out-json-list). Don't assume every outcome's.outstructurally parses — it depends on whether the producer was a builtin or an adapter-backed external command. The stderr accessors (.err/.stderr) arebytes, notstr(verified:.lines()on one istype_error: expected str, found bytes— call.str()first).tableislist<record>semantically — every table method is also a list method.- Equality is structural for data types, identity for
task/stream; comparing streams is an error.
3.6 Coercion — the whole matrix (site/content/internals/language-conformance-contract.md), corpus-verified exactly
There are exactly two coercion sites. Everything else is a type_error.
Site 1 — arithmetic promotion (+ - * / %):
| Operands | Result | Notes / corpus |
|---|---|---|
int ⊕ float (either order, all 4 ops) |
float |
coercion.toml:coerce-*-int/coerce-*-float — e.g. 0.5 + 2 → 2.5 |
size ± size |
size |
1.5kb from 2kb - 500b; negative result is type_error, hint "negative" (coerce-size-minus-size-negative-is-error) |
size / size |
float (ratio) |
10kb / 4kb → 2.5 |
size * int, int * size, size / int |
size |
size / int is fractional, not truncating: 10kb / 3 → 3.33kb (coerce-size-div-int-truncates) |
size * float, float * size |
size |
2kb * 1.5 → 3kb — but |
size / float |
type_error |
asymmetric on purpose — multiplying by a float is fine, dividing by one is not (coerce-size-div-float-is-error) |
size ± int (bare, either order) |
type_error |
must be size ± size; 1b + 1 errors (coerce-size-plus-int-is-error) |
size * (negative int), size / (negative int) |
type_error, "negative" |
10kb * -1, 10kb / -1 |
size / 0, size / 0b |
div_zero |
not type_error — zero division is its own code |
size + duration |
type_error |
no cross-unit arithmetic |
duration ± duration |
duration |
2h - 30m → 1h30m |
duration / duration |
number (int if exact, float if not) | 90s / 30s → 3; 90s / 60s → 1.5 |
duration * int/float, int/float * duration |
duration |
30m * 2 → 1h; 1.5 * 30s → 45s |
duration / 0, duration / 0s |
div_zero |
|
duration ± int (bare) |
type_error |
same asymmetry as size |
datetime + duration, duration + datetime |
datetime |
commutative (coerce-duration-plus-datetime-commutative) |
datetime - duration |
datetime |
|
duration - datetime |
type_error |
only one subtraction direction is defined |
datetime - datetime |
duration |
t"...14:00Z" - t"...12:30Z" → 1h30m |
datetime + datetime, datetime * int |
type_error |
|
list + list |
list (concat) |
[1,2] + [3] → [1, 2, 3] |
str + str |
str (concat) |
"a" + "b" → "ab" |
list/str/bool + <mismatched type> |
type_error |
[1,2] + 1, "a" + 1, true + false all error — no arithmetic on bool at all |
Site 2 — word binding (CMD-mode word → declared parameter type, at call-bind): str (identity),
path, glob (compiled pattern, unexpanded), int/float/size/duration/time/datetime (parse;
failure = arg_error), bool (flag presence, not a parsed word — --b present → true), list<T>
(repeated flags/positionals accumulate — variadic ...nums: list<int> sums correctly, corpus
word-bind-list-int-variadic-accumulates). Every one of these is individually corpus-verified in
spec/cases/coercion.toml's word-bind-* cases. Unknown-signature (T0) targets — a raw external
binary with no adapter — receive every word as str, verbatim, always; no coercion is attempted.
Value-carrying flags on user functions accept both --flag=value and --flag value when the
declared parameter is non-bool; a bool flag keeps presence semantics. Excess positionals without
a declared rest parameter and unknown named arguments raise arg_error instead of being dropped.
3.7 Comparisons and logic (site/content/internals/language-conformance-contract.md)
&&/|| admit only bool or a command outcome (success = true) as operands — 1 && true is
type_error (corpus operators.toml:op-and-int-operand-is-error); !5 is type_error
(corpus op-not-int-is-error). They short-circuit and return the deciding operand verbatim,
not a forced bool — chaining stays chainable (.status/.out still reachable on the result).
Comparison operators (< <= > >= == !=) do not chain; mixed-type comparison like "a" < 1 is
type_error (corpus op-cmp-str-lt-int-is-error); same-type comparisons including bool < bool
work (false < true → true).
3.8 Variables, functions, lambdas
letis immutable — reassigning istype_error(corpuscore.toml:let-immutable); shadowing is legal with a lint, never an error.varis mutable, with+= -= *= /=compound assignment (corpusvar-assign,var-compound).fn add(a: int, b: int) { a + b }then callingadd(2, 5)(EXPR call) oradd 2 3(CMD call, word-bound) both work identically — afngenuinely is a command (corpuscore.toml:fn-call,coercion.toml:word-bind-int-positional). Defaults: `fn inc(a: int, by: int =- { a + by }
,inc(4)→5(**corpus**fn-default). To **capture a CMD-form call's result in a binding, parenthesize it**:let x = (deploy staging --dry)(verified against the binary) — the unparenthesizedlet x = deploy staging --dryis a parse error (expected newline or;between statements), because alet` RHS lexes in EXPR mode where bare words don't glue into a command.
- { a + by }
- Lambdas:
x => expror(a, b) => expr/block. (corpuscore.toml:multi-lambda,lambda-call-method). Closures capture the enclosing binding itself (a shared cell, not a copy) — avarmutated by a closure through repeated calls accumulates across calls (corpusclosures.toml:closure-mutates-captured-var-via-each). - Implicit
.field/.methodlambda sugar — in argument position only:.field <op> edesugars tox => x.field <op> e, and.method(args)desugars tox => x.method(args).
Truncated - read the full file at https://github.com/alliecatowo/shoal/blob/4195f6c3218c7bcd14501371190fe17f475bb289/plugin/skills/shoal/SKILL.md.