# Yamlscript

> Write idiomatic YAMLScript code. Use when asked to write, convert, or review YAMLScript (.ys files). Converts Clojure to idiomatic YAMLScript using confirmed style rules and tested examples.

- Skill: `yaml/yamlscript` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add yaml/yamlscript`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yaml/yamlscript/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yaml (https://skillmd.com/u/yaml)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yaml/yamlscript

---


# YAMLScript Skill

## Setup

Ensure `ys` version 0.2.32 is available for testing:

```bash
[[ -x /tmp/ys-skill/bin/ys-0.2.32 ]] ||
  curl -s https://yamlscript.org/install | VERSION=0.2.32 PREFIX=/tmp/ys-skill bash
YS=/tmp/ys-skill/bin/ys
```

Optionally clone the source for looking up stdlib functions, DWIM
support, and docs:

```bash
[[ -d /tmp/ys-skill/yamlscript ]] ||
  git clone --depth 1 https://github.com/yaml/yamlscript \
    /tmp/ys-skill/yamlscript
# Key files:
#   core/src/ys/std.clj   — YS standard library
#   core/src/ys/dwim.clj  — functions with auto arg-placement
#   doc/                  — language documentation
```

## Workflow

1. **Write correct Clojure first** — Clojure is unambiguous; get the logic
   right before worrying about YS syntax
2. **Convert to YAMLScript** — apply the rules below
3. **Test every attempt** before presenting it:
   ```bash
   # Single-line expressions
   $YS -pe 'expr'
   # Multi-line programs
   $YS -c - <<<'!ys-0 ...'
   ```
4. **Iterate** until the output is correct and idiomatic
5. **Lint the source** with the `ys-lint.ys` script that ships next to this
   SKILL.md (same skill directory). Run it against every `.ys` file you
   wrote or edited:
   ```bash
   /path/to/skill/ys-lint.ys FILE...
   ```
   `ys-lint.ys` flags *possible* surface-form mistakes the compiler can't
   see because they vanish at the AST stage: `.nth(N)` vs `.N`,
   `.nth(var)` vs `.$var`, `x + 1` vs `.++`, `x - 1` vs `.--`,
   `.first()` / `.last()` vs `.0` / `.$` (or `:first` / `:last`),
   `vector(...)` and inline `V+(...)` vs `+[...]`, inline `M+(...)`
   vs `+{...}`, avoidable `apply` calls vs direct splats (`f(xs*)` /
   `f: xs*`), `str(bareVar)` vs `bareVar:S`,
   `quot(a b)` and `a.quot(b)` vs `a // b`, `rem(a b)` vs `a % b`,
   parenthesized simple integer-looking divisions such as `(n / d)` vs
   `(n // d)`, `:zero?` vs `.!` when falsey-zero semantics are OK,
   ` or(` / ` and(` calls vs `||` / `&&`,
   any `KW (cond):` test-expression paren-wrap (for `if`, `if-not`,
   `when`, `when-not`, `while`), any `KW [...]:` bracketed binding
   form for the reliably-strippable keywords (`binding`,
   `if-let`/`if-lets`/`if-some`, `let`, `loop`,
   `when-first`/`when-let`/`when-lets`/`when-some`, `with-open`) and
   for the iteration keywords (`each`, `for`, `doseq`, `dotimes`) when
   the binding starts with a named var (not a `_`-var or destructure
   pattern), `then: nil` / `else: nil` vs `when` / `when-not`,
   direct `then: false` under `if` as a candidate for reversed
   `when`, a zero-arg method
   `x.foo()` vs the colon chain `x:foo`, a `=>:` whose value is a call /
   colon-chain / dot-chain / spaced binary op vs a pair form
   (`f: args` / `x: .m(a)` / `a OP: b`), a direct `=>:` child under
   an `if` block vs `then:` / `else:`, `say: ''` vs bare `say:`,
   `x.join(' ')` vs the colon chain `x:joins`,
   `slurp` / `spit` vs `read` / `write`,
   plain-YAML structural checks such as scalar `then:` / `else:`
   branches that can be positional `if` branches,
   a wide `recur:` / `loop` arg list that should be comma-separated,
   and lines over 79 cols.

   Most linter rules match source text with regex; a few strip the
   top `!ys-0` tag, load the file as plain YAML, and inspect the YAML
   shape. Every hit is still a *candidate*, not a verdict. False
   positives are expected: a long line may be a literal task string the
   program can't shorten; an `x - 1` inside a generated string isn't a
   `.--` candidate; an identifier that happens to look like a pattern
   may not be one. Inspect every reported line, fix the real mistakes,
   and explicitly justify each hit you treat as a false positive. This
   step is required: the working program isn't done until you have
   walked every lint hit and either fixed it or accepted it with reason.

## Program Tag

- Always use `!ys-0` — the short idiomatic form
- `!yamlscript/v0` and `!yamlscript/v0/` are legacy — do not use
- `!ys-0` = code mode; `!ys-0:` = data mode

## YS vs Clojure Standard Library

Prefer YS stdlib functions (`ys.std`) over their Clojure equivalents —
they are more powerful and polymorphic (e.g. `reverse` works on strings,
`replace` defaults the replacement to `""`, `rng` works on chars).
If performance is a concern, fall back to the specific Clojure function
for that case.

