okengine 0.23.2 → 0.24.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.
Files changed (75) hide show
  1. package/AGENTS.md +5 -1
  2. package/manifest.v1.schema.json +53 -3
  3. package/package.json +2 -1
  4. package/site/content/docs/ai/mcp.mdx +1 -1
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/decide.mdx +95 -417
  7. package/site/content/docs/elements/ai/deciders.mdx +154 -0
  8. package/site/content/docs/elements/ai/index.mdx +2 -1
  9. package/site/content/docs/elements/ai/meta.json +1 -1
  10. package/site/content/docs/reference/cli.mdx +2 -2
  11. package/site/content/docs/reference/configuration.mdx +2 -1
  12. package/site/content/docs/reference/fx.mdx +11 -9
  13. package/src/cli/agents-md.test.ts +8 -2
  14. package/src/cli/decide-certify.test.ts +166 -0
  15. package/src/cli/decide.ts +170 -3
  16. package/src/cli/decision-lock-watch.test.ts +4 -4
  17. package/src/cli/eval.ts +38 -59
  18. package/src/cli/mcp-from-console.ts +1 -1
  19. package/src/cli/registry.ts +45 -9
  20. package/src/compiler/decisions.extract.test.ts +86 -31
  21. package/src/compiler/effects-infer.ts +1 -0
  22. package/src/compiler/extract.ts +239 -24
  23. package/src/config/index.ts +6 -0
  24. package/src/console/server/decisions.test.ts +6 -1
  25. package/src/console/server/decisions.ts +32 -1
  26. package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
  37. package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
  45. package/src/console/ui-next/dist/index.html +1 -1
  46. package/src/console/ui-next/src/client.ts +8 -0
  47. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
  48. package/src/elements/ai/deciders/presets.ts +182 -0
  49. package/src/elements/ai/decisions/bind.ts +6 -5
  50. package/src/elements/ai/decisions/catalog.ts +93 -0
  51. package/src/elements/ai/decisions/certificate.test.ts +47 -19
  52. package/src/elements/ai/decisions/certificate.ts +107 -24
  53. package/src/elements/ai/decisions/certify.ts +104 -9
  54. package/src/elements/ai/decisions/codec.test.ts +142 -0
  55. package/src/elements/ai/decisions/codec.ts +302 -0
  56. package/src/elements/ai/decisions/decide.test.ts +108 -31
  57. package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
  58. package/src/elements/ai/decisions/decider.types.test.ts +62 -0
  59. package/src/elements/ai/decisions/e2e.test.ts +20 -16
  60. package/src/elements/ai/decisions/http.ts +15 -7
  61. package/src/elements/ai/decisions/labels.test.ts +6 -1
  62. package/src/elements/ai/decisions/labels.ts +4 -3
  63. package/src/elements/ai/decisions/openai.live.test.ts +42 -0
  64. package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
  65. package/src/elements/ai/decisions/provider.ts +22 -1
  66. package/src/elements/ai/declare.ts +208 -51
  67. package/src/elements/ai.ts +1 -0
  68. package/src/index.ts +1 -0
  69. package/src/kernel/app.ts +8 -0
  70. package/src/kernel/decision-budget-entry.ts +11 -0
  71. package/src/kernel/element-registries.ts +3 -0
  72. package/src/kernel/fx-decide.ts +276 -88
  73. package/src/manifest/types.ts +38 -2
  74. package/src/release/measure.ts +47 -8
  75. package/src/test/create-test-app.ts +21 -1
