Skip to content

Commit 8b713fa

Browse files
docs(skills): objectstack-automation teaches the {{ }} delimiter in flow text slots (#22475)
Fixes #22454 Clause-②: no ## What changed, and why PR #22315 made a flow's TEXT slots — a `notify` node's `title` / `message`, a `screen` node's `title` / `description`, a refusing `end` node's `message` — read ADR-0032 §3's `{{ }}` delimiter. The door's own rule (`packages/spec/src/automation/flow-text-slot-template.ts`, `TEXT_SLOT_TEMPLATE_REFUSAL`): 「A flow text slot reads `{{ }}` template holes (ADR-0032 §3), not the single-brace `{…}` dialect: a `{…}` token here is no placeholder any more and would be sent as literal text, so it is refused.」 The published skill still taught the single brace at three sites. Each is restated to what `main` enforces today, nothing beyond it: - **`SKILL.md` notify example** (base `:108–109`): `title: 'Done: {{ record.title }}'`. The old `message: 'Closed by {$User.Id}'` has no mechanical flip — `{$User.Id}` is not a variable, so no hole spells it; the door says to compute it into a variable first — so the body is now `'Closed {{ record.closed_at | date:long }}'`, a path with a formatter, which the door accepts and renders (measured below). The `recipients` / `sourceId` lines keep their single brace: value-like positions still go through `interpolate()`. - **`SKILL.md` dialect paragraph** (base `:238–244`): three statements, one door each — text slots render `{{ }}` holes (a variable path, optional formatter, no logic) and `os validate` / `registerFlow` refuse a `{…}` token there with its `{{ }}` spelling; `{$User.Id}` / `{NOW()}` have no hole and `{{ $User.Id }}` renders blank (measured) — assign them to a variable first; every other slot (`recipients`, `inputs`, `http`, `filter`) keeps the single-brace template. The clause "`{{x}}` is the template-field dialect" is deleted — `{{ }}` is the text-slot dialect itself. - **eval id 2**: `expected_output` names `title: 'Won: {{ record.name }}'` (a spelling the door accepts); `must_contain` gains `"{{"`; `must_not_contain` drops `"{{"`, which pinned the old dialect and would have failed the correct answer. No single-brace negative is added: `recipients: '{record.owner_id}'` is a legitimate single-brace value on the same node, so any `{record.` substring would false-fire, and no runner in this repository reads these assertions (Acceptance notes). Two sentences in § Filter tokens that listed text slots among the single-brace positions were re-read: "in `title`, `message` and `url` a bare `{current_year_start}` is a nonsense reference" keeps only `url` (a text slot reads no single-brace token at all); "(message body, `http` url) only renders an empty string — a warning" now says "(a `{{ }}` hole, `http` url)" — `packages/lint/src/validate-flow-template-paths.ts` judges a `{{ record.x }}` hole's path exactly like a bare token, so the warning half still holds in the hole spelling. ### Every slot the paragraph names, re-read against `main` at `d87dff67` | slot | what `main` does | measured in | |:--|:--|:--| | notify `title` / `message`, screen `title` / `description`, end `message` | `{{ }}` holes; a `{…}` token refused at `NotifyConfigSchema` / `ScreenConfigSchema` / `EndConfigSchema`, at `registerFlow` and at `objectstack validate` (`error`), then the holes compiled by `validateExpression('template', …)` | `flow-text-slot-template.ts`; `service-automation/src/engine.ts` (registerFlow); `lint/src/validate-expressions.ts` | | `recipients`, `actionUrl`, `sourceId`, `payload`, `templateData` values | single-brace `interpolate()` | `service-automation/src/builtin/notify-node.ts` | | `script.inputs` | single-brace (`values interpolate {token} templates`) | `spec/src/automation/schemaless-node-config.zod.ts`; `screen-nodes.ts` | | `http` (the whole config) | single-brace (`The whole config is interpolate()d before reading`) | `spec/src/automation/io-node-config.zod.ts`; `http-nodes.ts` | | `filter` | single-brace with the placeholder hand-off | unchanged — § Filter tokens | | `fields.*` / `assignments.*` (the skill's item 1, untouched) | `{…}` refused, CEL envelope prescribed; `{NOW()}` / `{TODAY() ± N}` / `{$User.*}` still accepted — #19939's C half landed in #22259, the card itself stays `pm:queue` | `flow-value-slot-template.ts` | ### Measured on the built dists (lint closure built at `057ce436`) - `NotifyConfigSchema.safeParse`: the new example ACCEPTED; `title: 'Done: {record.title}'` REFUSED — "Write `Done: {record.title}` as `Done: {{ record.title }}`"; `message: 'Closed by {$User.Id}'` REFUSED — "`{$User.Id}` is not a variable, so no hole spells it: compute it into a variable with an `assignment` node … and write `{{ v }}` here"; eval id 2's `'Won: {{ record.name }}'` ACCEPTED; `'By {{ $User.Id }}'` ACCEPTED (it is syntactically a hole). - `validateExpression('template', …)` from `@objectstack/formula`: all four strings compile. - `templateEngine.evaluate`: `Done: {{ record.title }}` → `Done: Fix login`; `Closed {{ record.closed_at | date:long }}` → `Closed October 9, 2026`; `By {{ $User.Id }}` → `By ` with `ok: true` — the blank the paragraph now names. ### Enumeration - Single-brace TEXT-slot examples: `git grep -n -P` for a `title` / `message` / `description` key whose quoted value holds a `{` with no `{` on either side (a lookbehind and a lookahead; the dispatch's `\{[^{]` spelling also matches the second brace of a `{{ ` hole, so the lookaround form is the pin) over `skills/objectstack-automation` at `d87dff67` → `SKILL.md:108`, `:109` (exactly the seat's reading); on this head → 0 hits; over all of `skills/` on this head → 0 hits. - Cross-skill: the only other `{{` mention under `skills/**` is `skills/objectstack-i18n/SKILL.md:246` (i18n bundle placeholders, not flow text slots) — not this package, untouched. - `{{` under this package: `SKILL.md` 1 → 5 sites, the eval 1 → 2. - The `{TODAY()}` tokens in `references/examples-flows.md` sit in `filter` positions — single-brace on `main`, untouched. ### Line and token budget | file | lines | bytes | `check-skills-token-ratchet` tokens (ceiling) | |:--|:--|:--|:--| | `SKILL.md` | 439 → 438 (−1) | 23,052 → 23,129 (+77) | 5,763 → 5,783 (5,785 — headroom 2) | | `evals/flows-triggers-approvals.json` | 55 → 55 (0) | 5,018 → 5,012 (−6) | 1,255 → 1,253 (1,255) | | package `skills/objectstack-automation/**` | 1,092 → 1,091 (−1) | | | Net lines −1, no line added. The text-slot sentences are paid by deleting the § Filter tokens lede ("The one slot where two `{…}` dialects meet, and the one whose failure widens a query instead of narrowing it" — restated by the two bullets under it and by the routing table's "a dropped filter condition widens the query") and the now-false `{{x}}` clause; no re-wrap paid a line. No ceiling was moved: `scripts/check-skills-token-ratchet.mjs` is outside the claimed file surface (the eval's ceiling could be lowered 1,255 → 1,253 by a later edit). ### Gates, head `057ce436` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (change set from the merge base, no paths given) derived 24 commands; each was run with its exit captured before any pipe; `--ran` reconciliation: 「24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN」. All 24 exit 0 with their own verdict lines read — among them `check-skills-token-ratchet` (54 authored files within ceilings), `check:skill-identifier-liveness`, `check:skill-docs`, `check:doc-formula-expressions`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:nul-bytes`, `check:doc-authoring`, `check:corpus-claim-drift`, `check:pm-governed-merges`. The list derived from the two paths (the dispatch's command) is identical. The derivation printed a STALE TREE note — 3 commits behind `origin/main`, two derivation inputs changed (`scripts/check-durability-degradation-log-level.mjs`, `scripts/measure-durability-swallow-family.mjs`), neither in this list; `origin/main` was not merged in because those commits carry `merge=os-regen` spec artifacts, which would hang a regeneration commit on a skills-only PR. The lint dependency closure was built once under the verify lock (129 s) so `check:skill-docs` and `check:doc-formula-expressions` read a fresh `dist/`. Skill eval run: NOT MEASURED — no runner in this repository reads `must_contain` / `must_not_contain` (a tree grep finds only the eval JSONs and two READMEs); the eval is a fixture for a model-driven grader. The sibling `objectui/scripts/check-skill-eval-tokens.mjs` matches a token case-sensitively as a whole token, degrading to an exact substring at a non-identifier end — so `"{{"` is a plain substring there. Changeset: none. `skills/**` is in no published package's `files[]` — 69 published packages, 0 name `skills`, positive control 69 name `dist` — and the catalog ships from git by `npx skills add`; `skip-changeset` applied. ## Acceptance notes - `SKILL.md:154` ("The handler reads `{NODE_ID.error}` (or run-wide `{$error}`)") names no slot: in a text slot the spelling is now `{{ n1.error }}` / `{{ $error.message }}`, in a value-like position it stays single-brace. Left as is (budget; the door names the spelling loudly). carrier: 承接者:无 — noted, not filed. - The eval's ratchet ceiling (1,255 → 1,253) is lowerable in `scripts/check-skills-token-ratchet.mjs`, outside this card's file surface. carrier: 承接者:无 — noted, not filed. - `{{ $User.Id }}` renders blank with `ok: true` and passes `objectstack validate` (`validate-flow-template-paths.ts` skips an unresolvable root; `lint-flow-patterns.ts` reads a bare `$` only outside holes). A read-only inference about a shape no in-tree flow authors; the paragraph now warns. Not filed — class (c) needs a named producer and there is none. - The card reads #19939 as "the value-slot dialect", not landed: its C half (`fields.*` / `assignments.*` refusal) landed in #22259; the card stays `pm:queue` for its remaining passes. The skill's item 1 already matched that half and is untouched. ## 维护者速读(草稿) **改了什么** — 把 `objectstack-automation` 技能里仍在教「通知文案用单花括号 `{token}`」的三处改成平台现在真正接受的 `{{ }}` 写法:通知示例、「文本槽 vs 其它槽」那段说明、以及评测用例 2。顺带把 Filter 一节里两句把文本槽列为单花括号位置的话改准确。净行数 −1,token 棘轮内(SKILL.md 余量 2)。 **为什么改** — #22315 落地后,`notify` 的 `title` / `message`(以及 `screen` 的 `title` / `description`、`end` 的 `message`)只读 `{{ }}`,单花括号在构建门、注册门和存盘门都会被拒。技能是随 `npx skills add` 原样装到客户项目里的,照它写出来的流必定被拒,错误到了门口才看见。改后技能说的和门做的一致;其它槽位(`recipients`、`inputs`、`http`、`filter`)仍是单花括号,照 `main` 实测写明,不多说一句。 **风险与代价(含回滚)** — 风险低:只改发布技能的文字和评测断言,不碰代码与契约;新示例已在构建产物上实测被接受并渲染。代价是 SKILL.md 多 77 字节(棘轮内)。回滚:revert 本 PR 即可,无其它依赖。 **席位意见** — (留空) **你要做的** — Tier H:`skills/**` 是受管面,本 PR 保持 draft,由您的 APPROVED review 批准后落地;不需要您做其它操作。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97610a5 commit 8b713fa

2 files changed

Lines changed: 17 additions & 18 deletions

File tree

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ undeclared key is a named parse error. **RAW** keys never interpolate: a
105105
```ts
106106
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
107107
recipients: '{record.assignee}', // REQUIRED — id, CSV, or string[]
108-
title: 'Done: {record.title}', // inline path; XOR `template` (RAW, localizable)
109-
message: 'Closed by {$User.Id}', // body; only with inline `title`
108+
title: 'Done: {{ record.title }}', // inline; XOR `template` (RAW, localizable)
109+
message: 'Closed {{ record.closed_at | date:long }}', // body; only with inline `title`
110110
topic: 'task', // RAW; default 'notify'
111111
severity: 'warning', // RAW; CLOSED enum info|warning|critical
112112
channels: ['inbox'], // RAW; default inbox
@@ -196,21 +196,18 @@ run reports success. `objectstack validate` names the offending template.
196196
197197
### Filter tokens (`config.filter`)
198198

199-
The one slot where two `{…}` dialects meet, and the one whose failure **widens**
200-
a query instead of narrowing it.
201-
202199
- **Precedence — flow variables win, placeholders pass through.** The flow
203200
template engine runs first. A whole-string token it resolves is a flow value;
204201
one it does **not** resolve that IS a recognised filter placeholder
205202
(`{current_user_id}`, `{current_year_start}`) passes through **verbatim** for
206203
the query engine to expand. So a flow variable named after a placeholder
207-
**shadows** it. Only `filter` gets this hand-off — in `title`, `message`
208-
and `url` a bare `{current_year_start}` is a nonsense reference.
204+
**shadows** it. Only `filter` gets this hand-off — in `url` a bare
205+
`{current_year_start}` is a nonsense reference.
209206
- **Static checkability splits by position.** A `{record.…}` token **inside a
210207
filter** naming an unknown field, or hopping a relation the start node does not
211208
list in `config.expand`, is an **ERROR** at `objectstack validate`: it resolves
212209
to nothing, the condition is DROPPED, and the node refuses to execute. The
213-
*same* reference **outside** a filter (message body, `http` url) only
210+
*same* reference **outside** a filter (a `{{ }}` hole, `http` url) only
214211
renders an empty string — a **warning**. A `{var}` naming a flow variable
215212
or node output is **not statically checkable at all**.
216213

@@ -235,13 +232,15 @@ Legal metadata that authors — AI especially — get wrong; most are caught by
235232
- still accepted there until CEL can write them: `{NOW()}` / `{TODAY() ± N}`
236233
and `{$User.<path>}`
237234

238-
Text slots (notify `title` / `message`, `inputs`, `http` `url` / `body`, …)
239-
and `filter` keep the SINGLE-brace template — `{var}` / `{record.title}`,
240-
`{record.tags.0}` (array index), `{$User.Id}`, `{NOW()}`, `{TODAY() + 30}`,
241-
`{round(x)}`-style arithmetic; no `{…}` ⇒ literal; `{{x}}` is the
242-
template-field dialect. ❌ `'{ROUND(x, 2)}'` — an unknown name in call
243-
position **fails the node** at run time, unchecked at build, not
244-
`fault`-routable.
235+
Text slots (notify `title` / `message`, screen `title` / `description`, end
236+
`message`) render `{{ }}` holes — a variable path, optional formatter, no
237+
logic; `os validate` / `registerFlow` refuse a `{…}` token there with its
238+
`{{ }}` spelling. `{$User.Id}` / `{NOW()}` have no hole (`{{ $User.Id }}`
239+
renders blank): assign them to a variable first. Every other slot
240+
(`recipients`, `inputs`, `http`, `filter`) keeps the SINGLE-brace template:
241+
`{record.tags.0}`, `{TODAY() + 30}`, `{round(x)}`; no `{…}` ⇒ literal.
242+
❌ `'{ROUND(x, 2)}'` — an unknown name in call position **fails the node**
243+
at run time, unchecked at build, not `fault`-routable.
245244

246245
2. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
247246
Reference a field explicitly.

‎skills/objectstack-automation/evals/flows-triggers-approvals.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
{
1515
"id": 2,
1616
"prompt": "When an opportunity moves into stage `closed_won`, send the account owner an inbox notification with the deal name.",
17-
"expected_output": "Emits a `record_change` flow whose binding lives on the `start` node `config`: `objectName: 'opportunity'`, `triggerType: 'record-after-update'`, and a bare-CEL `condition` `previous.stage != 'closed_won' && record.stage == 'closed_won'` so it fires only on the transition. A `notify` node with `recipients: '{record.owner_id}'`, `title` with single-brace interpolation, `channels: ['inbox']`. Reminds that `requires` must list `automation`, `triggers` and `messaging` (otherwise notify reports success with `skipped: true`).",
17+
"expected_output": "Emits a `record_change` flow whose binding lives on the `start` node `config`: `objectName: 'opportunity'`, `triggerType: 'record-after-update'`, and a bare-CEL `condition` `previous.stage != 'closed_won' && record.stage == 'closed_won'` so it fires only on the transition. A `notify` node with `recipients: '{record.owner_id}'`, `title: 'Won: {{ record.name }}'`, `channels: ['inbox']`. Reminds that `requires` must list `automation`, `triggers` and `messaging` (otherwise notify reports success with `skipped: true`).",
1818
"files": [],
1919
"assertions": {
20-
"must_contain": ["triggerType", "record-after-update", "previous.", "notify", "recipients", "messaging"],
21-
"must_not_contain": ["trigger:", "{{"]
20+
"must_contain": ["triggerType", "record-after-update", "previous.", "notify", "recipients", "messaging", "{{"],
21+
"must_not_contain": ["trigger:"]
2222
}
2323
},
2424
{

0 commit comments

Comments
 (0)