Most `Math/*` functions are exposed in `ys.std` (`sqrt`, `sqr`,
`floor`, `abs`, `pow`, etc.). Drop the `Math/` prefix when a YS
builtin exists; it's more idiomatic.

## Common Mistakes

Patterns Codex gets wrong most often. Scan these before writing any YS.

### Use `if` for two-branch conditionals — `cond` only for 3+ branches

`cond` is **only** appropriate when there are three or more mutually
exclusive branches. Any time you have a single predicate plus an
`else:` (one real branch and one fallback), use `if` instead. This is
the single most common conditional mistake.

- `cond: x == 0: a / else: b` → `if x == 0: \n  then: a \n  else: b`
- `cond: pred: x / else: recur(...)` → `if pred: \n  then: x \n  else: recur(...)`

`if` can drop the `then:` and `else:` keys when both branches are
*pair-form* children (mapping entries), because YS reads the two
children positionally regardless of their keys. The keys can even
collide:

```
if (n % d) == 0:
  recur: quot(n d) d cnt.++
  recur: n d.++ cnt
```

This also lets the then-branch be a nested `if X:` pair while the
else-branch is an explicit `else:`:

```
if n >= 2:
  if (n % d) == 0:
    recur: ...
    recur: ...
  else: cnt
```

Bare-scalar branches (plain identifiers, calls, expressions) can also
drop `then:` / `else:` when both branches are simple direct scalar
values with no whitespace and neither branch starts with YAML syntax
characters such as quotes, brackets, braces, block-scalar markers, or
tags:

```
if prime?(candidate):
  count.++
  count
```

Prefer this over:

```
if prime?(candidate):
  then: count.++
  else: count
```

The linter flags `{if ...: {then: scalar, else: scalar}}` shapes by
loading the file as plain YAML and checking the branch values, but
only when both scalar branch source values contain no whitespace. This
rule deliberately skips quoted strings and other YAML-special starts.
This is not the same as using `=>:` under `if`, which is never correct.

Bare-scalar branches are still fragile when mixed with mapping entries:
mixing a bare scalar with an `else:` mapping entry is invalid YAML.
When in doubt, keep `then:` and `else:` explicit unless both branches
are pair-form children or both are simple direct scalar branch values
with no whitespace and no YAML-special start.

When both branches are bare scalars, a trailing `+` on the `if` line
folds the next two indented lines into one plain scalar that YS reads
as the remaining positional args of `if`:

```
if n == 1: +
  '1'
  factors(n).join(' x ')
```

Compiles to `(if (= n 1) "1" (str/join " x " (factors n)))`. Use this
when both branches are short bare expressions and the symmetry reads
better than `then:`/`else:` keys.

Inner conditionals nested inside a `cond` clause are usually
two-branch and should be `if`. Before writing `cond:`, count the
clauses: if it's two (one predicate + `else:`), rewrite as `if`. Three
or more clauses (excluding `else:`) keeps `cond`.

Scan every `cond:` in the file before finishing — if it has only one
non-`else:` clause, it's wrong.

### `=>:` only when no pair form works

A YAML mapping context — `defn` body, `do:` block, conditional
branch block, `loop` body, etc. — requires every line to be a
`key: value` pair. `=>:` is the fallback key when the expression
genuinely cannot be written as a pair.

**Use `=>:` for atomic values** (no other pair form exists):
- bare identifiers: `=>: x`, `=>: result`
- bare numeric/literal atoms: `=>: 42`, `=>: nil`, `=>: true`,
  `=>: :foo`
- bare interpolated strings: `=>: "$s$check"`
- bare data-collection literals: `=>: +[1 2 3]`, `=>: +{a: 1}`

**Never use `=>:` as a direct branch key of an `if` construct.**
Even when the branch result is an atom that would normally allow
`=>:`, an `if` branch already has semantic keys. Use `then:` or
`else:` instead:

```
if done?:
  then: result
  else:
    recur: next
```

not:

```
if done?:
  =>: result
  else:
    recur: next
```

**Restructure compound expressions into a pair:**

1. **Function call** → fn-call pair `name: args`
   - `=>: f(a b)` → `f: a b`
   - `=>: vec(out)` → `vec: out`
   - `=>: foo()` → `foo:`
   - `=>: recur(i.++ b nx)` → `recur: i.++ b nx`
   - `=>: V+(re im)` → `V+: re im`
2. **Dot/property or method chain** → chain-pair `receiver: .member`
   - `=>: x.y` → `x: .y`
   - `=>: a.b(c).d(e)` → `a: .b(c).d(e)`
   - `=>: row.assoc(w best)` → `row: .assoc(w best)`
   - `=>: meta.from.split('/wiki/').$` → `meta.from: .split('/wiki/').$`
3. **Binary operator** → op-pair `lhs OP: rhs`
   - `=>: a + b` → `a +: b`
   - `=>: n == psum` → `n ==: psum`
   - `=>: is-thu || is-wed-leap` → `is-thu ||: is-wed-leap`
4. **`cond` default arm**: use `else:` not `=>:`

**Drop single-use indirection**: a `result =: expr` whose only use
is a trailing `=>: result` folds into a single trailing pair:
- `result =: r:sqr == n; =>: result` → `r:sqr ==: n`
- `result =: row.conj(s); =>: result` → `row: .conj(s)`

