std/transform には3つのToolが含まれており、その3つすべてに共通する性質があります。それは capabilityを一切宣言しない ことです。どのToolの内部からも ctx.netctx.fsctx.browserctx.clockctx.random には到達できません。入力として渡したJSONだけが使えます。純粋なToolには効果がないことを証明できるため、エンジンはその結果をJournalに記録しません。リプレイ時には、そのToolを無料で再実行するだけです(標準Packを参照)。
Tool目的入力スロット出力
transformJSONのまとまりに対してBrowser Flow式を評価するexpr: string, input?: object評価されたJSON値
jq小さなpath/reshapeサブセット。本物のjqではありませんfilter: string, value?: objectフィルター後のJSON値
template{{ name }} プレースホルダーを補間するtemplate: string, vars?: object{ text: string }
この3つはすべて packages/std/src/transform.ts で定義されています。
inputvaluevars はすべて JSON envelopez.object({}).passthrough())として型付けされています。つまり、トップレベルのJSON object であり、配列やスカラーではありません。これらのスロットに裸の配列や数値を渡すと、Tool境界でバリデーションに失敗します。トップレベル配列をreshapeする必要がある場合は、まずオブジェクトで包み({ items: [...] })、そこから参照してください。この点は、下で説明する jq で特に重要です。

transforminput に対して式を評価する

transform は、flow内の他の場所で with:when:foreach:output: を支えているものと同じ式パーサー/評価器(parseOrThrow / evaluate)を実行します(式言語)。ただし、expr をTool自身の run() の中で 2回目に 評価し、その際には意図的に狭められたコンテキストを使います。
/** 純粋なEvalContext: clock/random/secret/step/envはtransform Toolに届かない。 */
function pureEvalContext(input: Record<string, JsonValue>): EvalContext {
  return {
    input,
    steps: {},
    env: {},
    secret: (name: string): JsonValue => {
      throw new Error(`transform is pure: secret.${name} is not available`);
    },
    now: "",
    run: { id: "" },
  };
}
stepsenv は空のオブジェクトで、secret.<name> は無条件にthrowし、now/run.id は空です。実データを持つ 唯一の namespace は input.<name> です。これは、Tool自身の input スロットに渡した任意のJSONオブジェクトから束縛されます。

重要な落とし穴: 同じ構文、異なるスコープ

この2つの場所で式言語が変わるわけではありません。文法 は同一です。変わるのは、文字列が2つの異なるコンテキストで2回評価されるため、参照が何を名前解決できるか です。
レイヤー書く場所評価するもの到達可能な参照
Flowレベルのbinding任意の手順の with:when:foreach:output:Riderが、完全なflow EvalContext に対して評価input.*flowの 宣言済み入力)、steps.<id>.outputenv.<KEY>secret.<KEY>nowrun.id
transform 自身の exprtransform Toolの expr スロットに束縛される文字列値Tool自身の run() が、pureEvalContext に対して評価input.<name> のみ。つまり Toolの input スロット、言い換えると この 手順の with.input: に束縛したもの
紛らわしいのは、どちらのレイヤーでも input同じトークン でありながら、決して同じデータではない点です。flowレベルでは input.* はflowの宣言済み入力を意味します。expr の内側では、この1つの手順のために組み立てた小さなJSONのまとまりを意味します。expr の内側には stepsenvsecretnow はそもそも存在しません。fallbackも部分的な可視性もなく、存在しないかthrowします。 RiderはToolが実行される 前に with.input を解決します。これは完全なスコープを持つ、通常のFlowレベルbindingです。解決済みの値がToolの input 引数になります。その後になって初めて、transformexpr を2回目にparseして評価します。その時点では steps/env/secret/now は単に消えています。
${{ steps.… }}${{ env.… }}${{ secret.… }}${{ now }}expr 文字列そのものの 内側 に書いても解決されると期待しないでください。そのテキストは純粋なToolによって再parseされますが、そのToolからはそれらが見えません(secret. はthrowし、それ以外は空/undefinedとして読まれます)。動的な値はまず with.input: を通してください(完全なflowスコープで、Riderが1回だけ評価)。そのうえで、expr からは input.<name> として参照します。

