arkaos 5.17.2 → 5.18.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 (130) hide show
  1. package/THE-ARKAOS-GUIDE.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +2 -1
  4. package/arka/skills/costs/SKILL.md +16 -0
  5. package/config/claude-agents/marta-cqo.md +10 -0
  6. package/config/decisions/corpora/bash-effect.jsonl +38 -0
  7. package/config/decisions/corpora/creation-intent.jsonl +47 -0
  8. package/config/decisions/corpora/dispatch-role.jsonl +32 -0
  9. package/config/decisions/corpora/forge-complexity.jsonl +102 -0
  10. package/config/decisions/corpora/forge-departments.jsonl +34 -0
  11. package/config/decisions/corpora/heldout/learning-signal.jsonl +17 -0
  12. package/config/decisions/corpora/heldout/qg-prescreen.jsonl +20 -0
  13. package/config/decisions/corpora/heldout/slop-score.jsonl +20 -0
  14. package/config/decisions/corpora/learning-signal.jsonl +35 -0
  15. package/config/decisions/corpora/phantom-action.jsonl +34 -0
  16. package/config/decisions/corpora/qg-prescreen.jsonl +46 -0
  17. package/config/decisions/corpora/refine.jsonl +104 -0
  18. package/config/decisions/corpora/route.jsonl +33 -0
  19. package/config/decisions/corpora/skill-hints.jsonl +34 -0
  20. package/config/decisions/corpora/skill-proposer.jsonl +32 -0
  21. package/config/decisions/corpora/slop-score.jsonl +33 -0
  22. package/config/decisions/corpora/subagent-discipline.jsonl +32 -0
  23. package/config/decisions/corpora/sycophancy.jsonl +34 -0
  24. package/config/decisions/corpora/topic-drift.jsonl +34 -0
  25. package/config/decisions/corpora/ui-in-ts.jsonl +60 -0
  26. package/config/hooks/gate-manifest.json +1 -0
  27. package/config/models.yaml +10 -2
  28. package/core/decisions/__init__.py +8 -0
  29. package/core/decisions/backoff.py +137 -0
  30. package/core/decisions/cache.py +92 -0
  31. package/core/decisions/client.py +191 -0
  32. package/core/decisions/config.py +159 -0
  33. package/core/decisions/cost_effect.py +242 -0
  34. package/core/decisions/engine.py +400 -0
  35. package/core/decisions/models.py +118 -0
  36. package/core/decisions/paths.py +39 -0
  37. package/core/decisions/privacy.py +378 -0
  38. package/core/decisions/promotion.py +126 -0
  39. package/core/decisions/registry.py +23 -0
  40. package/core/decisions/replay.py +852 -0
  41. package/core/decisions/replay_report.py +413 -0
  42. package/core/decisions/shadow.py +147 -0
  43. package/core/decisions/site.py +343 -0
  44. package/core/decisions/sites/__init__.py +1 -0
  45. package/core/decisions/sites/command.py +58 -0
  46. package/core/decisions/sites/dispatch.py +161 -0
  47. package/core/decisions/sites/forge.py +230 -0
  48. package/core/decisions/sites/governance.py +361 -0
  49. package/core/decisions/sites/prompt.py +195 -0
  50. package/core/decisions/sites/quality.py +281 -0
  51. package/core/decisions/telemetry.py +335 -0
  52. package/core/decisions/telemetry_cli.py +149 -0
  53. package/core/decisions/transport.py +97 -0
  54. package/core/egress/audit.py +46 -6
  55. package/core/egress/credentials.py +1296 -0
  56. package/core/egress/policy.py +7 -4
  57. package/core/evals/verdict_labels.py +25 -5
  58. package/core/forge/budget.py +32 -0
  59. package/core/forge/complexity.py +11 -3
  60. package/core/forge/jev_sites.py +80 -0
  61. package/core/forge/orchestrator.py +76 -72
  62. package/core/governance/aggregate_guard.py +175 -77
  63. package/core/governance/carry_advisor.py +36 -19
  64. package/core/governance/evidence_checks.py +134 -55
  65. package/core/governance/fence_recovery.py +165 -0
  66. package/core/governance/harness_scanner.py +4 -0
  67. package/core/governance/jev_advisory.py +154 -0
  68. package/core/governance/leak_scanner.py +2 -2
  69. package/core/governance/literal_git.py +66 -0
  70. package/core/governance/qg_prescreen.py +518 -0
  71. package/core/governance/qg_tier.py +17 -0
  72. package/core/governance/reviewer_ledger.py +225 -46
  73. package/core/governance/skill_proposer.py +38 -13
  74. package/core/governance/slop_check.py +169 -0
  75. package/core/hooks/operator_message.py +128 -0
  76. package/core/hooks/stop.py +418 -38
  77. package/core/hooks/stop_budget.py +43 -0
  78. package/core/hooks/subagent_stop.py +39 -9
  79. package/core/hooks/ups_dispatch.py +243 -0
  80. package/core/hooks/user_prompt_submit.py +458 -106
  81. package/core/keys.py +4 -0
  82. package/core/runtime/model_router.py +38 -0
  83. package/core/runtime/pricing.py +8 -0
  84. package/core/synapse/command_menu.py +70 -0
  85. package/core/synapse/layers.py +108 -66
  86. package/core/workflow/flow_enforcer.py +71 -1
  87. package/core/workflow/frontend_gate.py +57 -1
  88. package/core/workflow/transcript_scope.py +34 -25
  89. package/departments/quality/SKILL.md +29 -2
  90. package/harness/codex/AGENTS.md +1 -1
  91. package/harness/copilot/copilot-instructions.md +1 -1
  92. package/harness/cursor/rules/arkaos.mdc +2 -2
  93. package/harness/gemini/GEMINI.md +1 -1
  94. package/harness/opencode/AGENTS.md +1 -1
  95. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  96. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  97. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  98. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  99. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  100. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  101. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  102. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  103. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  104. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  105. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  106. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  107. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  108. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  109. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  110. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  111. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  112. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  113. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  114. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  115. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  116. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  117. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  118. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  119. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  120. package/harness/zed/.rules +1 -1
  121. package/installer/config-seed.js +71 -1
  122. package/installer/keys.js +1 -1
  123. package/installer/update.js +29 -0
  124. package/knowledge/commands-registry.json +17 -4
  125. package/knowledge/commands-registry.json.bak +6739 -0
  126. package/knowledge/skills-manifest.json +1 -1
  127. package/package.json +1 -1
  128. package/pyproject.toml +1 -1
  129. package/scripts/synapse-bridge.py +17 -1
  130. package/scripts/tools/corpus_merge.py +402 -0