**Colon-chains must convert to dot-chains in chain-pair position** —
chain-pair only supports a leading `.`:
- `=>: out:V` → `vec: out`
- `=>: stack:pop:pop.conj(x)` → `stack: .pop().pop().conj(x)`

**Op-pair / pair-form quirks**:
- `%:` with a scalar value is the pair form of the remainder operator:
  `a %: b` compiles to `(rem a b)`. With a mapping value, trailing `%`
  is the legacy generic form-map marker instead.
- An operator-pair scalar value must contain exactly one form. A compound
  expression such as `a %: b - c` is one form, but `a %: b c` is invalid.
  Mapping values retain their mapping and form-map semantics.
- A pair value cannot begin with a quoted string followed by more
  args. `format: '%+.4f' x y` fails to parse. Workarounds:
  - Promote the string into the key: `format '%+.4f': x y`
  - Force it into the value with `+`: `format: +'%+.4f' x y`

### Never write `x + 1` or `x - 1` — use `.++` / `.--`

Increment and decrement by 1 are common enough to have their own
postfix operators. Use them anywhere — assignment values, argument
positions, return values, loop bodies, string interpolation:

- `v + 1` → `v.++`
- `v - 1` → `v.--`
- `(3 * v) + 1` → `(3 * v).++`
- `recur: i + 1` → `recur: i.++`

`.++` and `.--` compile to `inc+` / `dec+` (polymorphic). This is the
single most-forgotten rule — scan every `+ 1` and `- 1` before
finishing.

Exception: do not rewrite `f + 1` when `f` is a function and the
expression is a partial application. For example, `(rotate + 1)` means
"a function that calls `rotate` with `1` as its first argument"; it is
not numeric increment and must not become `rotate.++`.

### Never write `.nth(N)` or `.nth(bareVar)` — use `.N` / `.$var`

Index access has terse dot-forms that should be preferred over the
explicit `.nth(...)` call:

- `v.nth(0)` → `v.0`           (literal integer index)
- `v.nth(12)` → `v.12`
- `s.nth(0)` → `s.0`           (works on strings too)
- `parts.nth(2)` → `parts.2`
- `v.nth(i)` → `v.$i`          (bare variable index)
- `v.nth(idx)` → `v.$idx`
- `m.nth(ip)` → `m.$ip`

`.nth(expr)` is only correct when the index is a **computed
expression** — e.g. `v.nth(i.--)`, `v.nth((row * 4) + c)`,
`v.nth(i + g)`. The `.$var` form takes a single bare variable; it
does not accept compound expressions.

Scan every `.nth(` in the file before finishing — if the argument is a
literal integer or a bare variable, rewrite to the dot/dollar form.

### `.first()` / `.last()` — use `.0` / `.$` or `:first` / `:last`

The call form `x.first()` and `x.last()` is verbose. Two terser
alternatives, each with its own niche:

- `.0` / `.$` — positional access. Use when the value is a vector or
  pair and you're thinking "first/last element by index":
  - `pair.first()` → `pair.0`
  - `tuple.last()` → `tuple.$`
  - `sorted.first()` → `sorted.0`
- `:first` / `:last` — colon-chain. Use when the value is a sequence
  and you want the seq operations' "head/tail" framing:
  - `tri.last()` → `tri:last`
  - `lines.first()` → `lines:first`

Either reads better than the call form. Pick by whether the data is
*indexable* (`.0`/`.$`) or *seq-like* (`:first`/`:last`); both compile
to the same thing for vectors, so when in doubt use the dot form.

### Never write `vector(...)` literals — use vector syntax

`vector(a b c)` (the fn call) and vector literal syntax build the
same thing. Always prefer vector syntax — it reads as a literal, not
a function call.

Use `+[...]` only when the vector literal is the entire YAML value
plain scalar and therefore needs the leading `+` escape:

- `vector(a b c d)` → `+[a b c d]`
- `vector(nt ny)` → `+[nt ny]`
- `vector()` → `+[]`

Inside YeS expressions, function arguments, lambdas, method calls, or
any other expression context where `[` is not the first character of
the YAML value, use bare `[...]` with no `+`:

- `digits.map(\(vector(_ s)))` → `digits.map(\([_ s]))`
- `rest.conj(vector(v ns))` → `rest.conj([v ns])`

Never write `\(+[...])`, `foo(+[...])`, or `obj.method(+[...])`.
There `+` is not an escape; it is parsed as addition/concatenation.

Use `vec(coll)` only when you're *converting* an existing collection,
not when listing elements.

### Prefer `+[]` / `+{}` for collection literals

Use `+[...]` and `+{...}` for collection literals when the literal
starts the YAML value. They read as literals and should be the default
for short vectors and maps in value position, including maps with
computed values:

```
pair =: +[name score]
node =: +{:char ch :freq freq}
```

If the literal is inside a YeS expression, drop the `+` because the
literal no longer starts the YAML value:

```
items.map(\([name score]))
nodes.conj({:char ch :freq freq})
```

Use `V+` and `M+` mainly when the collection constructor is the pair
key, especially for block form or when the call layout is clearer as a
YAML pair:

```
M+: :a 1, :b 2

V+:
  item-a
  item-b
  item-c
```

Avoid inline `V+(...)` / `M+(...)` when a `+[...]` / `+{...}` literal
is equally clear.

### Avoid defensive `:V`

Do not add `:V` just because a value is lazy or because the next form
will iterate it. Prefer leaving sequence-producing calls as sequences,
then run the program without materializing first.

Add `:V` only when the program needs vector behavior:
- indexed access with `.N` / `.$i`
- vector-style `conj` order
- repeated traversal where laziness would be surprising
- output must visibly print as a vector
- a later operation specifically requires an indexed collection

When unsure, remove `:V` and run the program. Keep it only if the
program fails or the output semantics change.

### Prefer direct splat calls over `apply`

YS 0.2.32 can splat collection-producing expressions directly into a
call. When the collection is already a value or fits naturally in a
scalar expression, call the target function and append `*` to the
collection instead of wrapping the call in `apply`:

- `apply(max xs)` → `max(xs*)`
- `apply max: xs` → `max: xs*`
- `apply(min-key score xs)` → `min-key(score xs*)`
- `apply(max 0 map(count lines))` → `max(0 map(count lines)*)`
- `apply(concat groups)` → `concat(groups*)`
- `apply f: args` → `f: args*`

The splatted argument can be a variable (`xs*`), a parenthesized
expression (`([f] + args)*`), a call result (`make-args()*`), a dot-chain
(`rows.map(count)*`), or a colon-chain (`freqs:vals*`). Regular arguments
may appear before it, and calls may contain multiple splats.

For operator functions, use a named callable such as `add(xs*)`,
`mul(xs*)`, or `le(xs*)`; a parenthesized operator head such as
`(+ xs*)` also works.

Use `:join` / `join:` when the operation is conceptually joining strings:
`apply(str pieces)` can become `pieces:join`. Use `str(pieces*)` when the
variadic `str` call itself is the clearer expression.

Keep `apply` when the collection is naturally produced by an indented
block and forcing it into a scalar expression would make the code less
clear:

```
apply max-key last:
  map _ xs:
    fn(x): score(x)
```

### Prefer `:S` colon-chain over `str(bareVar)`

Single-argument `str(x)` where `x` is a bare identifier has a terser
colon-chain form `x:S`. Use that:

- `str(c)` → `c:S`
- `str(n)` → `n:S`

`:S` reads as "convert to String" — that's exactly what the call is
doing. Use it whenever the intent is *stringification*.

**Don't** rewrite `str(bareVar)` as `"$bareVar"`. Interpolation is
for composing a string from parts, not for stringification — even
when the result happens to look the same. And the two are not always
equivalent: at the interpolation boundary the value's original type
can leak through (a `Character` can come back as a `Character`),
whereas `str(x)` and `x:S` always produce a real `String`. The
difference shows up in places where the result is used as a map
key, a regex operand, or an `in?` test — Soundex's `code-map`
lookup is a real example where `"$c"` misses entries that `c:S`
finds.

`str(...)` with multiple args (e.g. `str(a b c)` for concatenation) is
unrelated — keep it. The rule is only about the single-bare-var case.

### Prefer `.!` over `:zero?` when falsey-zero semantics are OK

YS falsey semantics make numeric `0` false, so `.!` is usually the
terser way to test "zero result" in numeric code. Prefer it for counts,
remainders, and loop totals when the value is known to be numeric:

- `(i % 5).!` over `(i % 5):zero?`
- `count.!` over `count == 0` or `count:zero?`
- `total.!` over `zero?(total)`

Use `:zero?` / `zero?(...)` only when you specifically need the
stricter predicate and must distinguish numeric zero from nil, false,
empty strings, or empty collections.

### Use conditional assignment for "if true update, else keep same value"

YS 0.2.32 supports conditional assignment targets. When an assignment
would set a target to a new value only when a condition is true, and
otherwise keep the same target value, put the condition in the target:

```
foo :if pred =: bar
```

This means "if `pred`, assign `bar` to `foo`; otherwise keep `foo`".
Prefer it over the verbose self-fallback shape:

```
foo =:
  if pred:
    then: bar
    else: foo
```

The same syntax works with update assignments:

```
count :if prime?(candidate) +=: 1
```

over:

```
count =:
  if prime?(candidate):
    then: count.++
    else: count
```

It also works with destructuring and functional/modified assignment
operators (`+=:`, `*=:`, `||=:`, `.=:` etc.):

```
a b c :if (x == y) =: d e f
total :if include? +=: n
data :if found .=: assoc(k v)
```

The condition after `:if` must be a single form. Parenthesize compound
conditions: `a b :if (x == y) =: c d`, not
`a b :if x == y =: c d`.

### `else:` not `do:` for the else branch of `if`

When the then-branch is a single form and the else-branch is multiple
forms, introduce the else block with `else:`, not `do:`. `do:` compiles
but is not idiomatic.

### `when`/`when-not` for one-armed conditionals returning nil

If a branch of `if` or `cond` returns `nil`, the conditional is really
one-armed — use `when` (or `when-not`) instead. `when` returns nil when
the test is false, so the explicit nil branch is dead weight. A `cond`
with one real arm and a nil fallback is the loudest version of this
mistake.