@@ -0,0 +1,154 @@
1
+ ---
2
+ title: "Deciders"
3
+ description: "One model that can answer a decision, with its protocol, secret, and certificate."
4
+ icon: "Cpu"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ A decider is the model behind a decision. Two presets are filled in. Every other host passes the protocol, the URL, and what it can answer.
9
+
10
+ <Callout title="The one rule">
11
+ Use `provider: "openrouter"` or `provider: "openai"` when that is the host. Any other host needs
12
+ `driverId`, `baseUrl`, `secret`, and `capabilities`.
13
+ </Callout>
14
+
15
+ ## Quick start
16
+
17
+ <Steps>
18
+
19
+ <Step>
20
+ ### OpenRouter
21
+
22
+ Certificates bind to the dated model id the host echoes.
23
+
24
+ ```typescript
25
+ import { ai } from "okengine";
26
+
27
+ export const jev = ai.decider("jev", {
28
+ provider: "openrouter",
29
+ model: "typesafe/jev-1.13-20260917",
30
+ });
31
+ ```
32
+
33
+ The request is System One: `POST https://openrouter.ai/api/alpha/decisions` with `{ model, state, questions }`. The secret is `OPENROUTER_API_KEY`.
34
+
35
+ </Step>
36
+
37
+ <Step>
38
+ ### OpenAI
39
+
40
+ The certificate binds to the echoed id, stays unpinned, and expires 30 days after certify.
41
+
42
+ ```typescript
43
+ export const luna = ai.decider("luna", {
44
+ provider: "openai",
45
+ model: "gpt-6-luna",
46
+ });
47
+ ```
48
+
49
+ The request is `{ model, input, questions }` at `https://api.openai.com/v1/decisions`. Object state is sent as JSON text. The secret is `OPENAI_API_KEY`.
50
+
51
+ </Step>
52
+
53
+ <Step>
54
+ ### Any other host
55
+
56
+ Name the protocol. Do not invent a preset.
57
+
58
+ ```typescript
59
+ export const local = ai.decider("local", {
60
+ driverId: "systemone",
61
+ baseUrl: "https://decisions.example.com/v1",
62
+ model: "local-decider",
63
+ secret: "DECISION_API_KEY",
64
+ capabilities: { boolean: true, choice: true, score: true, refusal: false },
65
+ region: "self",
66
+ });
67
+ ```
68
+
69
+ `region` and `zdr` are your declaration. The Manifest marks them `declared`.
70
+
71
+ </Step>
72
+
73
+ </Steps>
74
+
75
+ ## Presets
76
+
77
+ | Provider | Protocol | URL | Secret | Pinning | Answers |
78
+ | ------------ | ------------------ | ------------------------------------------- | -------------------- | ------- | ------------------------------- |
79
+ | `openrouter` | `systemone` | `https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `dated` | boolean, choice, score |
80
+ | `openai` | `openai-decisions` | `https://api.openai.com/v1/decisions` | `OPENAI_API_KEY` | `alias` | boolean, choice, score, refusal |
81
+
82
+ No other host is filled in. A dated certificate stores the echoed canonical slug. An alias certificate stores `pinned: false` and `expiresAt`. Console shows the unpinned flag. `oke decide certify` prints it.
83
+
84
+ If OpenRouter lists a dated id for the model, autonomy requires the decider to use that id.
85
+
86
+ ## Capabilities
87
+
88
+ | Field | Meaning |
89
+ | ------------------------------------------ | -------------------------------------------------------------- |
90
+ | `boolean` / `choice` / `score` / `refusal` | Whether that question kind is allowed. |
91
+ | `maxChoices` | Enforced only when the row sets it. |
92
+ | `minLevels` / `maxLevels` | Enforced only when the row sets it. |
93
+ | `maxContext` | Estimated input tokens. Over the limit throws before the call. |
94
+
95
+ Presets do not set a numeric limit. A custom row's `false` flag rejects that question at compile time.
96
+
97
+ Both wires become one answer: `boolean`, `choice`, `score`, `refusal`, or `malformed`. A refusal carries no text.
98
+
99
+ ## Options
100
+
101
+ | Option | Preset | Custom host |
102
+ | ---------------- | ------------------------------- | --------------------------------- |
103
+ | `provider` | `openrouter` or `openai` | Optional label |
104
+ | `driverId` | Filled in | `systemone` or `openai-decisions` |
105
+ | `baseUrl` | Filled in | Required |
106
+ | `model` | Required | Required |
107
+ | `secret` | Preset secret, or your override | Required |
108
+ | `region` / `zdr` | Stored as `declared` | Stored as `declared` |
109
+ | `timeout` | Optional | Optional |
110
+ | `concurrency` | Optional cap for this decider | Optional |
111
+
112
+ One decider has one breaker. Three HTTP 5xx responses open it for 30 seconds. The next call skips that decider and tries `backup`.
113
+
114
+ ## Troubleshooting
115
+
116
+ <Accordions>
117
+
118
+ <Accordion title="autonomy requires model">
119
+ `oke decide certify: autonomy requires model "…" on decider "…"`. The catalog lists a dated id.
120
+ Put that id on the decider.
121
+ </Accordion>
122
+
123
+ <Accordion title="model is unpinned">
124
+ Certify prints `model "…" is unpinned`. An alias certificate expires 30 days later. After that,
125
+ answers take `otherwise` with `why: "uncertified"`.
126
+ </Accordion>
127
+
128
+ <Accordion title="cannot answer choice">
129
+ `ai.decision("…"): decider "…" cannot answer choice "…"`. The capability row has that kind set to
130
+ `false`.
131
+ </Accordion>
132
+
133
+ </Accordions>
134
+
135
+ ## Learn more
136
+
137
+ - [Decisions](/docs/elements/ai/decide) — `otherwise`, certificates, and `fx.decide`
138
+ - [Configuration](/docs/reference/configuration) — `drivers.decide`
139
+
140
+ ## Next
141
+
142
+ <Cards>
143
+ <Card
144
+ title="Decisions"
145
+ description="Questions, otherwise, and the lockfile."
146
+ href="/docs/elements/ai/decide"
147
+ />
148
+ <Card title="Models" description="Chat and embed providers." href="/docs/elements/ai/models" />
149
+ <Card
150
+ title="CLI"
151
+ description="Certify, promote, labels, and models."
152
+ href="/docs/reference/cli"
153
+ />
154
+ </Cards>
@@ -321,7 +321,8 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
321
321
  - [Models](/docs/elements/ai/models) — provider registry and `baseUrl` rules