@@ -0,0 +1,400 @@
1
+ """Decision engine — one bounded call per turn for every active site.
2
+
3
+ :func:`decide` never raises. Paths:
4
+
5
+ * every site ``off`` (or bypass / disabled) → heuristics, no disk, no network;
6
+ * no transport (no key) → heuristics, no disk, no network;
7
+ * every live site ``shadow`` → detached worker (``shadow.py``), heuristics now;
8
+ * any site ``act`` → ONE synchronous call carrying the questions of every
9
+ live site (keys ``<site>__<question>``), capped by the smallest ceiling.
10
+
11
+ Each live path writes one telemetry line per site. Unavailability of any
12
+ kind (``DecisionUnavailable.reason``) lands in ``Outcome.reason`` and the
13
+ caller acts on its heuristic.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import time
20
+ import uuid
21
+ from collections.abc import Collection, Mapping, Sequence
22
+ from dataclasses import dataclass, replace
23
+
24
+ from core.decisions import cache
25
+ from core.decisions.backoff import blocked
26
+ from core.decisions.client import DecisionUnavailable, post_decision
27
+ from core.decisions.config import (
28
+ DecisionsConfig,
29
+ Mode,
30
+ load_decisions_config,
31
+ site_mode,
32
+ site_timeout_ms,
33
+ threshold_for,
34
+ )
35
+ from core.decisions.models import Answer, DecisionRequest, DecisionResponse, Question, State
36
+ from core.decisions.privacy import prepare_state, strictest_state_class
37
+ from core.decisions.registry import SITES
38
+ from core.decisions.shadow import spawn_shadow
39
+ from core.decisions.site import Outcome, Site, SiteCall, answer_confidence, valid_answers
40
+ from core.decisions.telemetry import (
41
+ COST_CATEGORY,
42
+ DecisionRecord,
43
+ call_cost_usd,
44
+ record,
45
+ record_call_cost,
46
+ state_sha16,
47
+ )
48
+ from core.decisions.transport import Transport, resolve_transport
49
+
50
+ MIN_CALL_MS = 50 # below this the call cannot finish: skip it ("deadline")
51
+ # The endpoint's model echo is untrusted text bound for telemetry.
52
+ SAFE_MODEL_RE = re.compile(r"[A-Za-z0-9._/:-]{1,80}")
53
+ UNSAFE_MODEL_ECHO = "unsafe-model-echo"
54
+
55
+ RunResult = tuple[DecisionResponse | None, str, int]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CallMeta:
60
+ """What every telemetry line of one call shares."""
61
+
62
+ call_id: str
63
+ session_id: str
64
+ state_sha16: str
65
+ transport: Transport
66
+ latency_ms: int
67
+ reason: str
68
+
69
+
70
+ def question_key(site_name: str, question: str) -> str:
71
+ """Wire key ``<site>__<question>`` (hyphens → underscores)."""
72
+ return f"{site_name.replace('-', '_')}__{question}"
73
+
74
+
75
+ def active(cfg: DecisionsConfig | None = None, names: Collection[str] | None = None) -> bool:
76
+ """True when at least one site is live AND a transport exists.
77
+
78
+ ``names`` scopes the question to one caller's sites (the UPS asks about
79
+ its own, so a live bash-effect never wakes a prompt stage whose sites
80
+ are all off); None means every registered site.
81
+ """
82
+ cfg = cfg or load_decisions_config()
83
+ scope = SITES.values() if names is None else [SITES[n] for n in names if n in SITES]
84
+ if not any(site_mode(cfg, site) != "off" for site in scope):
85
+ return False
86
+ return resolve_transport(cfg) is not None
87
+
88
+
89
+ def asked_questions(calls: Sequence[SiteCall], state: State) -> dict[str, Question]:
90
+ """Every question of every call for ``state``, keyed ``<site>__<question>``."""
91
+ questions: dict[str, Question] = {}
92
+ for call in calls:
93
+ for name, question in call.site.questions_of(state).items():
94
+ questions[question_key(call.site.name, name)] = question
95
+ return questions
96
+
97
+
98
+ def build_request(calls: Sequence[SiteCall], state: State, model: str) -> DecisionRequest:
99
+ """One request holding every question of every call."""
100
+ return DecisionRequest(model=model, state=state, questions=asked_questions(calls, state))
101
+
102
+
103
+ def decide(
104
+ calls: Sequence[SiteCall],
105
+ state: State,
106
+ *,
107
+ session_id: str,
108
+ timeout_ms: int | None = None,
109
+ cfg: DecisionsConfig | None = None,
110
+ model: str | None = None,
111
+ ) -> dict[str, Outcome]:
112
+ """Outcome per site name. Never raises: internal errors → heuristics.
113
+
114
+ ``model`` is the ``decisions.model`` of ``models.yaml`` (alias or id);
115
+ None keeps the transport default.
116
+ """
117
+ try:
118
+ return _decide(calls, state, session_id, timeout_ms, cfg, model)
119
+ except Exception as exc:
120
+ return _heuristics(calls, {}, f"internal:{type(exc).__name__}")
121
+
122
+
123
+ def _decide(
124
+ calls: Sequence[SiteCall],
125
+ state: State,
126
+ session_id: str,
127
+ timeout_ms: int | None,
128
+ cfg: DecisionsConfig | None,
129
+ model: str | None = None,
130
+ ) -> dict[str, Outcome]:
131
+ cfg = cfg or load_decisions_config()
132
+ modes: dict[str, Mode] = {c.site.name: site_mode(cfg, c.site) for c in calls}
133
+ live = [c for c in calls if modes[c.site.name] != "off"]
134
+ if not live:
135
+ return _heuristics(calls, modes, "off")
136
+ transport = resolve_transport(cfg, model=model)
137
+ if transport is None:
138
+ return _heuristics(calls, modes, "no-transport")
139
+ if all(modes[c.site.name] == "shadow" for c in live):
140
+ spawn_shadow(live, state, session_id)
141
+ return _heuristics(calls, modes, "shadow")
142
+ timeout = _effective_timeout_ms(cfg, live, modes, timeout_ms)
143
+ outcomes = _heuristics(calls, modes, "off")
144
+ outcomes.update(run_and_record(
145
+ live, state, modes=modes, cfg=cfg, transport=transport,
146
+ session_id=session_id, timeout_s=timeout / 1000,
147
+ ))
148
+ return outcomes
149
+
150
+
151
+ def _heuristics(
152
+ calls: Sequence[SiteCall], modes: Mapping[str, Mode], reason: str
153
+ ) -> dict[str, Outcome]:
154
+ return {
155
+ c.site.name: Outcome(
156
+ value=c.heuristic, heuristic=c.heuristic, jev=None, confidence=None,
157
+ mode=modes.get(c.site.name, "off"), acted_on="heuristic", reason=reason,
158
+ )
159
+ for c in calls
160
+ }
161
+
162
+
163
+ def _effective_timeout_ms(
164
+ cfg: DecisionsConfig,
165
+ live: Sequence[SiteCall],
166
+ modes: Mapping[str, Mode],
167
+ timeout_ms: int | None,
168
+ ) -> int:
169
+ base = cfg.hook_timeout_ms if timeout_ms is None else timeout_ms
170
+ ceilings = [site_timeout_ms(cfg, c.site) for c in live if modes[c.site.name] == "act"]
171
+ return min([base, *ceilings])
172
+
173
+
174
+ def run_and_record(
175
+ calls: Sequence[SiteCall],
176
+ state: State,
177
+ *,
178
+ modes: Mapping[str, Mode],
179
+ cfg: DecisionsConfig,
180
+ transport: Transport,
181
+ session_id: str,
182
+ timeout_s: float,
183
+ ) -> dict[str, Outcome]:
184
+ """One call for ``calls``, resolved per site and written to telemetry."""
185
+ if timeout_s * 1000 < MIN_CALL_MS:
186
+ response, reason, latency = None, "deadline", 0
187
+ else:
188
+ response, reason, latency = run_sync(
189
+ calls, state, transport=transport, cfg=cfg,
190
+ timeout_s=timeout_s, session_id=session_id,
191
+ )
192
+ outcomes = {
193
+ c.site.name: resolve(
194
+ c, response, modes[c.site.name], threshold_for(cfg, c.site), reason, state=state)
195
+ for c in calls
196
+ }
197
+ meta = CallMeta(uuid.uuid4().hex[:12], session_id, state_sha16(state),
198
+ transport, latency, reason)
199
+ record_outcomes(calls, outcomes, response, meta, state=state)
200
+ return outcomes
201
+
202
+
203
+ def run_sync(
204
+ calls: Sequence[SiteCall],
205
+ state: State,
206
+ *,
207
+ transport: Transport,
208
+ cfg: DecisionsConfig,
209
+ timeout_s: float,
210
+ session_id: str = "",
211
+ cost_category: str = COST_CATEGORY,
212
+ mark_local: bool = False,
213
+ ) -> RunResult:
214
+ """backoff → privacy → cache → POST → cache.put. ``(response|None, reason, ms)``.
215
+
216
+ ``cost_category`` labels the ledger row of a fresh call (the replay
217
+ harness passes ``decision-replay``). ``mark_local`` (replay only)
218
+ reports a failure decided on this machine as ``<reason>:local``
219
+ (:meth:`DecisionUnavailable.run_reason`); live telemetry keeps the
220
+ bare reason vocabulary.
221
+ """
222
+ cause = blocked()
223
+ if cause is not None:
224
+ # The trip's cause rides along ("backoff:http-401") so telemetry and
225
+ # /arka decisions say WHY the breaker is open, not just that it is.
226
+ return None, f"backoff:{cause}", 0
227
+ start = time.monotonic()
228
+ try:
229
+ return _fetch(calls, state, transport, cfg, timeout_s, session_id, cost_category)
230
+ except DecisionUnavailable as exc:
231
+ reason = exc.run_reason() if mark_local else exc.reason
232
+ return None, reason, int((time.monotonic() - start) * 1000)
233
+
234
+
235
+ def _fetch(
236
+ calls: Sequence[SiteCall],
237
+ state: State,
238
+ transport: Transport,
239
+ cfg: DecisionsConfig,
240
+ timeout_s: float,
241
+ session_id: str,
242
+ cost_category: str = COST_CATEGORY,
243
+ ) -> RunResult:
244
+ redact = cfg.redact_clients and any(c.site.redact_default for c in calls)
245
+ # The strictest class governs a mixed call (a diff site keeps the whole
246
+ # request fail-closed); session_id scopes the once-per-session notice.
247
+ prepared = prepare_state(
248
+ state, redact=redact, session_id=session_id,
249
+ state_class=strictest_state_class(c.site.state_class for c in calls),
250
+ )
251
+ # Dynamic questions are built from the PREPARED state, so a candidate
252
+ # menu is redacted like the state it came from.
253
+ if not asked_questions(calls, prepared):
254
+ raise DecisionUnavailable("no-questions")
255
+ request = build_request(calls, prepared, transport.model)
256
+ key = cache.cache_key(transport.model, prepared, request.questions)
257
+ hit = cache.get(key, cfg.cache_ttl_seconds)
258
+ if hit is not None:
259
+ return hit, "cache-hit", 0
260
+ response, latency_ms = post_decision(transport, request, timeout_s)
261
+ cache.put(key, response)
262
+ record_call_cost(session_id, transport, response.usage, category=cost_category)
263
+ return response, "ok", latency_ms
264
+
265
+
266
+ def site_answers(site_name: str, response: DecisionResponse) -> dict[str, Answer]:
267
+ """This site's answers, keyed by bare question name."""
268
+ prefix = question_key(site_name, "")
269
+ return {
270
+ key[len(prefix):]: answer
271
+ for key, answer in response.answers.items()
272
+ if key.startswith(prefix)
273
+ }
274
+
275
+
276
+ def valid_site_answers(
277
+ site: Site, response: DecisionResponse, state: State | None = None
278
+ ) -> dict[str, Answer]:
279
+ """This site's answers, cleaned against the questions asked for ``state``.
280
+
281
+ The ONLY answers that leave the engine (``Outcome.answers``, the
282
+ telemetry line): an off-menu choice, an unasked key or a malformed
283
+ distribution is untrusted and survives neither on disk nor in the
284
+ hook's context. Dynamic questions are rebuilt from the RAW state:
285
+ should redaction have rewritten a candidate id, Jev's answer names
286
+ an id this menu lacks and the site abstains (fail-closed).
287
+ """
288
+ return valid_answers(site.questions_of(state), site_answers(site.name, response))
289
+
290
+
291
+ def resolve(
292
+ call: SiteCall,
293
+ response: DecisionResponse | None,
294
+ mode: Mode,
295
+ threshold: float,
296
+ reason: str = "ok",
297
+ *,
298
+ state: State | None = None,
299
+ ) -> Outcome:
300
+ """Turn the response into this site's Outcome (mode and direction applied).
301
+
302
+ ``state`` is the call's state; a site with ``questions_for`` needs it
303
+ to know which answers it may accept (without it, it abstains).
304
+ """
305
+ h = call.heuristic
306
+ if response is None:
307
+ return Outcome(h, h, None, None, mode, "heuristic", reason)
308
+ answers = valid_site_answers(call.site, response, state)
309
+ return replace(_judge(call, answers, mode, threshold, state), answers=answers)
310
+
311
+
312
+ def _judge(
313
+ call: SiteCall, answers: dict[str, Answer], mode: Mode, threshold: float, state: State | None
314
+ ) -> Outcome:
315
+ h = call.heuristic
316
+ jev = call.site.judge(answers, threshold, state)
317
+ confidence = _min_confidence(answers)
318
+ if jev is None:
319
+ return Outcome(h, h, None, confidence, mode, "heuristic", "abstain")
320
+ if mode != "act":
321
+ return Outcome(h, h, jev, confidence, mode, "heuristic", "shadow")
322
+ return _apply_direction(call, jev, confidence, mode)
323
+
324
+
325
+ def _apply_direction(
326
+ call: SiteCall, jev: object, confidence: float | None, mode: Mode
327
+ ) -> Outcome:
328
+ site, h = call.site, call.heuristic
329
+ if site.direction == "escalate_only" and jev != h:
330
+ check = site.is_escalation
331
+ if check is None or not check(jev, h):
332
+ return Outcome(h, h, jev, confidence, mode, "heuristic", "downgrade-blocked")
333
+ return Outcome(jev, h, jev, confidence, mode, "jev", "jev")
334
+
335
+
336
+ def _min_confidence(answers: Mapping[str, Answer]) -> float | None:
337
+ values = [c for c in (answer_confidence(a) for a in answers.values()) if c is not None]
338
+ return round(min(values), 4) if values else None
339
+
340
+
341
+ def _compact(answers: Mapping[str, Answer]) -> dict[str, dict[str, object]]:
342
+ out: dict[str, dict[str, object]] = {}
343
+ for name, a in answers.items():
344
+ value = a.choice if a.choice is not None else a.noul if a.noul is not None else a.score
345
+ out[name] = {"v": value, "c": answer_confidence(a)}
346
+ return out
347
+
348
+
349
+ def _model_echo(response: DecisionResponse | None, transport: Transport) -> str:
350
+ """The echoed model when it is a safe token; the requested one when absent."""
351
+ if response is None or not response.model:
352
+ return transport.model
353
+ if SAFE_MODEL_RE.fullmatch(response.model):
354
+ return response.model
355
+ return UNSAFE_MODEL_ECHO
356
+
357
+
358
+ def record_outcomes(
359
+ calls: Sequence[SiteCall],
360
+ outcomes: Mapping[str, Outcome],
361
+ response: DecisionResponse | None,
362
+ meta: CallMeta,
363
+ *,
364
+ state: State | None = None,
365
+ ) -> None:
366
+ """One telemetry line per site; cost and tokens split across sites."""
367
+ share = max(1, len(calls))
368
+ fresh = response is not None and meta.reason == "ok"
369
+ cost = call_cost_usd(meta.transport, response.usage) if fresh and response else None
370
+ tokens = response.usage.input_tokens // share if fresh and response else 0
371
+ for call in calls:
372
+ out = outcomes[call.site.name]
373
+ keys = sorted(call.site.questions_of(state))
374
+ record(_record_for(call, out, response, meta, (cost, share, tokens), keys))
375
+
376
+
377
+ def _record_for(
378
+ call: SiteCall,
379
+ out: Outcome,
380
+ response: DecisionResponse | None,
381
+ meta: CallMeta,
382
+ split: tuple[float | None, int, int],
383
+ question_keys: list[str],
384
+ ) -> DecisionRecord:
385
+ cost, share, tokens = split
386
+ # ``out.answers`` is already cleaned (resolve) and empty on fallbacks.
387
+ return DecisionRecord(
388
+ session_id=meta.session_id, site=call.site.name, mode=out.mode,
389
+ call_id=meta.call_id, question_keys=question_keys,
390
+ answers=_compact(out.answers), confidence=out.confidence,
391
+ latency_ms=meta.latency_ms, input_tokens=tokens,
392
+ cost_usd=round(cost / share, 10) if cost is not None else 0.0,
393
+ transport=meta.transport.name,
394
+ model=_model_echo(response, meta.transport),
395
+ fallback_used=out.jev is None, reason=out.reason,
396
+ heuristic_result=out.heuristic, jev_result=out.jev,
397
+ agree=(out.jev == out.heuristic) if out.jev is not None else None,
398
+ acted_on=out.acted_on, state_sha16=meta.state_sha16,
399
+ cache_hit=meta.reason == "cache-hit",
400
+ )
@@ -0,0 +1,118 @@
1
+ """Pydantic contract of the OpenRouter Decisions endpoint (Jev).
2
+
3
+ Request: ``{"model", "state", "questions": {key: {type, instructions,
4
+ criteria}}}``. Response: ``{"model", "answers": {key: {...}}, "usage"}``.
5
+ Criteria shape depends on the question type: ``choice`` maps option →
6
+ description (1..255 options), ``score`` lists 2..10 ordered levels,
7
+ ``noul`` (yes/no probability) takes none.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import StrEnum
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
16
+
17
+ MAX_CHOICE_OPTIONS = 255
18
+ SCORE_LEVELS = (2, 10)
19
+
20
+ State = str | dict[str, Any] | list[Any]
21
+
22
+
23
+ class QuestionType(StrEnum):
24
+ """The three typed question kinds Jev answers."""
25
+
26
+ NOUL = "noul"
27
+ CHOICE = "choice"
28
+ SCORE = "score"
29
+
30
+
31
+ class Question(BaseModel):
32
+ """One typed question; criteria are validated against the type."""
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ type: QuestionType
37
+ instructions: str = Field(min_length=1)
38
+ criteria: dict[str, str] | list[str] | None = None
39
+
40
+ @model_validator(mode="after")
41
+ def _criteria_match_type(self) -> Question:
42
+ if self.type is QuestionType.NOUL:
43
+ _require(self.criteria is None, "noul takes no criteria")
44
+ elif self.type is QuestionType.CHOICE:
45
+ ok = isinstance(self.criteria, dict)
46
+ _require(ok, "choice needs a dict of options")
47
+ size = len(self.criteria or {})
48
+ _require(1 <= size <= MAX_CHOICE_OPTIONS, "choice needs 1..255 options")
49
+ else:
50
+ ok = isinstance(self.criteria, list)
51
+ _require(ok, "score needs a list of levels")
52
+ low, high = SCORE_LEVELS
53
+ _require(low <= len(self.criteria or []) <= high, "score needs 2..10 levels")
54
+ return self
55
+
56
+ def to_payload(self) -> dict[str, Any]:
57
+ """Wire form: ``criteria`` omitted when absent."""
58
+ payload: dict[str, Any] = {"type": self.type.value, "instructions": self.instructions}
59
+ if self.criteria is not None:
60
+ payload["criteria"] = self.criteria
61
+ return payload
62
+
63
+
64
+ def _require(condition: bool, message: str) -> None:
65
+ if not condition:
66
+ raise ValueError(message)
67
+
68
+
69
+ class DecisionRequest(BaseModel):
70
+ """One call: a state and every question asked about it."""
71
+
72
+ model: str = Field(min_length=1)
73
+ state: State
74
+ questions: dict[str, Question] = Field(min_length=1)
75
+
76
+ def to_payload(self) -> dict[str, Any]:
77
+ """JSON body for ``POST /api/alpha/decisions``."""
78
+ return {
79
+ "model": self.model,
80
+ "state": self.state,
81
+ "questions": {k: q.to_payload() for k, q in self.questions.items()},
82
+ }
83
+
84
+
85
+ class Answer(BaseModel):
86
+ """One typed answer. Unknown fields are ignored (alpha endpoint)."""
87
+
88
+ model_config = ConfigDict(extra="ignore")
89
+
90
+ type: QuestionType | None = None
91
+ choice: str | None = None
92
+ # Real responses carry a fractional expected level (e.g. 2.85);
93
+ # ``legend``/``probabilities`` are keyed "0".."n" (smoke 2026-09-23).
94
+ score: float | str | None = None
95
+ noul: float | None = Field(default=None, ge=0.0, le=1.0)
96
+ probabilities: dict[str, float] | list[float] | None = None
97
+ legend: Any = None
98
+ confidence: float | None = Field(default=None, ge=0.0, le=1.0)
99
+
100
+
101
+ class Usage(BaseModel):
102
+ """Token accounting; ``cost`` is the provider-reported USD cost."""
103
+
104
+ model_config = ConfigDict(extra="ignore")
105
+
106
+ input_tokens: int = 0
107
+ output_tokens: int = 0
108
+ cost: float | None = None
109
+
110
+
111
+ class DecisionResponse(BaseModel):
112
+ """The endpoint's answer map plus usage."""
113
+
114
+ model_config = ConfigDict(extra="ignore")
115
+
116
+ model: str = ""
117
+ answers: dict[str, Answer]
118
+ usage: Usage = Field(default_factory=Usage)
@@ -0,0 +1,39 @@
1
+ """Filesystem roots of the decisions layer, each with an env override.
2
+
3
+ Resolved at call time (never cached at import) so tests and sandboxes
4
+ redirect them with an environment variable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ CACHE_ENV = "ARKA_DECISIONS_CACHE_DIR"
13
+ TELEMETRY_ENV = "ARKA_DECISIONS_TELEMETRY_PATH"
14
+
15
+
16
+ def cache_root() -> Path:
17
+ """``~/.arkaos/cache/decisions`` unless ``ARKA_DECISIONS_CACHE_DIR`` is set."""
18
+ override = os.environ.get(CACHE_ENV, "").strip()
19
+ if override:
20
+ return Path(override)
21
+ return Path.home() / ".arkaos" / "cache" / "decisions"
22
+
23
+
24
+ def spool_dir() -> Path:
25
+ """Where shadow calls wait for their detached worker."""
26
+ return cache_root() / "spool"
27
+
28
+
29
+ def telemetry_path() -> Path:
30
+ """``~/.arkaos/telemetry/decisions.jsonl`` unless overridden."""
31
+ override = os.environ.get(TELEMETRY_ENV, "").strip()
32
+ if override:
33
+ return Path(override)
34
+ return Path.home() / ".arkaos" / "telemetry" / "decisions.jsonl"
35
+
36
+
37
+ def repo_root() -> Path:
38
+ """The checkout this package runs from (PYTHONPATH for workers)."""
39
+ return Path(__file__).resolve().parents[2]