- `cond: x.!: nil / else: real` → `when x: real`
- `cond: m: i / else: nil` → `when m: i`
- `if cond: form / else: nil` → `when cond: form`
- `when X.!` → `when-not X`

When an `if` has `then: false`, consider reversing the condition and
using `when` for the else branch. `when` returns nil when its test is
false, and nil often works anywhere false was only used as "no result":

```
if letters.# < 2:
  then: false
  else:
    every?: ...
```

can become:

```
when letters.# >= 2:
  every?: ...
```

Only make this rewrite when nil is acceptable in place of false. Keep
the explicit `if` when callers require a strict Boolean `false`.

### `declare` is not needed in YAMLScript

YS resolves `defn` references across the whole file, so mutual
recursion works regardless of definition order. Don't reach for
`declare: name` — it's a Clojure habit and adds noise:

```
# correct — F is defined first and references M defined later
defn F(n):
  if n.!: 1 (n - M(F(n.--)))

defn M(n):
  if n.!: 0 (n - F(M(n.--)))
```

### No reserved symbols in YS or Clojure

Any symbol can be used as a local binding. Names that shadow stdlib
functions (`next`, `count`, `key`, `name`, `val`, `type`, `class`,
`first`, `last`, `rest`, `map`, `line`, `done`, etc.) are fine. Don't
invent abbreviations like `nxt`, `cnt`, `k`, or `done?` just to avoid
the stdlib name.

```
# correct
next =: next-board(b)
when next: recur(next)

# wrong reaching for `nxt` to avoid shadowing `next`
nxt =: next-board(b)
when nxt: recur(nxt)
```

Pick the clearest name from the domain. The only reason to avoid a
particular symbol in a scope is if you need to use the original value
in that same scope.

## Style Defaults

The choices below have no single right answer in YAMLScript. The skill
ships with the defaults listed here, but they are **overridable from a
project's `AGENTS.md`**. If a project's `AGENTS.md` contradicts a
default, follow the project.

These are stylistic only — anything in *Common Mistakes*, *Key Rules*,
or *Anti-Patterns* is not negotiable.

### Prefer subject-first chains over nested calls

When a function has an obvious "subject" argument (the thing being
extended, transformed, queried, or tested), prefer the subject-first
chain form over a nested function call. Less parenthesizing is usually
better, and `subject:op` / `subject:op1:op2` reads left-to-right:

- `n:random-brackets` over `random-brackets(n)`
- `s:balanced?` over `balanced?(s)`
- `s:seq` over `seq(s)`
- `brackets:seq:shuffle` over `shuffle(seq(brackets))`
- `m.assoc(:k v)` over `assoc(m :k v)` when args are needed
- `xs.conj(x)` over `conj(xs x)` when args are needed
- `s.split('/')` over `split(s '/')` when args are needed

Use colon chains for zero-argument calls and subject-first unary calls:
`b:a` is preferred over `a(b)`, and `c:b:a` is preferred over
`a(b(c))`, when the chained order is the natural data flow.

Use the bare-function form when arguments are co-equal (e.g.
`merge(a b c)`, `concat(xs ys zs)`) or when there is no natural
receiver.

To override: in `AGENTS.md`, write *"prefer bare-function form
(`assoc(m k v)`) over receiver-first chains"*.

### Vectors of short strings

For a static vector of short word-like strings, prefer `qw(a b c)`
over `=:: ['a', 'b', 'c']`:

- `colors =: qw(red green blue)` over `colors =:: ['red', 'green', 'blue']`

`qw` produces a vector of strings. Use the data-mode literal when the
elements contain spaces or non-word characters.

### Default argument values

For a `defn` arg with a long default value, prefer setting it in the
body with `||=:` over a long signature line:

```
defn main(text=nil):
  text ||=: 'The quick brown fox jumps over the lazy dog'
```

over

```
defn main(text='The quick brown fox jumps over the lazy dog'):
```

Short defaults (numbers, short strings, keywords) belong in the
signature: `defn main(n=10):`.

### Block form vs chain for multi-arg calls

For a call with three or more substantial args, prefer block form
with one arg per line over a single-line chain:

```
concat:
  quicksort(less)
  vector(p)
  quicksort(more)
```

over

```
concat: quicksort(less) vector(p) quicksort(more)
```

Two args fit fine on one line.

## Key Rules