322
322
  - [Prompts](/docs/elements/ai/prompts) — `via`, budgets, versions, evals
323
323
  - [Agents](/docs/elements/ai/agents) — `fx.run`, tools, `maxSteps`
324
- - [Decisions](/docs/elements/ai/decide) — `fx.decide`, `how`, and the lockfile
324
+ - [Deciders](/docs/elements/ai/deciders) — the model that answers, and how its certificate is pinned
325
+ - [Decisions](/docs/elements/ai/decide) — `fx.decide`, `otherwise`, and the lockfile
325
326
  - [MCP](/docs/elements/ai/mcp) — inbound `mcp.tool` and outbound `ai.mcpServer`
326
327
  - [OpenRouter](/docs/recipes/openrouter) — zero-Docker cloud path
327
328
  - [fx](/docs/reference/fx) — `fx.ask` / `fx.run` / `fx.decide` / `fx.embed`
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "AI",
3
3
  "icon": "Sparkles",
4
- "pages": ["index", "models", "prompts", "agents", "decide", "events", "mcp"]
4
+ "pages": ["index", "models", "prompts", "agents", "deciders", "decide", "events", "mcp"]
5
5
  }
@@ -134,8 +134,8 @@ Override app / console / mcp listen ports in `oke.config.ts` `ports` — see
134
134
  | `docker` | Derive compose · clean leftover stacks | `--prod\|-p`, `--out`, `--config`, `--manifest`; sub `clean` |
135
135
  | `images` | List / pin digests | `list` · `pin` |
136
136
  | `build` | Tree-shaken bundle | `--target\|-t bun\|node\|edge`, `--entry`, `--outdir` |
137
- | `eval` | Prompt eval sets (CI gate) | `--manifest`, `--certify` (decision seed certificate; prompt evals stay as they are) |
138
- | `decide` | Write a decision lockfile from the operator candidate | `promote <name>` (`--origin`, `--lock`) |
137
+ | `eval` | Prompt eval sets (CI gate) | `--manifest` |
138
+ | `decide` | Certify, promote, export labels, list models | `certify <name> --deciders a,b` · `promote <name>` (`--origin`, `--lock`) · `labels <name> --export` · `models` |
139
139
  | `ai` | Configure AI driver + models | `setup` (`--provider`, `--chat`, `--vision`, `--embed`, `--yes`) |
140
140
  | `branch` | Fork journaled state | `<name>`, `--at\|-a` |
141
141
  | `replay` | Re-invoke a past Flow from Runs | `--request-id\|-r`, `--entry`, `--dry-run`, `--live` |
@@ -99,7 +99,7 @@ Built-in defaults (`DRIVER_DEFAULTS`) when a key is omitted:
99
99
  | `channel.sms` | — (opt-in) | — | — |
100
100
  | `runs` | `files` | `memory` | `files` |
101
101
 