使い方

input: は完全なflowスコープで組み立て、expr: は裸の input.* のみを使う文字列として書く。この2スロット形が、Toolの契約に一致することが保証されているパターンです。
- id: shape
  use: transform
  with:
    input: ${{ { price: steps.lookup.output.price, threshold: input.threshold } }}
    expr: "input.price < input.threshold"
.step("shape", {
  use: "transform",
  withRaw: {
    input: "${{ { price: steps.lookup.output.price, threshold: input.threshold } }}",
    expr: "input.price < input.threshold",
  },
})
SDKは、closure全体を単一の expr bindingへ直接loweringする transform(projection) 便利ヘルパーもexportしています(packages/sdk/src/index.ts)。例: transform((c) => c.input.price < c.input.threshold)。使う前に慎重に追跡してください。lowering後のテキスト(input.price < input.threshold)は単一の完全な ${{ }} ブロックなので、Riderはそれをflowレベルで評価します。そこでは input.* はflow自身の入力を意味し、この手順の入力ではありません。そしてToolには文字列ではなく、すでに評価済みの booleanexpr として渡されます。これは exprz.string().min(1) スキーマと衝突します。往復後も確実に残る唯一の形は、すべてのフィールドが数値またはbooleanであるオブジェクトリテラルclosuretransform((c) => ({ price: c.steps.lookup.output.price * 2 })))です。SDKのオブジェクトリテラルloweringは各フィールドをそれぞれ独自の ${{ }} ブロックで再ラップし、Riderのtemplate stringifierは数値/booleanをクォートなしでrenderします。これは、Toolが再parseできる有効な構文にたまたまなります。文字列値のフィールド(たとえば c.now を使うもの)は 残りません。stringifierがそれもクォートなしでemitするため、Toolによる再parse時に無効な構文になります。これがend-to-endで検証されるまでは、ごく単純な全数値projection以外では、上記の明示的な2スロット StepDef 形を優先してください。
コンテキストが構築されれば、transformrun は1行です。
run: async ({ expr, input }) => {
  const ast = parseOrThrow(expr);
  const ctx = pureEvalContext((input ?? {}) as Record<string, JsonValue>);
  return { value: asJson(evaluate(ast, ctx)) };
},
expr は必須です(z.string().min(1))。input は省略すると {} がデフォルトになるため、input: bindingのない裸の transform 手順で expr: "input" を評価すると {} になります。

jq — 小さなサブセットであり、本物のjqではない

jq は本物のjqを埋め込んだもの ではありません。一般的な「レスポンスをreshapeする」ケースだけを扱う、手書きのgrammarです。それ以上のものではありません。
path := '.' | ('.' key | '[' index ']' | '[]')*
pipe := path ('|' fn)*
fn   := 'length' | 'keys' | 'values'
  • .key はオブジェクトフィールドへ移動します。
  • [N] は配列indexへ移動します。
  • [] は配列のすべての要素に対して、pathの残りを map します。
  • 任意の数の | length| keys| values stageを、前の結果に対して左から右に適用できます(.a | keys | length は有効です)。ただし、この3つの関数名だけが存在します。それ以外は jq: unsupported pipe "<fn>" をthrowします。
- id: extract
  use: jq
  with:
    value: ${{ steps.lookup.output }}
    filter: ".results[].price"