### Formatting
- **Lines must not exceed 79 columns.** This is a hard limit, not a
  suggestion. Target 20/40/60 columns as the natural "square" sizes for
  most lines. YAML/YS gives you many ways to split:
  - **Block form**: replace a chain with an indented block
  - **Intermediate variables**: assign a sub-expression to a name
  - **Plain scalar folding**: a plain (unquoted) YAML scalar folds at any
    whitespace — break before a binary operator and indent the
    continuation:
    ```
    user =: ENV.RC_USER ||
      die('set RC_USER (botpassword username)')
    ```
  - **Double-quoted line fold**: a `"..."` string can be split at any
    space — YAML folds the newline (and the continuation's leading
    whitespace) into a single space. Indent the continuation to read
    cleanly:
    ```
    say: "map my-add over pairs:
          $(map(my-add [1 2 3] [10 20 30]):joins)"
    ```
  - **Double-quoted backslash continuation**: a `"..."` string can be
    split with `\` at end of line, even when there's no whitespace to
    fold at. Useful for long URLs, identifiers, or any unbroken token:
    ```
    url =: "https://en.wikipedia.org/w/api.php?action=query\
            &titles=Rosetta_Code&format=json"
    ```
  - **Block scalars** (`|`, `>`): for multi-line literal text
- **End the file with exactly one newline.** No trailing blank line.
  The last byte should be one `\n` after the last code line, not two.

### Strings
- Single quotes unless interpolation or escapes needed
- `"Hello, $name!"` not `str('Hello, ' name '!')`
- `"Result: $(x * y)"` for expression interpolation
- `"Now: $now()"` for a bare function or method call. The shortened
  `$ident(args)` form works for plain identifiers (letters, digits,
  underscore, hyphen) and static calls like
  `"$System/currentTimeMillis()"`. Prefer it over `$(ident(args))`
  when the call is a single function or method on a bare name.
- Interpolation stops parsing the identifier at `?` or `!`, so
  predicate names break the shortened form: write
  `"$(all-equal?(xs))"`, not `"$all-equal?(xs)"` (which interpolates
  only `$all-equal` and leaves `?(xs)` as literal text). Reach for
  `$(...)` for operators, chains, anything beyond one call, or any
  identifier containing `?` or `!`.
- `say: |` with a multi-line block — all lines interpolated and printed
- `::` (double colon) is sugar for `! ` (mode-toggle tag).
  `a:: b` = `a: ! b` — toggles between code and data mode:
  - In code mode (default `!ys-0`), `::` switches value to data
  - In data mode, `::` switches value back to code
  - `say:: hello` — data mode: literal string `"hello"`, not
    variable lookup (quoted `'hello'` is already literal either way)
  - `say:: |` — data mode: literal block scalar (no interpolation)
  - `json/dump::` with indented YAML — build data structures
    natively instead of `json/dump: +{...}` with escaped maps
  - `http/post url::` — pass YAML maps as options
  - Inside a `::` data block, `key:: expr` toggles back to code:
    `model:: model` = YAML key `model` with the value of
    *variable* `model`
  - `content:: |` with `$var` — block scalar with interpolation
  - `::` only works on mapping pair values (key-value syntax).
    For sequence entries, use the explicit `!` tag:
    `- ! expr` to evaluate `expr` as code within data mode
- `:::` enters code-value mode for a mapping or sequence value.
  Collection structure and mapping keys are data, while scalar values
  and sequence elements are code:
  ```yaml
  result =:::
    first: foo()
    nested:
      second: bar()
    items:
    - foo()
    - bar()
  ```
  Code-value mode recurses through block and flow mappings and sequences.
  It can be entered from code mode, data mode, or code-value mode.
  A nested `:::` is therefore valid but redundant.
  The value after `:::` must be a mapping or sequence; a scalar is an
  error.
  Use `key:: value` to make an entire mapping value subtree data again.
  Use `- ! value` for the same data override on a sequence element.
  Inside an overridden data subtree, ordinary `::` re-enters code mode.
  An unmarked mapping is always structural in code-value mode, so write
  block-form code as a scalar call to a helper function instead.
  Prefer `:::` when most values in a collection are computed.
  For a mostly literal collection with only one or two computed values,
  data mode with `::` on those values is often clearer.
- In code-value mode, `:when condition:::` conditionally splices mapping
  entries into the surrounding map:
  ```yaml
  result =:::
    always: current
    :when include-extra:::
      extra: make-extra()
  ```
  When the condition is false, the spliced entries are absent.
  The remaining entries keep their source-key insertion order.
  The triple colon is required so the splice body is also code-value
  mode.
  Do not put block-form code under an ordinary key because that nested
  mapping is structural.
  Use a conditional splice for an optional key, or compute the value in
  a helper or intermediate variable and use that scalar result.
- `!<fn>` tag — avoids an extra indent level: `each i xs: !say` instead
  of nesting `say:` as a separate pair inside the body
- CLI args that look like numbers are auto-converted — `num()` not needed.
  Do NOT defensively coerce with `:int` / `:N` either. `defn main(n=10):`
  is enough; `ethiopian(a b)` works with no coercion when `a`/`b` came
  from the command line.
  Two globals expose the raw and converted views:
  - `ARGV` — all CLI args as raw strings (no conversion)
  - `ARGS` — all CLI args with numeric-looking values converted
  Use `ARGV` when the task is *about* string handling of numeric-looking
  input (e.g. "increment a numerical string"); otherwise `ARGS` and
  named/positional params with defaults are fine.
- `+` for simple concatenation at end of dot chain, not `str()`
- `n * 'str'` — integer times string repeats it: `n * '  '` for an
  indent of `n` levels. Replaces `apply: str repeat(n '  ')`. Order
  doesn't matter for this case: `'str' * n` also works.
- Interpolation auto-stringifies — `"$x"` works for any value, no
  `str(x)` needed inside `"..."` or `$(...)`
- `uc1(s)` — capitalize first character; `uc(s)` — all uppercase
- `join(sep coll)` — join with separator; `join(coll)` — no separator.
  Also `coll:join` (colon chain) and `join: coll` (pair form) for the
  no-separator variant. Prefer these when the operation is conceptually
  joining strings: `apply(str chunks)` → `chunks:join`. When preserving
  the variadic call is clearer, use a direct splat: `str(chunks*)`.
- `joins(coll)` — join with a single space. **`xs.join(' ')` is
  always `xs:joins`** — the colon chain says "join with one space"
  directly. `say: row.join(' ')` → `say: row:joins`.
- `split` and `join` have their own arg-swapping (not in DWIM list)
- `qw(word1 word2 ...)` — quoted word list; creates a vector of
  strings without needing quotes around each word
- `words(s)` — split string on whitespace; colon chain: `text:words`
- `lines(s)` — split string on newlines; colon chain: `text:lines`
- `in?(x coll)` — membership test; works on strings, vectors, sets,
  maps. Dot form: `w.in?(fruits)`. Flipped: `has?(coll x)`
- `replace(s pat repl)` — replace all matches; supports `$1` groups
- `replace(s pat)` — remove all matches (replacement defaults to `""`)
- `replace1(s pat repl)` — replace first match only
- In scalar expressions, escape YAML-special sequences:
  - `:\` → literal `: ` (colon-space would trigger colon-chain)
  - ` \#` → literal ` #` (space-hash would start a YAML comment)