102
- `store.index`, `channel.whatsapp` / `push`, and `ai` have no single three-env default table — set them explicitly when you need them. create-oke templates pin `vault.dev: "vault"` (built-in) because the default `dev` driver is `env`.
102
+ `store.index`, `channel.whatsapp` / `push`, and `ai` have no single three-env default table — set them explicitly when you need them. `drivers.decide` defaults to `{ test: "mock" }`. Unset `dev` and `prod` call the decider host. create-oke templates pin `vault.dev: "vault"` (built-in) because the default `dev` driver is `env`.
103
103
 
104
104
  ## Driver keys and ids
105
105
 
@@ -118,6 +118,7 @@ Built-in defaults (`DRIVER_DEFAULTS`) when a key is omitted:
118
118
  | `channel.whatsapp` | env driver map | `console` · `wa-cloud` |
119
119
  | `channel.push` | env driver map | `console` · `webpush` · `fcm` |
120
120
  | `ai` | env driver map | `mock` · `anthropic` · `openai-compatible` · `bedrock` · `vertex` |
121
+ | `decide` | env driver map | `mock` in `test` when omitted. Unset `dev` and `prod` call the decider host. |
121
122
  | `runs` | env driver map | `memory` · `files` (Parquet + DuckDB at `.oke/runs`) · `postgres` · `clickhouse` |
122
123
  | `prod` | `string[]` | flat protocol list for the Manifest — nested maps are preferred |
123
124
 
@@ -163,6 +163,7 @@ Read `fx.principal` for audit — it does not copy into `fx.auth`. Passing a Flo
163
163
 
164
164
  `fx.store(db).transaction(async (tx) => { … })` pins one SQL connection. `fx.emit` inside the
165
165
  callback stages on the signal outbox and publishes after commit. A throw rolls that batch back.
166
+
166
167
  `fx.store(db).run(builder)` runs a Drizzle builder through `toSQL()`. Joins stay on that builder;
167
168
  the hand-rolled chain does not grow a join API. Inference still records every joined table.
168
169
 
@@ -246,13 +247,13 @@ elements.
246
247
 
247
248
  ## AI
248
249
 
249
- | Signature | Records | Returns |
250
- | ----------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
251
- | `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
252
- | `fx.run(agent, input?)` | `ask` | Agent result |
253
- | `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
254
- | `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
255
- | `fx.decide(decision, input)` | `decide` | `$.<question>.how` is `auto`, `reviewed`, or `abstained`. `audited` is a boolean flag, not a `how`. Abstain makes the value `null`. |
250
+ | Signature | Records | Returns |
251
+ | ----------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
252
+ | `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
253
+ | `fx.run(agent, input?)` | `ask` | Agent result |
254
+ | `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
255
+ | `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
256
+ | `fx.decide(decision, input)` | `decide` | `$.<question>.how` is `auto`, `reviewed`, or `abstained`. `by` is the decider. `why` is set only when the question is not `auto`. `audited` is a boolean flag, not a `how`. Abstain makes the value `null`. |
256
257
 
257
258
  AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).
258
259
 
@@ -289,8 +290,9 @@ Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400
289
290
 
290
291
  Pure store-read flows cache by default. A send, emit, fetch, vault read, ask,
291
292
  decide, or `fx.call` is never cached. The key includes tenant, locale, scopes,
292
- and membership roles. Writes through `fx` invalidate those keys. `cache: false`
293
- always disables. `oke({ cache: { auto: false } })` turns the default off.
293
+ and membership roles. Writes through `fx` invalidate those keys.
294
+
295
+ `cache: false` always disables. `oke({ cache: { auto: false } })` turns the default off.
294
296
  Cross-instance invalidation and out-of-band writes are not covered.
295
297
 
296
298
  `fx.cache` is the manual (tier-3) surface:
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
  import { describe, expect, test } from "bun:test";
7
- import { commandNames } from "./registry.ts";
7
+ import { commandNames, OKE_COMMANDS } from "./registry.ts";
8
8
 
