@warble/ir-spec 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/ir-schema.md +236 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The Warble IR (`warble_ir_version`) as a resolvable npm package.
|
|
4
4
|
|
|
5
|
-
**This package's version *is* the payload.** `
|
|
6
|
-
|
|
5
|
+
**This package's version *is* the payload.** IR `x.y` publishes as npm version `x.y.0`, with the
|
|
6
|
+
patch component always zero — so this package's own version on the registry tells you which
|
|
7
|
+
`warble_ir_version` it describes, and the bundled `ir-schema.md` is that IR's spec as it stood. Everything else in
|
|
7
8
|
the package — the bundled [`ir-schema.md`](./ir-schema.md) (a copy of
|
|
8
9
|
[`docs/spec/ir-schema.md`](https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md)) and
|
|
9
10
|
the `IR_VERSION` constant exported from `index.js` — is documentation, not enforcement.
|
|
@@ -12,8 +13,8 @@ the `IR_VERSION` constant exported from `index.js` — is documentation, not enf
|
|
|
12
13
|
|
|
13
14
|
Before this package, the IR version a published dispatcher (`@warble/claude-agent-sdk`,
|
|
14
15
|
`@warble/codex-local`) accepts was discoverable only by reading its source. Both dispatchers now
|
|
15
|
-
declare `@warble/ir-spec` as a `peerDependencies` range (`
|
|
16
|
-
`"warble": { "irVersion": "
|
|
16
|
+
declare `@warble/ir-spec` as a `peerDependencies` range (`x.y.x`) plus an advisory
|
|
17
|
+
`"warble": { "irVersion": "x.y" }` field, so a consumer — or npm's own resolver — can see which IR a
|
|
17
18
|
dispatcher speaks without opening it.
|
|
18
19
|
|
|
19
20
|
**This package is not meant to be imported.** A `peerDependency` is a declaration, not a dependency
|
package/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `docs/spec/ir-schema.md` (bundled here as `ir-schema.md`) for why this is exported without being
|
|
4
4
|
* meant to be imported by a dispatcher.
|
|
5
5
|
*/
|
|
6
|
-
export declare const IR_VERSION: "0.
|
|
6
|
+
export declare const IR_VERSION: "0.7";
|
|
7
7
|
|
|
8
|
-
declare const _default: { IR_VERSION: "0.
|
|
8
|
+
declare const _default: { IR_VERSION: "0.7" };
|
|
9
9
|
export default _default;
|
package/index.js
CHANGED
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
* exported for tooling that wants the value without re-parsing `package.json` (e.g. a script
|
|
9
9
|
* checking a dispatcher's declared peer range against the spec it names).
|
|
10
10
|
*/
|
|
11
|
-
export const IR_VERSION = "0.
|
|
11
|
+
export const IR_VERSION = "0.7";
|
|
12
12
|
|
|
13
13
|
export default { IR_VERSION };
|
package/ir-schema.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Warble IR — the compile contract (`warble_ir_version: 0.
|
|
1
|
+
# Warble IR — the compile contract (`warble_ir_version: 0.7`)
|
|
2
2
|
|
|
3
3
|
The IR is the **language-neutral seam** between the Warble front-end (`warble compile`) and any
|
|
4
4
|
back-end. The v1 reference back-end is the Claude Code CLI target (`warble dispatch`, Rust); other
|
|
@@ -6,7 +6,7 @@ runtimes are other thin back-ends. Both sides depend only on this document — n
|
|
|
6
6
|
internals.
|
|
7
7
|
|
|
8
8
|
`warble compile <project-dir> -o ir.json` reads a Warble project (profile + components +
|
|
9
|
-
context binding) and emits **one** IR JSON document with `"warble_ir_version": "0.
|
|
9
|
+
context binding) and emits **one** IR JSON document with `"warble_ir_version": "0.7"` — the
|
|
10
10
|
current, live contract the compiler emits today. (Earlier drafts of this doc kept the per-step-tier
|
|
11
11
|
shape in a separate "v0.2 (proposed)" section; that has been folded into the contract below now
|
|
12
12
|
that it is implemented and wired into the built core/dispatcher.) The shape below is what the
|
|
@@ -41,11 +41,11 @@ version on anything else — there is no best-effort or partial parse of an unre
|
|
|
41
41
|
|
|
42
42
|
| Consumer | Accepted `warble_ir_version` | Where the accepted version is declared |
|
|
43
43
|
| --- | --- | --- |
|
|
44
|
-
| `core` (`warble compile`) | emits `0.
|
|
45
|
-
| `dispatcher/claude-code-cli` | `0.
|
|
46
|
-
| `dispatcher/vercel` | `0.
|
|
47
|
-
| `dispatcher/claude-agent-sdk` | `0.
|
|
48
|
-
| `dispatcher/codex-local` | `0.
|
|
44
|
+
| `core` (`warble compile`) | emits `0.7` | the `"warble_ir_version"` literal in `core/src/compile.rs` |
|
|
45
|
+
| `dispatcher/claude-code-cli` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/claude-code-cli/src/ir.rs` |
|
|
46
|
+
| `dispatcher/vercel` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/vercel/src/emit.rs` |
|
|
47
|
+
| `dispatcher/claude-agent-sdk` | `0.7` | `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts` |
|
|
48
|
+
| `dispatcher/codex-local` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/codex-local/src/ir.ts` |
|
|
49
49
|
|
|
50
50
|
Each back-end copies this value rather than importing it from `core` or from another back-end: a
|
|
51
51
|
back-end shouldn't need a Rust dependency edge just to know a version string, and independent copies
|
|
@@ -62,7 +62,7 @@ is informational, not itself an input enforcement check.
|
|
|
62
62
|
`@warble/claude-agent-sdk` and `@warble/codex-local` additionally each declare a `peerDependencies`
|
|
63
63
|
entry on [`@warble/ir-spec`](../../packages/ir-spec) — a dedicated npm package whose own version *is*
|
|
64
64
|
the IR version (see [IR version to npm version mapping](#ir-version-to-npm-version-mapping) below) —
|
|
65
|
-
plus an advisory `"warble": { "irVersion": "0.
|
|
65
|
+
plus an advisory `"warble": { "irVersion": "0.7" }` field in the same `package.json`. This makes the
|
|
66
66
|
IR version a dispatcher speaks visible in the npm dependency graph without opening the package.
|
|
67
67
|
**Neither dispatcher imports `@warble/ir-spec`** — the peer is a declaration, not a dependency edge,
|
|
68
68
|
and each dispatcher keeps enforcing its own copy of `SUPPORTED_IR_VERSION`(S) above. `@warble/ir-spec`
|
|
@@ -70,8 +70,12 @@ also bundles this document itself (as `ir-schema.md`, alongside `index.js`/`inde
|
|
|
70
70
|
snapshot for the IR version it publishes — a published npm version is immutable, so a snapshot as of
|
|
71
71
|
that version is worth more than a live link back to this file on `main`, which points at whatever the
|
|
72
72
|
spec later became. `just publish-check` fails if `packages/ir-spec/ir-schema.md` and this document
|
|
73
|
-
ever diverge, since a publish is exactly the point that drift becomes irreversible
|
|
74
|
-
`
|
|
73
|
+
ever diverge, since a publish is exactly the point that drift becomes irreversible. Keeping it in
|
|
74
|
+
sync is not a manual step: `npm run gen:reference` in `docs/site/` writes it from this document
|
|
75
|
+
alongside the generated reference pages, and the docs-site CI drift check covers it on the same
|
|
76
|
+
footing, so editing this document and regenerating is all there is to it. The snapshot has to be a
|
|
77
|
+
real committed file rather than a symlink — `npm pack` does not dereference one, it silently omits
|
|
78
|
+
the file and ships a package with no spec in it. Counting the
|
|
75
79
|
producer (what `core` actually emits) alongside every independent consumer/advisory copy, the
|
|
76
80
|
`@warble/ir-spec` package's own version, its `index.js` and `index.d.ts` version constants/literals,
|
|
77
81
|
both dispatchers' peer declarations, both dispatchers' advisory `warble.irVersion` fields, and the
|
|
@@ -88,7 +92,7 @@ spec title, there are **eighteen** locations that must agree:
|
|
|
88
92
|
| 7 | `dispatcher/vercel/src/emit.rs` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
|
|
89
93
|
| 8 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MIN_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
|
|
90
94
|
| 9 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
|
|
91
|
-
| 10 | This document's title (`warble_ir_version: 0.
|
|
95
|
+
| 10 | This document's title (`warble_ir_version: 0.7`) | Spec | `core/tests/ir_version_lockstep_tests.rs` |
|
|
92
96
|
| 11 | `packages/ir-spec/package.json` `"version"` (mapped `x.y` -> `x.y.0`) | Producer (npm) | `core/tests/ir_version_lockstep_tests.rs` |
|
|
93
97
|
| 12 | `packages/ir-spec/index.js` `IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
|
|
94
98
|
| 13 | `dispatcher/claude-agent-sdk/package.json` `peerDependencies["@warble/ir-spec"]` (mapped `x.y` -> `x.y.x`) | Declaration | `core/tests/ir_version_lockstep_tests.rs` |
|
|
@@ -101,14 +105,14 @@ spec title, there are **eighteen** locations that must agree:
|
|
|
101
105
|
This table's scope is contract-bearing declarations — constants and literals something actually
|
|
102
106
|
compares against — not every place `warble_ir_version` appears in prose. Each back-end's `ir` module
|
|
103
107
|
doc comment also mentions the current version for a human skimming the file (e.g. `//! Typed view of
|
|
104
|
-
the Warble IR (warble_ir_version: 0.
|
|
108
|
+
the Warble IR (warble_ir_version: 0.7)`); nothing checks those comments, and a version bump can leave
|
|
105
109
|
them stale without breaking anything. They are deliberately not extra rows — update them as a
|
|
106
110
|
courtesy to the reader, not because a test requires it.
|
|
107
111
|
|
|
108
112
|
`core/tests/ir_version_lockstep_tests.rs` is the sole cross-target lockstep owner: it text-parses
|
|
109
113
|
every contract-bearing declaration above and asserts that each equals the version core emits (rows
|
|
110
|
-
11
|
|
111
|
-
copies of the raw `x.y` string). The target-local `ir_version_tests.rs` files only exercise their own
|
|
114
|
+
11, 13, and 15 first pass the emitted version through the mapping below, since they are npm
|
|
115
|
+
identifiers, not copies of the raw `x.y` string; the table marks which ones). The target-local `ir_version_tests.rs` files only exercise their own
|
|
112
116
|
unsupported-version rejection and no-partial-output behavior; they do not scrape other targets'
|
|
113
117
|
sources. (`core/src/lib.rs`'s doctest and `core/tests/compile_tests.rs` also assert row 1's literal
|
|
114
118
|
directly, but aren't listed as separate lockstep-tested locations — they self-guard, failing the
|
|
@@ -123,8 +127,8 @@ only when `warble_ir_version` moves, on its own release line. The mapping from I
|
|
|
123
127
|
version is fixed and mechanical:
|
|
124
128
|
|
|
125
129
|
- IR version `x.y` maps to npm version `x.y.0` — the patch component is **always** zero.
|
|
126
|
-
- A dispatcher's `peerDependencies["@warble/ir-spec"]` range is `x.y.x` — e.g. IR `0.
|
|
127
|
-
version `0.
|
|
130
|
+
- A dispatcher's `peerDependencies["@warble/ir-spec"]` range is `x.y.x` — e.g. IR `0.7` is npm
|
|
131
|
+
version `0.7.0` and peer range `0.7.x`.
|
|
128
132
|
- An IR version with anything other than exactly two dot-separated numeric components (a three-part
|
|
129
133
|
`x.y.z`, or a non-numeric component) has **no defined mapping** and is rejected by
|
|
130
134
|
`core/tests/ir_version_lockstep_tests.rs` at the point it tries to compute rows 11, 13, and 15 — it
|
|
@@ -165,7 +169,7 @@ back-end accepts, and must be regenerated rather than merely re-read.
|
|
|
165
169
|
|
|
166
170
|
```jsonc
|
|
167
171
|
{
|
|
168
|
-
"warble_ir_version": "0.
|
|
172
|
+
"warble_ir_version": "0.7",
|
|
169
173
|
"profile": "orders-analytics", // profile.yml `profile:`
|
|
170
174
|
"context_binding": { // resolved from profile `context:` + context/binding.yml
|
|
171
175
|
"project": "examples/jaffle-wren", // coarse path to a wren project (retained for back-ends)
|
|
@@ -188,15 +192,20 @@ back-end accepts, and must be regenerated rather than merely re-read.
|
|
|
188
192
|
// so pre-consumer IRs are byte-identical.
|
|
189
193
|
}
|
|
190
194
|
},
|
|
191
|
-
"config": {}, // reserved profile-level config block;
|
|
195
|
+
"config": {}, // reserved profile-level config block; optionally carries `capability_ceiling`
|
|
196
|
+
"slots": [ // additive; present only when the PROFILE declares slots — see `slots` below
|
|
197
|
+
{ "name": "plan_mode", "default": "off", "present_when": "plan_mode_available",
|
|
198
|
+
"variants": { "on": "…rendered…", "off": "…rendered…" } }
|
|
199
|
+
],
|
|
192
200
|
"components": [ /* one resolved component node, see below */ ]
|
|
193
201
|
}
|
|
194
202
|
```
|
|
195
203
|
|
|
196
|
-
### `config` —
|
|
204
|
+
### `config` — one optional field in 0.7
|
|
197
205
|
|
|
198
|
-
`config` carried one field, `tier_policy`, from the first IR through `0.5`. `0.6`
|
|
199
|
-
the block
|
|
206
|
+
`config` carried one field, `tier_policy`, from the first IR through `0.5`. `0.6` removed it, and
|
|
207
|
+
the block was emitted as `{}` until the addition documented below gave it its first surviving
|
|
208
|
+
field.
|
|
200
209
|
|
|
201
210
|
`tier_policy` was a profile-wide tier stance (`cost_sensitive`) that the compiler was meant to
|
|
202
211
|
resolve into per-step tiers. Only the field ever landed. No back-end read it, its value was never
|
|
@@ -214,6 +223,34 @@ control (see [`profile-schema.md`](./authoring.md#61-tiers-not-model-names)).
|
|
|
214
223
|
The block itself stays so that profile-level config which *can* be honored is an additive change
|
|
215
224
|
rather than the reintroduction of a removed key.
|
|
216
225
|
|
|
226
|
+
#### `capability_ceiling` (additive since v0.7)
|
|
227
|
+
|
|
228
|
+
When a profile declares `capability_ceiling` — a list of capability strings — the compiler carries
|
|
229
|
+
it into `config` verbatim:
|
|
230
|
+
|
|
231
|
+
```jsonc
|
|
232
|
+
"config": {
|
|
233
|
+
"capability_ceiling": ["sql_execution", "chart_rendering"]
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A profile that omits `capability_ceiling` compiles exactly as before this field existed: `config`
|
|
238
|
+
stays `{}`.
|
|
239
|
+
|
|
240
|
+
The ceiling is a compile-time *authorization* check ("may a component of this profile require
|
|
241
|
+
this capability at all"), not the dispatch-time capability *resolution* every component's
|
|
242
|
+
`required_capabilities` already goes through against a target's profile (native / realize-via /
|
|
243
|
+
degrade / fail — see the compile-time checks table below). The two gates are independent: a
|
|
244
|
+
capability can pass the ceiling and still fail resolution against a given target, and the ceiling
|
|
245
|
+
is evaluated once at compile time regardless of which target the IR is later dispatched against.
|
|
246
|
+
A component whose `required_capabilities` names anything outside the profile's declared ceiling
|
|
247
|
+
fails compile before resolution is ever attempted.
|
|
248
|
+
|
|
249
|
+
Containment is exact string-set matching — no hierarchy or prefix inference on the `:` qualifier
|
|
250
|
+
some capability names use. A ceiling of `sql_execution` does **not** admit a component requiring
|
|
251
|
+
`sql_execution:read_only`; the qualifier is not an ordering, and a profile that means to allow the
|
|
252
|
+
narrower capability must list it explicitly alongside (or instead of) the broader one.
|
|
253
|
+
|
|
217
254
|
## Component node (resolved: component fields ⊕ supported profile mount fields)
|
|
218
255
|
|
|
219
256
|
```jsonc
|
|
@@ -251,6 +288,13 @@ rather than the reintroduction of a removed key.
|
|
|
251
288
|
]
|
|
252
289
|
},
|
|
253
290
|
"brief": "…shared framing for every step, placeholders substituted…", // additive; present only when authored — see below
|
|
291
|
+
"slots": [ // additive; present only when the component declares slots — see below
|
|
292
|
+
{ "name": "verification", "default": "base",
|
|
293
|
+
"variants": { "base": "…rendered…", "terse": "…rendered…" } }
|
|
294
|
+
],
|
|
295
|
+
"assets": [ // additive; present only when the component declares assets — see below
|
|
296
|
+
{ "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
|
|
297
|
+
],
|
|
254
298
|
"prompt_fragment": "…rendered skill instructions…", // see §prompt rendering
|
|
255
299
|
"llm_calls": [ // per-step tier, order preserved from component llm_steps
|
|
256
300
|
{ "name": "plan_dashboard", "tier": "strong", "conditional": false, "when": null,
|
|
@@ -261,6 +305,10 @@ rather than the reintroduction of a removed key.
|
|
|
261
305
|
"prompt": "<compose_layout.md rendered>" }
|
|
262
306
|
// a conditional step instead carries e.g. "conditional": true, "when": { "guard": "on_failure", "target": "generate_sql" }
|
|
263
307
|
// — see `llm_calls[].when` below
|
|
308
|
+
// a step may also narrow its tool boundary and/or mark its artifact exclusive, e.g.
|
|
309
|
+
// "capabilities": ["sql_execution:read_only"], "produces_exclusive": true — both additive,
|
|
310
|
+
// omitted here because neither step below declares them; see `llm_calls[].capabilities` /
|
|
311
|
+
// `llm_calls[].produces_exclusive` below
|
|
264
312
|
],
|
|
265
313
|
"guardrails": [ // resolved; `locked` is the normalized lock-state
|
|
266
314
|
{ "name": "read_only_execution", "locked": true }
|
|
@@ -451,6 +499,40 @@ an IR version bump under the policy above. As of this writing:
|
|
|
451
499
|
A back-end that ignores `when` must do so as a documented, deliberate choice (as `claude-code-cli`
|
|
452
500
|
does above) — never as a silent fallback for a guard shape it was simply never taught to recognize.
|
|
453
501
|
|
|
502
|
+
#### `llm_calls[].capabilities` (additive)
|
|
503
|
+
|
|
504
|
+
`string[]`, an exact-string subset of the component's own `required_capabilities` — **omitted
|
|
505
|
+
entirely (not present as a key, not `null`) unless the step declares it**. A step that narrows its
|
|
506
|
+
capabilities compiles to `"capabilities": [...]` on that call's IR entry; a step that doesn't
|
|
507
|
+
compiles to exactly the IR it produced before this field existed. Compile validates the subset
|
|
508
|
+
relationship (exact string containment, no hierarchy) against the component's declared
|
|
509
|
+
`required_capabilities` and loud-fails, naming both the step and the offending capability, when a
|
|
510
|
+
step names one outside that set. This is a compile-time declaration of what a step is allowed to
|
|
511
|
+
need, not a runtime identity or actor concept: how a back-end scopes a step's own call is already a
|
|
512
|
+
per-back-end mechanism this field says nothing about.
|
|
513
|
+
|
|
514
|
+
```jsonc
|
|
515
|
+
{ "name": "plan_query", "tier": "strong", "conditional": false, "when": null,
|
|
516
|
+
"consumes": [], "produces": "query_plan",
|
|
517
|
+
"capabilities": ["render_contract"],
|
|
518
|
+
"prompt": "…" }
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
#### `llm_calls[].produces_exclusive` (additive)
|
|
522
|
+
|
|
523
|
+
`bool` — **omitted entirely unless `true`.** A step's plain `produces` name is unchanged; this is a
|
|
524
|
+
separate provenance marker on that same artifact, meaning only the producing step is expected to
|
|
525
|
+
write it. Kept as an additive boolean rather than reshaping `produces` into an object. Like
|
|
526
|
+
`capabilities` above, it says nothing about *who* enforces exclusivity — that is whatever already
|
|
527
|
+
scopes a step's own call at the back-end.
|
|
528
|
+
|
|
529
|
+
```jsonc
|
|
530
|
+
{ "name": "run_query", "tier": "cheap", "conditional": false, "when": null,
|
|
531
|
+
"consumes": ["query_plan"], "produces": "query_result",
|
|
532
|
+
"produces_exclusive": true,
|
|
533
|
+
"prompt": "…" }
|
|
534
|
+
```
|
|
535
|
+
|
|
454
536
|
#### `guardrails[].scope` / `threshold` and the `locked`/`overridable` normalization
|
|
455
537
|
|
|
456
538
|
`scope` and `threshold` are passthrough fields — each is present in the resolved IR **only when
|
|
@@ -504,6 +586,14 @@ Optional free-form text, authored on the component (or replaced wholesale by a p
|
|
|
504
586
|
same `{{project}}` / `{{project_name}}` placeholder substitution as step prompts (§Prompt
|
|
505
587
|
rendering). Emitted **once, on the node itself** — never per-step — and present in the IR only when
|
|
506
588
|
authored; a component with no `brief` produces IR byte-identical to before this field existed.
|
|
589
|
+
|
|
590
|
+
The emitted value may be a **merge of two authored layers**: a profile's
|
|
591
|
+
[`system_prompt`](./authoring.md#system_prompt--profile-level-framing-for-every-component) — shared
|
|
592
|
+
by every component that profile mounts — followed by a blank line and the component's own effective
|
|
593
|
+
brief. Either layer may be absent; with neither, the key is absent as described above. The merge
|
|
594
|
+
happens at compile time and deliberately adds no IR field, so a back-end that already reads `brief`
|
|
595
|
+
needs no change and the wire contract is unversioned by it. The trade-off is that the IR does not
|
|
596
|
+
record which half came from where.
|
|
507
597
|
Every back-end that assembles a system prompt places it in the same position: after the
|
|
508
598
|
machine-generated preamble, before the body, on both the driver and every subagent. See
|
|
509
599
|
[`profile-schema.md`](./authoring.md#brief--authored-framing-shared-across-every-step) for the
|
|
@@ -534,6 +624,92 @@ from the node's shape instead. See
|
|
|
534
624
|
|
|
535
625
|
---
|
|
536
626
|
|
|
627
|
+
#### `slots` (additive since v0.7)
|
|
628
|
+
|
|
629
|
+
`object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
|
|
630
|
+
`slots:`.** A component without any compiles to exactly the IR it did before this field existed.
|
|
631
|
+
|
|
632
|
+
Each entry carries a `name`, a required `default`, a `variants` object, and `present_when` only when
|
|
633
|
+
authored:
|
|
634
|
+
|
|
635
|
+
```jsonc
|
|
636
|
+
"slots": [
|
|
637
|
+
{
|
|
638
|
+
"name": "verification",
|
|
639
|
+
"default": "base", // always present; names a key of "variants"
|
|
640
|
+
"present_when": "verification_enabled", // omitted entirely unless authored
|
|
641
|
+
"variants": {
|
|
642
|
+
"base": "…rendered text, placeholders substituted…",
|
|
643
|
+
"terse": "…rendered text…"
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
]
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**Every variant is carried; none is selected.** This is the deliberate division of labour: compile
|
|
650
|
+
*transports* the alternatives, dispatch *chooses* between them. The consequence is that the IR stays
|
|
651
|
+
a complete description of what the model could be told — a reader can enumerate every wording without
|
|
652
|
+
running anything — at the cost of a larger document. Selecting at compile would have been smaller and
|
|
653
|
+
would have made the IR silent about the paths not taken.
|
|
654
|
+
|
|
655
|
+
**Variant keys are opaque.** Warble does not interpret, validate, or order them; the mapping from a
|
|
656
|
+
bound model (or any other runtime fact) to a key belongs to the host's dispatch fragment. A
|
|
657
|
+
consumer that hard-codes key names is coupling itself to one project's convention, not to this
|
|
658
|
+
schema.
|
|
659
|
+
|
|
660
|
+
**A slot is referenced from prompt text as `{{ slot.<name> }}`.** Deliberately a different syntax
|
|
661
|
+
from the `{{project}}` / `{{project_name}}` value substitutions, so a slot reference and a mistyped
|
|
662
|
+
placeholder are distinguishable; it is also Jinja-native attribute access, so the renderer can be
|
|
663
|
+
replaced without touching authored files. Variant text goes through the same substitution as a step
|
|
664
|
+
body or a `brief`.
|
|
665
|
+
|
|
666
|
+
**Names are unique project-wide** — a profile-level slot may not reuse a component-level name — so a
|
|
667
|
+
consumer can resolve `{{ slot.<name> }}` against a single flat name space rather than having to know
|
|
668
|
+
which layer the surrounding text came from.
|
|
669
|
+
|
|
670
|
+
**The same shape appears at two levels.** A component's slots are on its component node and belong
|
|
671
|
+
to that component's own prompt text (its steps and `brief`); a profile's slots are the **top-level
|
|
672
|
+
`slots` array** and belong to the profile's `system_prompt`. They are not merged, and a profile's
|
|
673
|
+
slots are deliberately not copied onto each component node — the flat name space is what makes that
|
|
674
|
+
unnecessary. Compile refuses a name declared at both levels rather than letting one shadow the
|
|
675
|
+
other, so a consumer never has to implement a precedence rule.
|
|
676
|
+
|
|
677
|
+
**`present_when` is a condition on the slot's presence, unrelated to `llm_calls[].when`.** When it
|
|
678
|
+
does not hold, the slot is removed rather than filled with any variant: an instruction describing a
|
|
679
|
+
withheld capability is worse than no instruction. Evaluating it is the host's job; compile only
|
|
680
|
+
carries it.
|
|
681
|
+
|
|
682
|
+
#### `assets` (additive since v0.7)
|
|
683
|
+
|
|
684
|
+
`object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
|
|
685
|
+
`assets:`.** Each entry is exactly `{path, hash, bytes}`:
|
|
686
|
+
|
|
687
|
+
```jsonc
|
|
688
|
+
"assets": [
|
|
689
|
+
{ "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
|
|
690
|
+
]
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`path` is as authored, relative to the component directory. `hash` is `sha256:<hex>` over the
|
|
694
|
+
file's bytes and `bytes` its length; **both are computed at compile and neither is authorable** — a
|
|
695
|
+
`hash:` or `bytes:` written in `component.yml` is a parse error. An author-supplied fingerprint
|
|
696
|
+
could only rot, and silently replacing one would leave the author trusting a field that means
|
|
697
|
+
nothing.
|
|
698
|
+
|
|
699
|
+
**Content is not carried.** This is the deliberate contrast with [`slots`](#slots-additive-since-v07)
|
|
700
|
+
above, and the two together define the line: a slot variant is prompt text, so its content is read
|
|
701
|
+
into the IR and composed into a prompt; an asset is a file that must be present on disk when the
|
|
702
|
+
component runs, so only its identity travels. Embedding binaries — or dozens of stylesheets — in
|
|
703
|
+
every IR is not worth the size, and `bytes` exists so a consumer can price landing them first.
|
|
704
|
+
|
|
705
|
+
**Landing is the target's business.** Copy, symlink, or pre-bake into an image: the IR says what is
|
|
706
|
+
required and how to recognise it, never how to provide it. A consumer that finds an asset missing
|
|
707
|
+
or hash-mismatched at dispatch time has a real failure to report, but that check is not compile's.
|
|
708
|
+
|
|
709
|
+
**The hash is content identity, not a version number.** It supports "is this the same file", never
|
|
710
|
+
"is this newer" — deliberately the same stance freshness takes elsewhere in this spec. Do not infer
|
|
711
|
+
ordering from it.
|
|
712
|
+
|
|
537
713
|
## Resolution rules (front-end `warble compile` must implement)
|
|
538
714
|
|
|
539
715
|
1. **Parse** `profile.yml`, each mounted `components/<id>/component.yml`, and `context/binding.yml`.
|
|
@@ -595,9 +771,36 @@ from the node's shape instead. See
|
|
|
595
771
|
| `when` guard without `conditional` | `llm_steps[].when` present but `conditional` is not `true` | `step '<name>' on component '<id>' declares a 'when' guard but is not 'conditional: true' …` |
|
|
596
772
|
| unknown `when` guard name | `llm_steps[].when.guard` not in the closed 3-name vocabulary (`on_failure`/`on_flag`/`on_missing`) | `unknown guard '<name>' in step '<step>' of component '<id>' …` |
|
|
597
773
|
| `when` guard invalid target | `llm_steps[].when.target` is empty, or `guard: on_flag` with a non-dotted target | `guard '<name>' in step '<step>' of component '<id>' has an empty target` / `… expects a dotted 'artifact.field' target …` |
|
|
774
|
+
| duplicate step name | two `llm_steps[]` entries share the same `name` | `duplicate step name '<name>' on component '<id>' — step names must be unique within a component` |
|
|
775
|
+
| duplicate `produces` artifact | more than one `llm_steps[]` entry declares the same `produces` name | `duplicate 'produces' artifact '<name>' on component '<id>' — more than one step declares it, most recently '<step>'; each artifact must have exactly one producer` |
|
|
776
|
+
| step consumes its own `produces` | a step's `consumes[]` entry equals that same step's own `produces` | `step '<step>' on component '<id>' consumes '<name>', its own 'produces' artifact — a step cannot consume what it produces; 'consumes' must name an earlier step's output` |
|
|
777
|
+
| step consumes an artifact no earlier step produces | a step's `consumes[]` entry is not the `produces` of any strictly-earlier step in `llm_steps[]` (covers both "no step produces it" and "only a later step produces it") | `step '<step>' on component '<id>' consumes '<name>', which no earlier step produces — add a preceding step with 'produces: <name>', or remove it from 'consumes'` |
|
|
778
|
+
| `on_failure` guard targets a non-earlier step | `when.guard: on_failure` and `when.target` does not name a strictly-earlier step in `llm_steps[]` (the target namespace is step names, not artifacts) | `guard 'on_failure' in step '<step>' of component '<id>' targets step '<target>', which is not a strictly-earlier step of this component — 'on_failure' can only observe the outcome of a step that has already run` |
|
|
779
|
+
| `on_missing` guard targets an unproduced artifact | `when.guard: on_missing` and `when.target` is not the `produces` of any strictly-earlier step (the target namespace is artifact/`produces` names, not step names) | `guard 'on_missing' in step '<step>' of component '<id>' targets artifact '<target>', which no earlier step produces` |
|
|
780
|
+
| `on_flag` guard targets an unproduced artifact | `when.guard: on_flag` and the artifact segment (before the first `.`) of the dotted `when.target` is not the `produces` of any strictly-earlier step | `guard 'on_flag' in step '<step>' of component '<id>' targets '<target>', but artifact '<artifact>' is not produced by any earlier step` |
|
|
781
|
+
| capability outside ceiling | profile declares `config.capability_ceiling` and a mounted component's `required_capabilities` names a capability not in that set (exact-string containment, no hierarchy inference) | `component '<id>' requires capability '<capability>', which is outside the profile's capability_ceiling (<declared set>)` |
|
|
782
|
+
| slot referenced but not declared | prompt text contains `{{ slot.<name> }}` and the component declares no such slot (including the case where it declares none at all) | `component '<id>' references slot '<name>' in its prompt text, which it does not declare (declared: <names>)` / `… but declares no slots` |
|
|
783
|
+
| slot declared but not referenced | a `slots[]` entry no prompt text of that component references | `component '<id>' declares slot '<name>' but no prompt text references '{{ slot.<name> }}'` |
|
|
784
|
+
| slot name unusable in a reference | a `slots[]` entry's `name` does not match `[a-z_][a-z0-9_]*`, so no `{{ slot.<name> }}` could ever resolve to it | `<owner> declares slot '<name>', which is not a usable slot name — a name must match [a-z_][a-z0-9_]* …` |
|
|
785
|
+
| `produces_exclusive` with no artifact | `llm_steps[].produces_exclusive: true` on a step that declares no `produces` | `step '<step>' on component '<id>' declares 'produces_exclusive' but produces no artifact …` |
|
|
786
|
+
| slot with no variants | a `slots[]` entry whose `variants` map is empty | `<owner> declares slot '<name>' with no variants` |
|
|
787
|
+
| slot `default` outside its variants | `slots[].default` names a key absent from that slot's `variants` | `<owner> declares slot '<name>' with default '<key>', which is not one of its variants (<keys>)` |
|
|
788
|
+
| duplicate slot name | two `slots[]` entries on the same component share a `name` | `component '<id>' declares slot '<name>' more than once` |
|
|
789
|
+
| unrecognised `{{ … }}` in prompt text | a step body, `brief`, mount `brief`, `system_prompt` or slot variant contains a `{{ … }}` that is not `project`, `project_name`, or `slot.<name>` | `unrecognised template syntax in <surface>: '{{ … }}' is not a known placeholder … A single brace needs no escaping …` |
|
|
790
|
+
| template statement or comment delimiter | the same surfaces contain `{%` or `{#` | `unrecognised template syntax in <surface>: '{%' is a template statement or comment delimiter, which Warble does not support …` |
|
|
791
|
+
| profile/component slot name collision | a `profile.yml` `slots[]` entry shares a `name` with any mounted component's `slots[]` entry | `profile '<profile>' declares slot '<name>', which component '<id>' also declares — slot names are shared across the whole project, so rename one of them` |
|
|
792
|
+
| profile slot referenced but not declared | `system_prompt` contains `{{ slot.<name> }}` and the profile declares no such slot | `profile '<profile>' references slot '<name>' in its system_prompt, which it does not declare (declared: <names>)` |
|
|
793
|
+
| profile slot declared but not referenced | a profile `slots[]` entry its `system_prompt` never references | `profile '<profile>' declares slot '<name>' but its system_prompt does not reference '{{ slot.<name> }}'` |
|
|
794
|
+
| duplicate profile slot name | two profile `slots[]` entries share a `name` | `profile '<profile>' declares slot '<name>' more than once` |
|
|
795
|
+
| asset path escapes its directory / is missing | an `assets[].path` is absolute, contains `..`, or names no existing file (the shared file-reference rule) | `asset '<path>' must be a relative path inside its own directory …` / `… does not exist: <path>` |
|
|
796
|
+
| authored asset `hash` / `bytes` | `component.yml` supplies either field on an `assets[]` entry; both are compile-computed and not authorable | `unknown field 'hash'` / `unknown field 'bytes'` (serde `deny_unknown_fields`) |
|
|
797
|
+
| slot variant reference escapes its directory / is missing | a `slots[].variants` value is absolute, contains `..`, or names no existing file (the shared file-reference rule, as for `prompt_ref`) | `slot '<name>' variant '<key>' must be a relative path inside its own directory …` / `… does not exist: <path>` |
|
|
598
798
|
|
|
599
799
|
`required_capabilities` is **declared only** in this POC (not enforced by the compiler;
|
|
600
|
-
enforcement is the dispatcher/runtime's job).
|
|
800
|
+
enforcement is the dispatcher/runtime's job) — except against a profile's `config.capability_ceiling`,
|
|
801
|
+
which the compiler does check at compile time (see [`capability_ceiling`](#capability_ceiling-additive-since-v07)
|
|
802
|
+
above). The ceiling check is an authorization gate on what a component may declare; it never
|
|
803
|
+
substitutes for, and is unaffected by, the dispatch-time resolution that still happens later.
|
|
601
804
|
|
|
602
805
|
## Prompt rendering
|
|
603
806
|
|
|
@@ -608,6 +811,16 @@ named by step, with placeholders substituted from coarse context:
|
|
|
608
811
|
|
|
609
812
|
- `{{project}}` → `context_binding.project`
|
|
610
813
|
- `{{project_name}}` → basename of the project path
|
|
814
|
+
- `{{ slot.<name> }}` → **not** substituted here; carried through for dispatch to fill from that
|
|
815
|
+
slot's variants (see [`slots`](#slots-additive-since-v07))
|
|
816
|
+
|
|
817
|
+
Surrounding whitespace inside the braces is ignored. **Any other `{{ … }}`, and any `{%` or `{#`,
|
|
818
|
+
is a compile error** in every prompt-text surface — step bodies, a component or mount `brief`, the
|
|
819
|
+
profile `system_prompt`, and slot variants. Single braces are untouched, so a prompt teaching a
|
|
820
|
+
model to emit JSON needs no escaping. There is deliberately no escape sequence for a literal `{{`:
|
|
821
|
+
the substitution is plain string replacement, not a template engine, so there is nothing to escape
|
|
822
|
+
for — and refusing the unrecognised forms is what lets a real engine replace it later without
|
|
823
|
+
migrating any authored file.
|
|
611
824
|
|
|
612
825
|
Each `llm_calls[]` entry also carries its own **per-step rendered `prompt`** — the same
|
|
613
826
|
substitution as `prompt_fragment`, but rendered **per step and without** the `## <name>` header.
|
|
@@ -657,7 +870,7 @@ Warble differentiator.
|
|
|
657
870
|
`warble compile ./examples/demo-agent -o ir.json` against the demo project in this repo must produce an
|
|
658
871
|
IR equal to `examples/demo-agent/ir.golden.json` (committed alongside, used as the core's fixture test).
|
|
659
872
|
`warble compile ./examples/render-demo -o ir.json` similarly must equal
|
|
660
|
-
`examples/render-demo/ir.golden.json`. Both goldens use the current v0.
|
|
873
|
+
`examples/render-demo/ir.golden.json`. Both goldens use the current v0.7 contract:
|
|
661
874
|
`context_requirements`, `context_precondition`, and `params` are always present (possibly `[]`, as
|
|
662
875
|
on `dashboard`), while `eval` appears only on `generate_dashboard` and `scope: "."` appears only on
|
|
663
876
|
render-demo's authored `artifact_write` guardrail.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warble/ir-spec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Warble IR (warble_ir_version) as a resolvable npm package: a version constant plus the compile-contract schema document. Not a runtime dependency — its version IS the contract.",
|