### File I/O
- **Never use `slurp` / `spit`** — these are the Clojure names. YS
  spells them `read` and `write`, and those are the only idiomatic
  forms:
  - `read(file)` — read a whole file to a string (was `slurp`).
    Colon chain: `file:read`, e.g. `FILE:read:lines`.
  - `write(file content)` — write a string to a file (was `spit`).
- `FILE` is bound to the running program's own source path, handy for
  a program that reads data embedded in itself.

### Comments
- `# ...` — standard YAML comment to end of line. Use it between
  structures, after values, and at file top/bottom.
- A `#` comment terminates the surrounding YAML scalar, so it cannot
  appear inside a multi-line plain-scalar expression such as a
  dot-chain spread across lines.
- `\"..."` — YS expression-level comment. Opens with `\"`, closes at
  the next `"`. Use it to annotate steps inside a multi-line
  expression where `#` would break the scalar:
  ```yaml
  defn scramble(s):
    s          \"input string"
      .lc()    \"lowercase"
      .split() \"split into chars"
      .shuffle()
      .join()
      .uc1()   \"capitalize first char"
  ```
- `\"..."` constraints:
  - The body cannot contain `"` (no escape mechanism).
  - The body is still lexed by YAML, so YAML-special sequences must
    be escaped the same way they are in any plain scalar:
    `:\ ` for `: ` (colon-space), ` \#` for ` #` (space-hash). See
    Strings.
  - Not usable inside YAML string literals (`"..."`, `'...'`) or
    regex literals `/.../`.
  - Not usable as a standalone YAML block element — only inside an
    in-progress multi-line expression.

### Function Definitions
- `defn name(args):` form with parens
- Default args over multi-arity: `defn greet(name='World'):`
- For multi-line default text, use a top-level block scalar variable:
  ```yaml
  dflt =: |-
    line one
    line two
  defn main(text=dflt):
  ```
  Avoids the YAML plain scalar restriction that forbids `: ` inside
  default values written as `\n`-escaped double-quoted strings
- `defn-` for private helpers
- Destructuring in parameter lists: `defn score([a b]):` binds `a`
  and `b` to elements of a pair argument — saves an intermediate
  `a b =: pair` line. Works for both `defn` and `fn`.
- `main` with default args for CLI programs. Defaults should be
  values a user could actually type on the command line — strings
  and numbers, not vectors or maps. If `main` needs a collection,
  default a string and parse it in the body (see `ARGV`/`ARGS`).