9
9
  describe("AGENTS.md commands", () => {
10
10
  test("every oke command mentioned in AGENTS.md is registered", () => {
@@ -17,7 +17,13 @@ describe("AGENTS.md commands", () => {
17
17
  }
18
18
  expect(mentioned.has("eval")).toBe(true);
19
19
  expect(mentioned.has("decide")).toBe(true);
20
- expect(text).not.toContain("oke decide certify");
20
+ const decide = OKE_COMMANDS.find((command) => command.name === "decide");
21
+ expect(decide?.subcommands?.map((command) => command.name).sort()).toEqual([
22
+ "certify",
23
+ "labels",
24
+ "models",
25
+ "promote",
26
+ ]);
21
27
  for (const name of mentioned) {
22
28
  expect(registered.has(name)).toBe(true);
23
29
  }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `oke decide certify` writes one certificate per decider.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { mkdtemp } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { ai, resetAiDecls } from "../elements/ai.ts";
10
+ import { resetDecisionCertificates } from "../elements/ai/decisions/certificate.ts";
11
+ import { ALIAS_CERTIFICATE_MS } from "../elements/ai/deciders/presets.ts";
12
+ import { certifyDecisionDeciders } from "./decide.ts";
13
+
14
+ afterEach(() => {
15
+ resetAiDecls();
16
+ resetDecisionCertificates();
17
+ });
18
+
19
+ function seed(): string {
20
+ return Array.from({ length: 160 }, () =>
21
+ JSON.stringify({ input: { ticket: "1" }, expect: { team: "technical" } }),
22
+ ).join("\n");
23
+ }
24
+
25
+ function body(model: string) {
26
+ return {
27
+ model,
28
+ answers: {
29
+ team: {
30
+ type: "choice" as const,
31
+ choice: "technical",
32
+ probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
33
+ },
34
+ },
35
+ usage: { cost: 0.01 },
36
+ };
37
+ }
38
+
39
+ describe("oke decide certify", () => {
40
+ test("two mock deciders each get a certificate and an alias is unpinned", async () => {
41
+ const question = ai.choice("which team", { billing: "Billing", technical: "Technical" });
42
+ ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13.0" });
43
+ ai.decider("luna", { provider: "openai", model: "gpt-6-luna" });
44
+ ai.decision("triage", {
45
+ decider: ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13.0" }),
46
+ backup: [ai.decider("luna", { provider: "openai", model: "gpt-6-luna" })],
47
+ otherwise: "abstain",
48
+ autonomy: { maxError: 0.05, audit: 0 },
49
+ ask: { team: question },
50
+ });
51
+ const root = await mkdtemp(join(tmpdir(), "oke-certify-"));
52
+ const evals = join(root, "seed.jsonl");
53
+ await Bun.write(evals, seed());
54
+ const lines: string[] = [];
55
+ const now = 1_700_000_000_000;
56
+ const lock = await certifyDecisionDeciders({
57
+ root,
58
+ decision: "triage",
59
+ deciders: ["jev", "luna"],
60
+ now: () => now,
61
+ fetcher: async () => new Response("offline", { status: 404 }),
62
+ write: (line) => {
63
+ lines.push(line);
64
+ },
65
+ evaluate: {
66
+ jev: async () => body("typesafe/jev-1.13.0"),
67
+ luna: async () => body("gpt-6-luna"),
68
+ },
69
+ manifest: {
70
+ oke: "1.0",
71
+ app: "cert",
72
+ ai: {
73
+ deciders: {
74
+ jev: {
75
+ driverId: "systemone",
76
+ baseUrl: "https://openrouter.ai/api/alpha/decisions",
77
+ model: "typesafe/jev-1.13.0",
78
+ secret: "OPENROUTER_API_KEY",
79
+ pinning: "dated",
80
+ capabilities: { boolean: true, choice: true, score: true, refusal: false },
81
+ },
82
+ luna: {
83
+ driverId: "openai-decisions",
84
+ baseUrl: "https://api.openai.com/v1/decisions",
85
+ model: "gpt-6-luna",
86
+ secret: "OPENAI_API_KEY",
87
+ pinning: "alias",
88
+ capabilities: { boolean: true, choice: true, score: true, refusal: true },
89
+ },
90
+ },
91
+ decisions: {
92
+ triage: {
93
+ mode: "abstain",
94
+ decider: "jev",
95
+ backup: ["luna"],
96
+ otherwise: "abstain",
97
+ questions: ["team"],
98
+ evals,
99
+ autonomy: { maxError: 0.05, audit: 0 },
100
+ },
101
+ },
102
+ },
103
+ },
104
+ });
105
+ expect(lock.version).toBe(2);
106
+ expect(lock.decisions.triage?.deciders.jev?.pinned).toBe(true);
107
+ expect(lock.decisions.triage?.deciders.jev?.model).toBe("typesafe/jev-1.13.0");
108
+ expect(lock.decisions.triage?.deciders.luna).toMatchObject({
109
+ model: "gpt-6-luna",
110
+ pinned: false,
111
+ expiresAt: now + ALIAS_CERTIFICATE_MS,
112
+ });
113
+ expect(lines.some((line) => line.includes('decider "luna"') && line.includes("unpinned"))).toBe(
114
+ true,
115
+ );
116
+ expect(lines.some((line) => line.startsWith("jev\tteam\taccuracy"))).toBe(true);
117
+ expect(lines.some((line) => line.startsWith("luna\tteam\taccuracy"))).toBe(true);
118
+ });
119
+
120
+ test("a dated catalog slug is required when it differs from the configured model", async () => {
121
+ ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13" });
122
+ ai.decision("triage", {
123
+ decider: ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13" }),
124
+ otherwise: "abstain",
125
+ ask: { team: ai.choice("which", { a: "A" }) },
126
+ });
127
+ const root = await mkdtemp(join(tmpdir(), "oke-dated-"));
128
+ const evals = join(root, "seed.jsonl");
129
+ await Bun.write(evals, seed());
130
+ await expect(
131
+ certifyDecisionDeciders({
132
+ root,
133
+ decision: "triage",
134
+ deciders: ["jev"],
135
+ evaluate: { jev: async () => body("typesafe/jev-1.13") },
136
+ fetcher: async () =>
137
+ new Response(
138
+ JSON.stringify({
139
+ data: [
140
+ {
141
+ id: "typesafe/jev-1.13",
142
+ canonical_slug: "typesafe/jev-1.13-20260917",
143
+ },
144
+ ],
145
+ }),
146
+ { status: 200 },
147
+ ),
148
+ manifest: {
149
+ oke: "1.0",
150
+ app: "cert",
151
+ ai: {
152
+ decisions: {
153
+ triage: {
154
+ mode: "abstain",
155
+ decider: "jev",
156
+ otherwise: "abstain",
157
+ questions: ["team"],
158
+ evals,
159
+ },
160
+ },
161
+ },
162
+ },
163
+ }),
164
+ ).rejects.toThrow(/autonomy requires model "typesafe\/jev-1.13-20260917"/);
165
+ });
166
+ });
package/src/cli/decide.ts CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  type DecisionLockfile,
12
12
  } from "../elements/ai/decisions/certificate.ts";
13
13
  import { DECISION_EXPORT_WARNING } from "../elements/ai/decisions/export.ts";
14
+ import { ALIAS_CERTIFICATE_MS } from "../elements/ai/deciders/presets.ts";
15
+ import { requiredDatedModel } from "../elements/ai/decisions/catalog.ts";
16
+ import type { Manifest } from "../manifest/types.ts";
17
+ import type { DecisionEvaluate } from "../elements/ai/decisions/certify.ts";
14
18
 
15
19
  /** Options for {@link promoteDecision}. */
16
20
  export interface PromoteDecisionOptions {
@@ -137,22 +141,115 @@ async function exportLabelsCommand(name: string | undefined, rest: string[]): Pr
137
141
  *
138
142
  * @param args - Remaining argv after `decide`
139
143
  */
144
+ /**
145
+ * Certify one decision for each named decider and write lockfile version 2.
146
+ *
147
+ * @param options - Decision, deciders, and injected evaluators
148
+ */
149
+ export async function certifyDecisionDeciders(options: {
150
+ readonly root: string;
151
+ readonly manifest: Manifest;
152
+ readonly decision: string;
153
+ readonly deciders: readonly string[];
154
+ readonly evaluate: Readonly<Record<string, DecisionEvaluate>>;
155
+ readonly now?: () => number;
156
+ readonly fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
157
+ readonly write?: (text: string) => void;
158
+ }): Promise<DecisionLockfile> {
159
+ const { certifySeed } = await import("../elements/ai/decisions/certify.ts");
160
+ const { aiDecisionRegistry, aiDeciderRegistry } = await import("../kernel/element-registries.ts");
161
+ const decl = aiDecisionRegistry.find((item) => item.name === options.decision);
162
+ const decision = options.manifest.ai?.decisions?.[options.decision];
163
+ if (!decl || !decision) {
164
+ throw new Error(`oke decide certify: decision "${options.decision}" is not loaded`);
165
+ }
166
+ if (!decision.evals) {
167
+ throw new Error(`oke decide certify: decision "${options.decision}" has no evals`);
168
+ }
169
+ const text = await Bun.file(resolve(options.root, decision.evals)).text();
170
+ const lockPath = resolve(options.root, DECISION_LOCK_FILENAME);
171
+ const existing = Bun.file(lockPath);
172
+ let current: DecisionLockfile | undefined;
173
+ if (await existing.exists()) current = parseDecisionLockfile(await existing.json());
174
+ const write = options.write ?? ((line: string) => process.stdout.write(line));
175
+ const deciders: Record<string, DecisionLockfile["decisions"][string]["deciders"][string]> = {
176
+ ...(current?.decisions[options.decision]?.deciders ?? {}),
177
+ };
178
+ for (const name of options.deciders) {
179
+ const decider = aiDeciderRegistry.find((item) => item.name === name) ?? undefined;
180
+ const manifestDecider = options.manifest.ai?.deciders?.[name];
181
+ if (!decider && !manifestDecider) {
182
+ throw new Error(`oke decide certify: decider "${name}" is not loaded`);
183
+ }
184
+ const model = decider?.model ?? manifestDecider?.model ?? "";
185
+ const pinning = decider?.pinning ?? manifestDecider?.pinning ?? "dated";
186
+ const baseUrl = decider?.baseUrl ?? manifestDecider?.baseUrl ?? "";
187
+ const required = await requiredDatedModel(model, baseUrl, pinning, options.fetcher);
188
+ if (required && required !== model) {
189
+ throw new Error(
190
+ `oke decide certify: autonomy requires model "${required}" on decider "${name}"`,
191
+ );
192
+ }
193
+ const evaluate = options.evaluate[name];
194
+ if (!evaluate) throw new Error(`oke decide certify: no evaluator for decider "${name}"`);
195
+ const alias = pinning === "alias";
196
+ const now = options.now ?? Date.now;
197
+ const result = await certifySeed({
198
+ model,
199
+ pinned: !alias,
200
+ ...(alias ? { expiresAt: now() + ALIAS_CERTIFICATE_MS } : {}),
201
+ maxError: decision.autonomy?.maxError ?? decl.autonomy?.maxError ?? 0.05,
202
+ delta: decision.autonomy?.risk ?? decl.autonomy?.risk ?? 0.1,
203
+ jsonl: text,
204
+ ask: decl.ask,
205
+ evaluate,
206
+ now,
207
+ });
208
+ deciders[name] = result.cert;
209
+ if (result.unpinned) {
210
+ write(`oke decide certify: decider "${name}" model "${result.cert.model}" is unpinned\n`);
211
+ }
212
+ for (const [question, row] of Object.entries(result.report)) {
213
+ write(
214
+ `${name}\t${question}\taccuracy ${row.accuracy.toFixed(3)}\tECE ${row.ece.toFixed(3)}\tcoverage ${row.coverage.toFixed(3)}\tp50 ${row.p50Ms}ms\tp95 ${row.p95Ms}ms\tcost ${row.cost}\n`,
215
+ );
216
+ }
217
+ }
218
+ const next: DecisionLockfile = {
219
+ version: 2,
220
+ decisions: { ...(current?.decisions ?? {}), [options.decision]: { deciders } },
221
+ };
222
+ await Bun.write(lockPath, `${JSON.stringify(next, null, 2)}\n`);
223
+ const { setDecisionDrift } = await import("../elements/ai/decisions/certificate.ts");
224
+ const { persistDecisionDrift } = await import("../elements/ai/decisions/labels.ts");
225
+ setDecisionDrift(options.decision, false);
226
+ persistDecisionDrift(options.decision, false);
227
+ return next;
228
+ }
229
+
140
230
  export async function decideCli(args: string[]): Promise<number> {
141
231
  const [sub, name, ...rest] = args;
142
232
  if (sub === "--help" || sub === "-h" || !sub) {
143
233
  console.log(`oke decide promote <name> [--origin URL] [--lock path]
144
234
  oke decide labels <name> --export [--out file] [--origin URL]
235
+ oke decide certify <name> --deciders a,b
236
+ oke decide models
145
237
 
146
238
  promote fetches the operator candidate and writes oke-decisions.lock.json.
147
239
  labels --export writes reviewed labels as seed JSONL. The file contains production data.
240
+ certify writes one certificate per decider. models lists decision models.
148
241
  `);
149
242
  return sub ? 0 : 1;
150
243
  }
151
244
  if (sub === "labels") return exportLabelsCommand(name, rest);
245
+ if (sub === "models") {
246
+ const { fetchDecisionModels } = await import("../elements/ai/decisions/catalog.ts");
247
+ process.stdout.write(await fetchDecisionModels());
248
+ return 0;
249
+ }
250
+ if (sub === "certify") return certifyCommand(name, rest);
152
251
  if (sub !== "promote" || !name) {
153
- console.error(
154
- "oke decide: expected `oke decide promote <name>` or `oke decide labels <name> --export`",
155
- );
252
+ console.error("oke decide: expected promote, labels, certify, or models");
156
253
  return 1;
157
254
  }
158
255
  let origin = process.env.OKE_ORIGIN ?? "http://127.0.0.1:6530";
@@ -172,3 +269,73 @@ labels --export writes reviewed labels as seed JSONL. The file contains producti
172
269
  });
173
270
  return 0;
174
271
  }
272
+
273
+ async function certifyCommand(name: string | undefined, rest: string[]): Promise<number> {
274
+ if (!name) {
275
+ console.error("oke decide: expected `oke decide certify <name> --deciders a,b`");
276
+ return 1;
277
+ }
278
+ const flag = rest.indexOf("--deciders");
279
+ const list = flag >= 0 ? rest[flag + 1] : undefined;
280
+ if (!list) {
281
+ console.error("oke decide certify: --deciders is required");
282
+ return 1;
283
+ }
284
+ const deciders = list
285
+ .split(",")
286
+ .map((item) => item.trim())
287
+ .filter((item) => item.length > 0);
288
+ const { resolveStartEntry } = await import("./start.ts");
289
+ const root = resolve(process.env["OKE_ROOT_DIR"] ?? ".");
290
+ try {
291
+ await import(await resolveStartEntry(root));
292
+ } catch (error) {
293
+ console.error(
294
+ `oke decide certify: failed to load the app: ${error instanceof Error ? error.message : String(error)}`,
295
+ );
296
+ return 1;
297
+ }
298
+ const manifestPath = resolve(root, "oke.manifest.json");
299
+ const file = Bun.file(manifestPath);
300
+ if (!(await file.exists())) {
301
+ console.error(`oke decide certify: manifest not found: ${manifestPath}`);
302
+ return 1;
303
+ }
304
+ const manifest = (await file.json()) as Manifest;
305
+ const { aiDeciderRegistry, aiDecisionRegistry } = await import("../kernel/element-registries.ts");
306
+ const decl = aiDecisionRegistry.find((item) => item.name === name);
307
+ if (!decl) {
308
+ console.error(`oke decide certify: decision "${name}" is not loaded`);
309
+ return 1;
310
+ }
311
+ const evaluate: Record<string, DecisionEvaluate> = {};
312
+ for (const id of deciders) {
313
+ const decider = aiDeciderRegistry.find((item) => item.name === id);
314
+ if (!decider) {
315
+ console.error(`oke decide certify: decider "${id}" is not loaded`);
316
+ return 1;
317
+ }
318
+ const apiKey = process.env[decider.secret];
319
+ if (!apiKey) {
320
+ console.error(`oke decide certify: secret "${decider.secret}" is not configured`);
321
+ return 1;
322
+ }
323
+ evaluate[id] = async (input) => {
324
+ const { decisionHttp } = await import("../elements/ai/decisions/http.ts");
325
+ const { encodeDecisionRequest, normalizeDecisionResponse, wireQuestions } =
326
+ await import("../elements/ai/decisions/codec.ts");
327
+ const request = { model: decider.model, state: input, questions: wireQuestions(decl.ask) };
328
+ const raw = await decisionHttp({
329
+ url: decider.baseUrl,
330
+ apiKey,
331
+ request,
332
+ body: encodeDecisionRequest(decider.protocol, request),
333
+ timeoutMs: 30_000,
334
+ breakerKey: decider.name,
335
+ });
336
+ return normalizeDecisionResponse(decider.protocol, raw);
337
+ };
338
+ }
339
+ await certifyDecisionDeciders({ root, manifest, decision: name, deciders, evaluate });
340
+ return 0;
341
+ }