- id: count
  use: jq
  with:
    value: ${{ steps.lookup.output }}
    filter: ".results | keys | length"   # chained: object → its sorted key list → count
{ "does": {} } に対する .does.not.exist は、throwされるエラーではなく null を返します。navigationは undefined に当たるとすぐ停止し、そこから下は null を返します。配列の末尾を越える [5] も同じです。これは意図的に寛容な仕様です。上流のshapeが少し変わっただけで、jq 手順がRunを失敗させることはありません。
本物のjqでは、非配列に対して [] をmapするとエラーになります(または ? 付きなら何もyieldしません)。このサブセットは代わりに、式全体に対して null を返します。スカラーに対する [] が黙ってno-opになることを当てにしないでください。それが重要なら、上流でshapeを確認してください。
| keys はplain objectでないもの(配列を含む)に対して [] を返します。そして返すkeyは挿入順ではなく sorted です。| values はオブジェクトに対してそれを反映します(keyでsort)。ただし 配列 に対しては配列を そのまま 返し、それ以外には [] を返します。つまり .list | values はno-opですが、.list | keys[] へ潰れます。逆に覚えやすい箇所です。
path grammarのkey tokenは [A-Za-z_$][\w$]* です。["odd key"] / .["key-with-dash"] 形式はありません。スペース、dash、dotを含むJSON keyには、この jq ではまったく到達できません。上流のshapeを制御できるなら、keyはidentifier-safeにしてください。そうでなければ、先に transform でreshapeしてください。
tokenは .key[N][] をscanして見つけられます。先頭dotのない裸の key(例: .items[].price ではなく items[].price)はtokenizerにmatchせず、rejectされるのではなく黙ってskipされます。path segmentは必ず . で始めてください。
jq のスコープは transform よりさらに狭くなります。ここには式言語が一切なく、input.*/steps.* 参照もありません。filter は一度書くplain stringであり、対象になるデータはflowレベルで value: に束縛したJSONだけです(そこでは完全なスコープを使えます。transforminput: と同じです)。
transform(...)http.get(...)browser.tool(...) のような jq(...) SDK builderはありません(packages/sdk/src/index.ts はこの3つのhelperだけを定義しています)。代わりに汎用の StepDef 形を使ってください。
.step("extract", {
  use: "jq",
  withRaw: { value: "${{ steps.lookup.output }}", filter: ".results[].price" },
})

template{{ name }} 補間

template は、すべての {{ name }} プレースホルダーを vars[name] の文字列表現で置き換えて文字列をrenderします。
/\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g
- id: message
  use: template
  with:
    vars: ${{ { name: steps.lookup.output.user, count: steps.count.output } }}
    template: "Hello {{ name }}, you have {{ count }} results."
placeholder名は flat identifier でなければなりません。{{ user.name }} はregexにmatchしないため(character classに . がない)、{{ }} の内側のdotted pathは補間されず、literal textとしてそのまま残ります。templateの中でdotを使って掘るのではなく、先にネストした値を vars へflattenしてください(たとえば transformjq を使います)。
存在しない変数は、黙って空文字列になるのではなく hard error です。
if (!Object.hasOwn(bag, name)) throw new Error(`template: missing var "${name}"`);
これは意図的です。var名のtypoで Hello , you have results. と黙ってrenderされるtemplateは、大きな音を立てて失敗するRunより悪いからです。template文字列内のすべての {{ name }} には、vars に対応するkeyがなければなりません。 解決された値の文字列化は、JSON型ごとに1つの規則に従います。
値の型render結果
stringそのものをそのまま。escapeはしません(HTML/URL/shell-safetyは下流側の責任です)
nullliteral textの null
object / arrayJSON.stringify(v)
number / booleanString(v)
template(...) SDK builderもありません。理由は上の jq と同じです。汎用の StepDef 形を使ってください。
.step("message", {
  use: "template",
  withRaw: {
    vars: "${{ { name: steps.lookup.output.user, count: steps.count.output } }}",
    template: "Hello {{ name }}, you have {{ count }} results.",
  },
})

関連

式言語

transformexpr(およびすべてのflowレベルbinding)が利用する、reference set、operator、組み込み関数の完全なリファレンスです。

標準Pack

std/transform が他のfirst-party Packの中でどこに位置するか、そしてcapability行が「none (pure)」になっている理由です。

カスタムTool

defineTool を使って、純粋な transform 形のToolを自分で作成します。この3つが実装しているものと同じ契約です。

シークレット — 境界

pureEvalContextsecret() が値を返すのではなくthrowする理由です。純粋なToolは、シークレットを解決するレイヤーになってはなりません。