- `main` arg-list shapes:
  - `main(name)` — exactly one named arg (auto-converted if numeric)
  - `main(_)` — exactly one arg, unnamed (arity matters, name doesn't)
  - `main(*)` — any number of args, unnamed
  - `main(*args)` — any number of args, named
- Define functions top-down: `main` first, then helpers in call
  order — this is idiomatic YAMLScript

### Function Calls
- Top level: mapping pair — `say: 'hello'`
- `a: b c` ≡ `a b: c` ≡ `a b c:` — the colon splits a call into
  before/after segments; choose the split that reads naturally.
  Promote the "subject" of a call before the colon when it makes
  the call read like English:
  - `write file: content` not `write: file content`
  - `assoc m: k v` not `assoc: m k v`
  - `join: brackets:seq:shuffle` not `join: shuffle(seq(brackets))`
- Prefer colon-chain composition for unary pipelines. Write `x:b:a`
  instead of `a(b(x))` when each step takes the previous value as its
  only argument. This keeps the data flow left-to-right and avoids
  nested parentheses.
- **Higher-order function calls** — when the function arg is named,
  put it on the key side; the data flows to the value:
  - `reduce f: init coll` — reducing with `f` over `init`/`coll`
  - `map double: coll` — mapping `double` over `coll`
- **Inline-defined function for an HOF** — put `_` where the function
  arg goes; define the function as the block value:
  ```
  reduce _ init coll:
    fn(acc x):
      ...body...
  ```
  This works for any HOF: `map`, `filter`, `reduce`, etc. The block
  value substitutes at the `_`.
- Inline: YeS form — `inc(x)` not `(inc x)`
- Prefer `a.b(c)` over `b(a c)` — dot chain from the receiver
  unless the receiver needs escaping (`{}`, `[]`, `""`, `''`)
- Scalar `if`: dot-chain the condition before it —
  `cond.if(then else)` not `if(cond then else)`
- `X OP: Y` at the pair level is sugar for `X OP Y`, for any binary
  operator. `a +: b` ≡ `a + b`; `(cond) &&: body` ≡ `cond && body`
  (body only runs if `cond` is truey); `(cond) ||: body` ≡
  `cond || body` (body only runs if `cond` is falsey). The `&&:`/`||:`
  forms overlap functionally with `when`/`when-not` but the mechanism
  is the operator's short-circuit, not a control structure.
  Scalar `Y` must contain exactly one form; a compound expression counts
  as one form. Mapping values keep their mapping and form-map semantics.

### Control Flow
- `if <cond>: <then-form> <else-form>` — always needs both forms.
  **`if` is the default for two-branch conditionals.** Reach for `cond`
  only when there are 3+ branches — see Common Mistakes.
- Use `when` for one-armed conditional (no else); `when-not` is the
  inverted form (`when-not X` ≡ `when X.!`). See Common Mistakes for
  when to choose `when`/`when-not` over `if`/`cond`.
- `when+ expr:` — like `when`, but binds `_` to the truey value of `expr`
  inside the body. Use it to test-and-capture in one step:
  `when+ schema.'$ref': say: "-type: $(ref-sym(_))"`
- `.when(value)` — receiver acts as the test; returns `value` if truey,
  else nil. Replaces the `.if(value nil)` pattern:
  `only-ref?(s).when(ref-sym(s.'$ref'))` not
  `only-ref?(s).if(ref-sym(s.'$ref') nil)`
- `cond` returns nil when no clause matches — drop trailing `else: nil`
- `case` requires an explicit `else:` default arm. Unlike `cond` (returns
  nil), `case` throws `No matching clause: <value>` if no arm matches.
  A bare trailing form is parsed as another `key: action` pair, not a
  default — `else:` is required.
- `if` accepts three shapes:
  - **form / form** — two consecutive pairs, no keywords:
    `if cond: \n  say: yes \n  say: no`
  - **block / block** — both `then:` and `else:` required; using
    `then:` forces `else:`
  - **form / block** — bare then-form followed by an `else:` block.
    Do NOT use `do:` for the else block — `else:` is the idiomatic
    keyword (see Common Mistakes).
- When both branches are simple, prefer the tersest fit:
  - **Single-line pair**: `if cond: a b` when both forms parse as a
    single plain scalar — e.g. bare symbols, function calls, ranges:
    `if v == v2: v recur(v2)`, `if x:odd?: print('o') print('e')`.
  - **Single-line with `+` escape**: when the first form starts with a
    YAML syntax char (`'`, `"`, `[`, `{`, etc.), add `+` to the front:
    `if x:odd?: +'odd' 'even'`, `if found: +match 'none'`.
  - **Chain form**: `cond.if(a b)` when the condition reads well as a
    receiver and you're not already in a mapping-pair context:
    `x:odd?.if('odd' 'even')`.
  - **Two-pair form** (newlines): when either branch is too long to
    inline, fall back to `if cond: \n  a-form \n  b-form`.
- Consider reversing the condition to avoid `then:` — complex branch
  first (no keyword), simple branch as `else:` — often cleaner
- `else` not `:else` in `cond`
- `each` over `doseq` for side-effecting iteration
- `dotimes [_ n]:` — repeat n times ignoring the index; clearer than
  `each [_ (1 .. n)]:` when you don't need the iteration value
- `loop i 1, acc 0:` — loop with named bindings (no surrounding
  brackets). The bracket-free form is the *canonical* style for
  binding-list forms in YS — never write the bracketed Clojure
  form when you can avoid it. Reliably strippable: `binding`,
  `if-let`, `if-lets`, `if-some`, `let`, `loop`, `when-first`,
  `when-let`, `when-lets`, `when-some`, `with-open`. For these,
  `KW [a b c d]:` is always wrong — write `KW a b, c d:`
  (comma-separated pairs) or `KW a b c d:` (no commas) instead.
  Use `recur` for tail recursion back to the loop head.
- **Iteration keywords (`each`, `for`, `doseq`, `dotimes`) also take
  the bracket-free binding form.** A name-value binding list drops its
  brackets regardless of the value — even a parenthesized range or a
  vector literal:
  - `each [y (0 .. h)]:` → `each y (0 .. h):`
  - `for [i (0 .. n), j (i .. n)]:` → `for i (0 .. n), j (i .. n):`
  - `each [c [true false]]:` → `each c [true false]:`

  Two cases keep their brackets:
  1. **Ignore-var binding** whose variable is `_`: `dotimes [_ n]:`,
     `each [_ (1 .. n)]:`. The `_` form must stay bracketed.
  2. **Destructure shortcut** where a pattern binds each element of one
     collection: `each [a b] coll:`, `each [k v] m:`. Here `[a b]` is a
     destructure pa

…(truncated)
