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,34 @@
1
+ {"id": "td-pt-01", "lang": "pt", "prompt": "agora prepara o email de lançamento", "prior": ["corrige o bug do login"], "expected": true}
2
+ {"id": "td-pt-02", "lang": "pt", "prompt": "escreve um post para o LinkedIn sobre o nosso novo produto", "prior": ["implementa a paginação na lista de encomendas", "adiciona testes à paginação"], "expected": true}
3
+ {"id": "td-pt-03", "lang": "pt", "prompt": "configura o pipeline de CI no GitHub Actions", "prior": ["revê a proposta comercial para o ClienteY"], "expected": true}
4
+ {"id": "td-pt-04", "lang": "pt", "prompt": "desenha um logótipo para a marca ProjetoX", "prior": ["analisa os custos de tokens desta semana"], "expected": true}
5
+ {"id": "td-pt-05", "lang": "pt", "prompt": "qual é a melhor receita de bacalhau?", "prior": ["migra a base de dados para PostgreSQL 17"], "expected": true}
6
+ {"id": "td-pt-06", "lang": "pt", "prompt": "corrige o erro de TypeScript no componente do checkout", "prior": ["prepara o orçamento do trimestre"], "expected": true}
7
+ {"id": "td-pt-07", "lang": "pt", "prompt": "refatora o serviço de autenticação", "prior": ["escreve o guião do vídeo curto sobre produtividade"], "expected": true}
8
+ {"id": "td-pt-08", "lang": "pt", "prompt": "faz a reconciliação das faturas de agosto", "prior": ["cria a landing page do webinar"], "expected": true}
9
+ {"id": "td-pt-09", "lang": "pt", "prompt": "faz o planeamento financeiro do próximo ano", "prior": ["cria o componente do calendário"], "expected": true}
10
+ {"id": "td-pt-10", "lang": "pt", "prompt": "e agora adiciona um teste para esse caso", "prior": ["corrige o bug do login"], "expected": false}
11
+ {"id": "td-pt-11", "lang": "pt", "prompt": "usa 20 itens por página em vez de 50", "prior": ["implementa a paginação na lista de encomendas"], "expected": false}
12
+ {"id": "td-pt-12", "lang": "pt", "prompt": "torna o assunto mais curto e direto", "prior": ["escreve o email de lançamento"], "expected": false}
13
+ {"id": "td-pt-13", "lang": "pt", "prompt": "acrescenta uma secção de preços à proposta", "prior": ["revê a proposta para o ClienteY"], "expected": false}
14
+ {"id": "td-pt-14", "lang": "pt", "prompt": "o job de testes falhou, vê o log", "prior": ["configura o pipeline de CI"], "expected": false}
15
+ {"id": "td-pt-15", "lang": "pt", "prompt": "divide a análise do churn por plano", "prior": ["analisa o churn do último trimestre"], "expected": false}
16
+ {"id": "td-pt-16", "lang": "pt", "prompt": "muda o título da landing para algo mais forte", "prior": ["cria a landing page do webinar"], "expected": false}
17
+ {"id": "td-pt-17", "lang": "pt", "prompt": "não te esqueças dos índices na migração", "prior": ["migra a base de dados para PostgreSQL 17"], "expected": false}
18
+ {"id": "td-pt-18", "lang": "pt", "prompt": "cria um script de backup", "prior": [], "expected": false}
19
+ {"id": "td-pt-19", "lang": "pt", "prompt": "continua", "prior": ["corrige o bug do login", "adiciona teste ao login"], "expected": false}
20
+ {"id": "td-pt-20", "lang": "pt", "prompt": "põe o calendário a começar à segunda-feira", "prior": ["cria o componente do calendário"], "expected": false}
21
+ {"id": "td-en-01", "lang": "en", "prompt": "now draft the launch announcement for Twitter", "prior": ["fix the login redirect bug"], "expected": true}
22
+ {"id": "td-en-02", "lang": "en", "prompt": "what's a good name for our podcast?", "prior": ["refactor the payment service into smaller classes"], "expected": true}
23
+ {"id": "td-en-03", "lang": "en", "prompt": "write a cold email sequence for SaaS founders", "prior": ["set up the staging database"], "expected": true}
24
+ {"id": "td-en-04", "lang": "en", "prompt": "debug the flaky websocket test in CI", "prior": ["review the pricing page copy"], "expected": true}
25
+ {"id": "td-en-05", "lang": "en", "prompt": "design a colour palette for the brand refresh", "prior": ["plan the sprint backlog for next week"], "expected": true}
26
+ {"id": "td-en-06", "lang": "en", "prompt": "negotiate the renewal terms with ClienteY", "prior": ["summarise the research notes on vector databases"], "expected": true}
27
+ {"id": "td-en-07", "lang": "en", "prompt": "also add a regression test for it", "prior": ["fix the login redirect bug"], "expected": false}
28
+ {"id": "td-en-08", "lang": "en", "prompt": "keep the public interface unchanged while you do it", "prior": ["refactor the payment service"], "expected": false}
29
+ {"id": "td-en-09", "lang": "en", "prompt": "use the same credentials vault as production", "prior": ["set up the staging database"], "expected": false}
30
+ {"id": "td-en-10", "lang": "en", "prompt": "make the headline shorter", "prior": ["review the pricing page copy"], "expected": false}
31
+ {"id": "td-en-11", "lang": "en", "prompt": "move the auth stories to the top", "prior": ["plan the sprint backlog"], "expected": false}
32
+ {"id": "td-en-12", "lang": "en", "prompt": "compare pgvector and Qdrant in a table", "prior": ["summarise the research notes on vector databases"], "expected": false}
33
+ {"id": "td-en-13", "lang": "en", "prompt": "mention the new decisions layer first", "prior": ["write the release notes for v5.18"], "expected": false}
34
+ {"id": "td-en-14", "lang": "en", "prompt": "now run the smoke tests against staging", "prior": ["deploy the api to staging"], "expected": false}
@@ -0,0 +1,60 @@
1
+ {"id": "ui-in-ts-01", "lang": "en", "prompt": "export {}", "context": {"path": "app/Button.styles.ts"}, "expected": true}
2
+ {"id": "ui-in-ts-02", "lang": "en", "prompt": "export default {}", "context": {"path": "tailwind.config.ts"}, "expected": true}
3
+ {"id": "ui-in-ts-03", "lang": "en", "prompt": "export const t = {}", "context": {"path": "src/theme.ts"}, "expected": true}
4
+ {"id": "ui-in-ts-04", "lang": "en", "prompt": "el.className = \"btn btn-primary\"", "context": {"path": "app/render.ts"}, "expected": true}
5
+ {"id": "ui-in-ts-05", "lang": "en", "prompt": "const Card = styled.div`padding: 4px`", "context": {"path": "app/card.ts"}, "expected": true}
6
+ {"id": "ui-in-ts-06", "lang": "en", "prompt": "export const button = cva('btn', { variants: { size: { sm: 'h-8', lg: 'h-12' } } })", "context": {"path": "src/ui/variants.ts"}, "expected": true}
7
+ {"id": "ui-in-ts-07", "lang": "en", "prompt": "// store do carrinho\nexport const useCart = defineStore('cart', { state: () => ({ items: [] }) })", "context": {"path": "app/stores/cart.ts"}, "expected": false}
8
+ {"id": "ui-in-ts-08", "lang": "en", "prompt": "// cliente HTTP para a API de encomendas\nexport async function getOrders(): Promise<Order[]> {\n const res = await fetch('/api/orders')\n return res.json()\n}", "context": {"path": "src/api/orders.ts"}, "expected": false}
9
+ {"id": "ui-in-ts-09", "lang": "en", "prompt": "// tipos partilhados\nexport interface Order { id: string; total: number }", "context": {"path": "src/types/order.ts"}, "expected": false}
10
+ {"id": "ui-in-ts-10", "lang": "en", "prompt": "// script de build\nimport { build } from 'esbuild'\nawait build({ entryPoints: ['src/index.ts'], bundle: true })", "context": {"path": "scripts/build.mjs"}, "expected": false}
11
+ {"id": "ui-in-ts-11", "lang": "en", "prompt": "// servidor Express\napp.get('/health', (_req, res) => res.json({ ok: true }))", "context": {"path": "server/index.js"}, "expected": false}
12
+ {"id": "ui-in-ts-12", "lang": "en", "prompt": "// teste da função de soma\nimport { sum } from './sum'\ntest('soma', () => expect(sum(1, 2)).toBe(3))", "context": {"path": "src/sum.test.ts"}, "expected": false}
13
+ {"id": "ui-in-ts-13", "lang": "pt", "prompt": "// composable que mostra um toast no ecrã\nexport function useToast() {\n const el = document.createElement('div')\n el.style.position = 'fixed'\n el.style.bottom = '16px'\n document.body.appendChild(el)\n return (msg: string) => { el.textContent = msg }\n}", "context": {"path": "src/composables/useToast.ts"}, "expected": true}
14
+ {"id": "ui-in-ts-14", "lang": "en", "prompt": "// animação do hero\nimport gsap from 'gsap'\ngsap.from('.hero h1', { y: 40, opacity: 0, duration: 0.8 })", "context": {"path": "src/animations/hero.ts"}, "expected": true}
15
+ {"id": "ui-in-ts-15", "lang": "en", "prompt": "// tokens de design\nexport const colors = { zinc: { 900: '#18181b' }, rose: { 500: '#f43f5e' } }\nexport const radius = { md: '8px' }", "context": {"path": "src/design/tokens.ts"}, "expected": true}
16
+ {"id": "ui-in-ts-16", "lang": "pt", "prompt": "// renderiza a tabela de preços\nexport function renderPricing(root: HTMLElement, plans: Plan[]) {\n root.innerHTML = plans.map(p => `<article class=\"plan\"><h3>${p.name}</h3><p>${p.price}</p></article>`).join('')\n}", "context": {"path": "src/pricing/render.ts"}, "expected": true}
17
+ {"id": "ui-in-ts-17", "lang": "en", "prompt": "// configuração do Vite\nimport { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 3399 } })", "context": {"path": "vite.config.ts"}, "expected": false}
18
+ {"id": "ui-in-ts-18", "lang": "en", "prompt": "// validação com Zod\nexport const schema = z.object({ email: z.string().email() })", "context": {"path": "src/validation/signup.ts"}, "expected": false}
19
+ {"id": "ui-in-ts-19", "lang": "en", "prompt": "// helper de datas\nexport const toIso = (d: Date) => d.toISOString().slice(0, 10)", "context": {"path": "src/utils/date.ts"}, "expected": false}
20
+ {"id": "ui-in-ts-20", "lang": "en", "prompt": "// router do Vue\nexport default createRouter({ history: createWebHistory(), routes })", "context": {"path": "src/router/index.ts"}, "expected": false}
21
+ {"id": "ui-in-ts-21", "lang": "en", "prompt": "// middleware de autenticação\nexport default defineNuxtRouteMiddleware((to) => { if (!useAuth().user) return navigateTo('/login') })", "context": {"path": "middleware/auth.ts"}, "expected": false}
22
+ {"id": "ui-in-ts-22", "lang": "pt", "prompt": "// componente em render function\nimport { h } from 'vue'\nexport default { render: () => h('button', { class: 'rounded-md px-3 py-2' }, 'Guardar') }", "context": {"path": "src/components/SaveButton.ts"}, "expected": true}
23
+ {"id": "ui-in-ts-23", "lang": "en", "prompt": "// preset do Tailwind da marca\nmodule.exports = { theme: { extend: { fontFamily: { sans: ['Space Grotesk'] } } } }", "context": {"path": "config/brand-preset.cjs"}, "expected": true}
24
+ {"id": "ui-in-ts-24", "lang": "en", "prompt": "// variantes de classes\nexport const badge = (tone: 'ok' | 'err') => tone === 'ok' ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800'", "context": {"path": "src/ui/badge.ts"}, "expected": true}
25
+ {"id": "ui-in-ts-25", "lang": "en", "prompt": "// worker da fila de emails\nexport async function handle(job: Job) { await mailer.send(job.data) }", "context": {"path": "workers/email.ts"}, "expected": false}
26
+ {"id": "ui-in-ts-26", "lang": "en", "prompt": "// migração de dados\nexport async function up(db: Db) { await db.schema.alterTable('orders', t => t.integer('total_cents')) }", "context": {"path": "migrations/20260924_total_cents.ts"}, "expected": false}
27
+ {"id": "ui-in-ts-27", "lang": "en", "prompt": "import chalk from 'chalk'\nconsole.log(chalk.green('done'))", "context": {"path": "bin/cli.js"}, "expected": false}
28
+ {"id": "ui-in-ts-28", "lang": "en", "prompt": "export function formatPrice(cents: number): string { return (cents / 100).toFixed(2) }", "context": {"path": "src/format.ts"}, "expected": false}
29
+ {"id": "ui-in-ts-29", "lang": "en", "prompt": "const chart = new Chart(canvas, { type: 'bar', data, options: { plugins: { legend: { display: false } } } })", "context": {"path": "src/charts/revenue.ts"}, "expected": true}
30
+ {"id": "ui-in-ts-30", "lang": "en", "prompt": "export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' })", "context": {"path": "src/logger.ts"}, "expected": false}
31
+ {"id": "ui-in-ts-31", "lang": "en", "prompt": "document.querySelector('#menu')!.classList.toggle('open')", "context": {"path": "src/menu.js"}, "expected": true}
32
+ {"id": "ui-in-ts-32", "lang": "en", "prompt": "export default defineEventHandler(async () => ({ status: 'ok' }))", "context": {"path": "server/api/health.ts"}, "expected": false}
33
+ {"id": "ui-in-ts-33", "lang": "pt", "prompt": "// mostra uma mensagem quando a lista de encomendas está vazia\nimport { h, defineComponent } from 'vue'\nexport default defineComponent({ setup: () => () => h('p', { class: 'text-sm text-zinc-500' }, 'Ainda não tens encomendas.') })", "context": {"path": "src/components/EmptyState.ts"}, "expected": true}
34
+ {"id": "ui-in-ts-34", "lang": "pt", "prompt": "export function avisar(msg: string) {\n const el = document.createElement('div')\n el.className = 'toast'\n el.textContent = msg\n document.body.append(el)\n}\navisar('As alterações foram guardadas.')", "context": {"path": "src/ui/toast.ts"}, "expected": true}
35
+ {"id": "ui-in-ts-35", "lang": "pt", "prompt": "// cores da marca: o rosa só aparece em estados de alerta\nexport const cores = { fundo: '#09090b', texto: '#fafafa', alerta: '#f43f5e' }", "context": {"path": "src/theme/cores.ts"}, "expected": true}
36
+ {"id": "ui-in-ts-36", "lang": "pt", "prompt": "// faz aparecer os cartões um a um quando entram no ecrã\nimport gsap from 'gsap'\ngsap.from('.cartao', { opacity: 0, y: 24, stagger: 0.1, scrollTrigger: '.grelha' })", "context": {"path": "src/animations/entrada.ts"}, "expected": true}
37
+ {"id": "ui-in-ts-37", "lang": "pt", "prompt": "import { h } from 'vue'\nexport default { render: () => h('nav', { 'aria-label': 'Paginação' }, [h('button', { class: 'btn' }, 'Anterior'), h('button', { class: 'btn' }, 'Seguinte')]) }", "context": {"path": "src/components/Paginacao.ts"}, "expected": true}
38
+ {"id": "ui-in-ts-38", "lang": "pt", "prompt": "// abre o diálogo de confirmação e bloqueia o scroll da página\nexport function confirmar(dialog: HTMLDialogElement) {\n dialog.showModal()\n document.body.style.overflow = 'hidden'\n}", "context": {"path": "src/dom/modal.ts"}, "expected": true}
39
+ {"id": "ui-in-ts-39", "lang": "pt", "prompt": "// botão principal, com o contorno visível ao navegar pelo teclado\nexport const Botao = styled.button`\n padding: 8px 16px;\n &:focus-visible { outline: 2px solid #f43f5e; }\n`", "context": {"path": "src/styles/botao.ts"}, "expected": true}
40
+ {"id": "ui-in-ts-40", "lang": "pt", "prompt": "// pago fica a verde, pendente fica a âmbar\nexport const classeEstado = (pago: boolean) =>\n pago ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'", "context": {"path": "src/ui/estado.ts"}, "expected": true}
41
+ {"id": "ui-in-ts-41", "lang": "pt", "prompt": "new Chart(canvas, { type: 'line', data, options: { plugins: { title: { display: true, text: 'Vendas por mês' } } } })", "context": {"path": "src/charts/vendas.ts"}, "expected": true}
42
+ {"id": "ui-in-ts-42", "lang": "pt", "prompt": "export function montar(root: HTMLElement) {\n root.innerHTML = `<label for=\"email\">Endereço de email</label><input id=\"email\" type=\"email\"><button>Subscrever</button>`\n}", "context": {"path": "src/forms/subscricao.ts"}, "expected": true}
43
+ {"id": "ui-in-ts-43", "lang": "pt", "prompt": "// o título usa Space Grotesk; o texto corrido usa IBM Plex Sans\nexport default { theme: { extend: { fontFamily: { display: ['Space Grotesk'], sans: ['IBM Plex Sans'] } } } }", "context": {"path": "config/fonts.ts"}, "expected": true}
44
+ {"id": "ui-in-ts-44", "lang": "pt", "prompt": "const banner = document.getElementById('manutencao')!\nbanner.hidden = false\nbanner.querySelector('p')!.textContent = 'Estamos em manutenção entre as 2h e as 4h.'", "context": {"path": "src/dom/banner.ts"}, "expected": true}
45
+ {"id": "ui-in-ts-45", "lang": "pt", "prompt": "// devolve 404 quando a encomenda não existe\nexport default defineEventHandler(async (event) => {\n const encomenda = await db.find(getRouterParam(event, 'id'))\n if (!encomenda) throw createError({ statusCode: 404, statusMessage: 'Encomenda não encontrada' })\n return encomenda\n})", "context": {"path": "server/api/encomendas/[id].ts"}, "expected": false}
46
+ {"id": "ui-in-ts-46", "lang": "pt", "prompt": "// apaga os ficheiros temporários com mais de sete dias\nconst n = await removeOlderThan(tmpDir, 7)\nconsole.log(`Apaguei ${n} ficheiros temporários.`)", "context": {"path": "bin/limpar.js"}, "expected": false}
47
+ {"id": "ui-in-ts-47", "lang": "pt", "prompt": "// guarda o utilizador autenticado e o token; não desenha nada\nexport const useSessao = defineStore('sessao', { state: () => ({ utilizador: null, token: '' }) })", "context": {"path": "src/stores/sessao.ts"}, "expected": false}
48
+ {"id": "ui-in-ts-48", "lang": "pt", "prompt": "export const morada = z.object({\n codigoPostal: z.string().regex(/^\\d{4}-\\d{3}$/, 'O código postal tem de ter o formato 0000-000'),\n})", "context": {"path": "src/validation/morada.ts"}, "expected": false}
49
+ {"id": "ui-in-ts-49", "lang": "pt", "prompt": "import { comIva } from './iva'\ntest('aplica a taxa de IVA de 23 % ao preço base', () => expect(comIva(100)).toBe(123))", "context": {"path": "src/utils/iva.test.ts"}, "expected": false}
50
+ {"id": "ui-in-ts-50", "lang": "pt", "prompt": "// gera o sitemap a partir das rotas publicadas e escreve-o em public/\nimport { writeFile } from 'node:fs/promises'\nawait writeFile('public/sitemap.xml', toXml(await rotasPublicadas()))", "context": {"path": "scripts/gerar-sitemap.mjs"}, "expected": false}
51
+ {"id": "ui-in-ts-51", "lang": "pt", "prompt": "// envia a fatura por email quando o pagamento é confirmado\nexport async function handle(job: Job<{ faturaId: string }>) { await enviarFatura(job.data.faturaId) }", "context": {"path": "workers/faturas.ts"}, "expected": false}
52
+ {"id": "ui-in-ts-52", "lang": "pt", "prompt": "// procura clientes pelo NIF; devolve uma lista vazia quando não há resultados\nexport async function porNif(nif: string): Promise<Cliente[]> {\n const res = await fetch(`/api/clientes?nif=${nif}`)\n return res.ok ? res.json() : []\n}", "context": {"path": "src/api/clientes.ts"}, "expected": false}
53
+ {"id": "ui-in-ts-53", "lang": "pt", "prompt": "/** Estado da fatura: fica em rascunho até ser emitida e depois já não pode mudar. */\nexport type EstadoFatura = 'rascunho' | 'emitida'", "context": {"path": "src/types/fatura.ts"}, "expected": false}
54
+ {"id": "ui-in-ts-54", "lang": "pt", "prompt": "export default defineEventHandler((event) => {\n if (excedeuLimite(getRequestIP(event)))\n throw createError({ statusCode: 429, statusMessage: 'Demasiados pedidos, tenta daqui a um minuto.' })\n})", "context": {"path": "server/middleware/limite.ts"}, "expected": false}
55
+ {"id": "ui-in-ts-55", "lang": "pt", "prompt": "// regista a alteração para a auditoria, sem guardar a palavra-passe\nexport const registar = (id: string) => logger.info({ utilizador: id }, 'Palavra-passe alterada com sucesso')", "context": {"path": "src/auditoria.ts"}, "expected": false}
56
+ {"id": "ui-in-ts-56", "lang": "pt", "prompt": "// acrescenta o código postal às moradas; as linhas antigas ficam a nulo\nexport async function up(db: Db) { await db.schema.alterTable('moradas', t => t.string('codigo_postal').nullable()) }", "context": {"path": "migrations/20260924_moradas.ts"}, "expected": false}
57
+ {"id": "ui-in-ts-57", "lang": "pt", "prompt": "// em desenvolvimento, encaminha /api para o servidor local na porta 8000\nexport default defineConfig({ server: { proxy: { '/api': 'http://localhost:8000' } } })", "context": {"path": "vite.config.ts"}, "expected": false}
58
+ {"id": "ui-in-ts-58", "lang": "pt", "prompt": "// formata cêntimos em euros com vírgula decimal, como se escreve em Portugal\nexport const euros = (c: number) => new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(c / 100)", "context": {"path": "src/utils/moeda.ts"}, "expected": false}
59
+ {"id": "ui-in-ts-59", "lang": "pt", "prompt": "// todas as manhãs, lembra os clientes que têm faturas em atraso\nexport default defineTask({ meta: { name: 'lembretes' }, run: async () => ({ result: await lembrarAtrasos() }) })", "context": {"path": "server/cron/lembretes.ts"}, "expected": false}
60
+ {"id": "ui-in-ts-60", "lang": "pt", "prompt": "// sincroniza o stock com o ERP e ignora os artigos descontinuados\nexport async function sincronizar(erp: Erp) {\n for (const artigo of await erp.artigos())\n if (!artigo.descontinuado) await db.stock.upsert(artigo.sku, artigo.quantidade)\n}", "context": {"path": "src/sync/stock.ts"}, "expected": false}
@@ -681,6 +681,7 @@
681
681
  "enforcement_template": {
682
682
  "allow": true,
683
683
  "approval_state": "",
684
+ "bash_path": "",
684
685
  "bypass_used": false,
685
686
  "marker_found": null,
686
687
  "phase_observed": null,
@@ -27,8 +27,8 @@ providers:
27
27
  type: anthropic-direct # SDK; needs ANTHROPIC_API_KEY
28
28
  ollama:
29
29
  type: ollama # local models, http://localhost:11434
30
- # openrouter: # one key -> hundreds of models (Model Fabric PR-B)
31
- # type: openrouter
30
+ openrouter: # one key -> hundreds of models; also the Jev decisions transport
31
+ type: openrouter # needs OPENROUTER_API_KEY
32
32
 
33
33
  # ─── Aliases ────────────────────────────────────────────────────────────
34
34
  # `best` / `default` / `fast` resolve per provider, so roles stay readable.
@@ -68,3 +68,11 @@ fusion:
68
68
  # - {provider: ollama, model: kimi-k2.6}
69
69
  # - {provider: openrouter, model: deepseek/deepseek-v4-pro}
70
70
  # - {provider: anthropic, model: best}
71
+
72
+ # ─── Decisions (Jev typed classifier — not a role) ──────────────────────
73
+ # Typed yes/no, choice and score answers for hooks, gates, Forge and
74
+ # cognition (core/decisions/). No text generation, so never a role above.
75
+ # Per-site modes (act | shadow | off) live in ~/.arkaos/config.json.
76
+ decisions:
77
+ transport: openrouter # only transport supported (operator decision 2026-09-23)
78
+ model: jev-1.13 # alias -> typesafe/jev-1.13 on OpenRouter
@@ -0,0 +1,8 @@
1
+ """Typed decisions layer — System 1 under the ArkaOS agents.
2
+
3
+ A thin client for the OpenRouter Decisions endpoint (TypeSafe Jev):
4
+ typed questions in (``noul`` / ``choice`` / ``score``), typed answers
5
+ out, never generated text. Every call site keeps its heuristic as the
6
+ unavailability fallback; :func:`core.decisions.engine.decide` never
7
+ raises. See ADR ``docs/adr/2026-09-23-jev-decisions-layer.md``.
8
+ """
@@ -0,0 +1,137 @@
1
+ """Circuit breaker shared by every process: one JSON file under the cache.
2
+
3
+ ``trip`` records ``{"reason", "until"}``; ``blocked`` answers the reason
4
+ while ``now < until``. A longer existing block is never shortened — unless
5
+ another key set it: an ``http-401`` trip also records the key's
6
+ fingerprint (:func:`~core.decisions.transport.key_fingerprint`), and
7
+ rotating the key reopens the breaker at once (R-B1).
8
+ Neither function raises — a broken breaker means "not blocked".
9
+
10
+ Transport failures (``timeout`` / ``network``) trip it too, but only
11
+ after :data:`FAILURE_TRIP_COUNT` in a row (``failures.json``, shared by
12
+ every hook process): one slow turn is noise, three are an outage the
13
+ next turns must not each pay for (QG r1 B2). A success clears the count;
14
+ a count older than :data:`FAILURE_WINDOW_S` starts over.
15
+
16
+ Directories this module creates are 0700 and its files 0600 (QG r1 m3).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import json
23
+ import os
24
+ import tempfile
25
+ import time
26
+ from pathlib import Path
27
+
28
+ from core.decisions.paths import cache_root
29
+ from core.decisions.transport import key_fingerprint
30
+
31
+ # No caller trips for longer (client.py caps Retry-After at an hour); a
32
+ # deadline further out is a corrupt or planted file, not a real trip,
33
+ # and must neither block forever nor stop a real trip from landing.
34
+ MAX_BLOCK_S = 3600.0
35
+ FAILURE_TRIP_COUNT = 3
36
+ FAILURE_BACKOFF_S = 60.0
37
+ FAILURE_WINDOW_S = 600.0
38
+
39
+
40
+ def _state_path() -> Path:
41
+ return cache_root() / "backoff.json"
42
+
43
+
44
+ def _failures_path() -> Path:
45
+ return cache_root() / "failures.json"
46
+
47
+
48
+ def ensure_private_dir(path: Path) -> None:
49
+ """``mkdir -p`` where every directory CREATED here is 0700."""
50
+ missing: list[Path] = []
51
+ probe = path
52
+ while not probe.exists() and probe != probe.parent:
53
+ missing.append(probe)
54
+ probe = probe.parent
55
+ for directory in reversed(missing):
56
+ with contextlib.suppress(FileExistsError):
57
+ directory.mkdir(mode=0o700)
58
+ os.chmod(directory, 0o700) # mkdir's mode is masked by the umask
59
+
60
+
61
+ def record_failure(reason: str, now: float | None = None) -> int:
62
+ """Count one transport failure; trip at the threshold. Never raises."""
63
+ ref = time.time() if now is None else now
64
+ try:
65
+ state = json.loads(_failures_path().read_text(encoding="utf-8"))
66
+ count = int(state["count"]) if ref - float(state["last"]) <= FAILURE_WINDOW_S else 0
67
+ except (OSError, ValueError, KeyError, TypeError):
68
+ count = 0
69
+ count += 1
70
+ if count >= FAILURE_TRIP_COUNT:
71
+ trip(reason, FAILURE_BACKOFF_S, now=ref)
72
+ record_success()
73
+ return count
74
+ with contextlib.suppress(OSError, ValueError):
75
+ _atomic_write(_failures_path(), {"count": count, "last": ref})
76
+ return count
77
+
78
+
79
+ def record_success() -> None:
80
+ """A call went through: consecutive failures start from zero."""
81
+ with contextlib.suppress(OSError):
82
+ _failures_path().unlink(missing_ok=True)
83
+
84
+
85
+ def _read() -> dict[str, object]:
86
+ try:
87
+ data = json.loads(_state_path().read_text(encoding="utf-8"))
88
+ except (OSError, ValueError):
89
+ return {}
90
+ return data if isinstance(data, dict) else {}
91
+
92
+
93
+ def blocked(now: float | None = None) -> str | None:
94
+ """The trip reason while the breaker is open, else None."""
95
+ state = _read()
96
+ until = state.get("until")
97
+ if not isinstance(until, int | float) or _set_by_other_key(state):
98
+ return None
99
+ ref = time.time() if now is None else now
100
+ if ref >= until or until - ref > MAX_BLOCK_S:
101
+ return None
102
+ return str(state.get("reason") or "backoff")
103
+
104
+
105
+ def trip(reason: str, seconds: float, now: float | None = None) -> None:
106
+ """Open the breaker for ``seconds``; keeps any later existing deadline."""
107
+ ref = time.time() if now is None else now
108
+ until = ref + min(max(0.0, float(seconds)), MAX_BLOCK_S)
109
+ state = _read()
110
+ current = state.get("until")
111
+ keep = not _set_by_other_key(state)
112
+ if keep and isinstance(current, int | float) and until < current <= ref + MAX_BLOCK_S:
113
+ return
114
+ payload: dict[str, object] = {"reason": reason, "until": until}
115
+ if reason == "http-401":
116
+ payload["key"] = key_fingerprint()
117
+ with contextlib.suppress(OSError, ValueError):
118
+ _atomic_write(_state_path(), payload)
119
+
120
+
121
+ def _set_by_other_key(state: dict[str, object]) -> bool:
122
+ key = state.get("key")
123
+ return isinstance(key, str) and key != key_fingerprint()
124
+
125
+
126
+ def _atomic_write(path: Path, payload: dict[str, object]) -> None:
127
+ ensure_private_dir(path.parent)
128
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".backoff-", suffix=".tmp")
129
+ try:
130
+ os.fchmod(fd, 0o600)
131
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
132
+ json.dump(payload, fh)
133
+ os.replace(tmp, path)
134
+ except BaseException:
135
+ with contextlib.suppress(OSError):
136
+ os.unlink(tmp)
137
+ raise
@@ -0,0 +1,92 @@
1
+ """Response cache keyed by sha256(model + prepared state + questions).
2
+
3
+ Stores RESPONSES only — never the state. One file per key under
4
+ ``<cache_root>/<key[:2]>/<key>.json``, written tmp+rename with mode
5
+ 0600. Corrupt, expired or foreign files are a miss; nothing raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import tempfile
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from pydantic import ValidationError
20
+
21
+ from core.decisions.backoff import ensure_private_dir
22
+ from core.decisions.models import DecisionResponse, Question, State
23
+ from core.decisions.paths import cache_root
24
+
25
+ _KEY_RE = re.compile(r"[0-9a-f]{64}")
26
+
27
+
28
+ def cache_key(model: str, state: State, questions: dict[str, Question]) -> str:
29
+ """Deterministic sha256 over the canonical JSON of the request."""
30
+ canonical = json.dumps(
31
+ {
32
+ "model": model,
33
+ "state": state,
34
+ "questions": {k: q.to_payload() for k, q in questions.items()},
35
+ },
36
+ sort_keys=True,
37
+ ensure_ascii=False,
38
+ separators=(",", ":"),
39
+ )
40
+ return hashlib.sha256(canonical.encode("utf-8", errors="surrogatepass")).hexdigest()
41
+
42
+
43
+ def _entry_path(key: str) -> Path | None:
44
+ if not _KEY_RE.fullmatch(key):
45
+ return None
46
+ return cache_root() / key[:2] / f"{key}.json"
47
+
48
+
49
+ def get(key: str, ttl_s: float, now: float | None = None) -> DecisionResponse | None:
50
+ """The cached response while younger than ``ttl_s``, else None."""
51
+ path = _entry_path(key)
52
+ if path is None:
53
+ return None
54
+ try:
55
+ entry = json.loads(path.read_text(encoding="utf-8"))
56
+ stored_at = float(entry["stored_at"])
57
+ response = DecisionResponse.model_validate(entry["response"])
58
+ except (OSError, ValueError, KeyError, TypeError, ValidationError):
59
+ return None
60
+ ref = time.time() if now is None else now
61
+ if ttl_s <= 0 or ref - stored_at > ttl_s:
62
+ return None
63
+ return response
64
+
65
+
66
+ def put(key: str, response: DecisionResponse, now: float | None = None) -> bool:
67
+ """Store one response atomically; False when it could not."""
68
+ path = _entry_path(key)
69
+ if path is None:
70
+ return False
71
+ stored_at = time.time() if now is None else now
72
+ payload = {"stored_at": stored_at, "response": response.model_dump(mode="json")}
73
+ try:
74
+ _atomic_write(path, payload)
75
+ except (OSError, ValueError, TypeError):
76
+ return False
77
+ return True
78
+
79
+
80
+ def _atomic_write(path: Path, payload: dict[str, object]) -> None:
81
+ # Every directory created on the way is 0700, not the umask default.
82
+ ensure_private_dir(path.parent)
83
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".entry-", suffix=".tmp")
84
+ try:
85
+ os.fchmod(fd, 0o600)
86
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
87
+ json.dump(payload, fh, ensure_ascii=False)
88
+ os.replace(tmp, path)
89
+ except BaseException:
90
+ with contextlib.suppress(OSError):
91
+ os.unlink(tmp)
92
+ raise
@@ -0,0 +1,191 @@
1
+ """Stdlib client for ``POST /api/alpha/decisions``.
2
+
3
+ :func:`post_decision` raises ONLY :class:`DecisionUnavailable`; its
4
+ ``reason`` is the stable telemetry vocabulary:
5
+
6
+ ``no-transport`` | ``bypassed`` | ``backoff`` | ``deadline`` |
7
+ ``egress-denied:<kind>`` | ``timeout`` | ``network`` | ``http-401`` |
8
+ ``http-422`` | ``http-429`` | ``http-529`` | ``http-<n>`` |
9
+ ``invalid-json`` | ``invalid-shape`` | ``redirected``.
10
+
11
+ A failure decided on this machine is raised with ``local=True``; the
12
+ replay harness reads it through :meth:`DecisionUnavailable.run_reason` as
13
+ ``<reason>:local`` (``invalid-shape:local`` for a state or body that
14
+ cannot be serialised, ``timeout:local`` for the wall-clock deadline) and
15
+ never counts it as a call that reached the endpoint. Live telemetry keeps
16
+ the bare ``reason`` above.
17
+
18
+ 401/429/529 also trip the shared circuit breaker (``backoff.py``);
19
+ ``timeout``/``network`` count towards it (3 in a row trip it for 60 s)
20
+ and a success clears the count.
21
+
22
+ Deadline: ``urlopen(timeout=)`` bounds each socket operation, not the
23
+ call — DNS is not covered and a server dripping one byte every 0.4 s
24
+ held a 1 s call for 11.4 s (QG r1 B1). The exchange runs in a daemon
25
+ thread joined against a monotonic deadline; past it the call is
26
+ ``timeout`` and the thread is abandoned. The thread only fetches bytes:
27
+ parsing, cache, breaker and telemetry all happen on the caller's side,
28
+ so an abandoned thread can write nothing.
29
+
30
+ Redirects: urllib turns a 301/302/303 answer to a POST into a GET and
31
+ copies every non-content header to whatever host ``Location`` names —
32
+ the Bearer key included (reproduced against two local servers in the
33
+ 2026-09-23 security review). ``Authorization`` therefore travels as an
34
+ UNREDIRECTED header (urllib never copies those), the body is dropped
35
+ by urllib itself, and a response whose final URL differs from the one
36
+ requested is refused (``redirected``) so another host cannot answer.
37
+ 307/308 to a POST already surface as ``http-307``/``http-308``.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import threading
44
+ import time
45
+ import urllib.error
46
+ import urllib.request
47
+
48
+ from pydantic import ValidationError
49
+
50
+ from core.decisions import backoff
51
+ from core.decisions.models import DecisionRequest, DecisionResponse
52
+ from core.decisions.transport import Transport
53
+
54
+ BACKOFF_401_S = 3600.0
55
+ BACKOFF_429_DEFAULT_S = 60.0
56
+ BACKOFF_529_S = 120.0
57
+ MAX_RETRY_AFTER_S = 3600.0
58
+
59
+
60
+ # The ``:<cause>`` suffix of a reason decided locally (see run_reason).
61
+ LOCAL_MARK = "local"
62
+
63
+
64
+ class DecisionUnavailable(Exception): # noqa: N818 — contract name from the ADR
65
+ """No typed answer this time; the caller uses its heuristic."""
66
+
67
+ def __init__(self, reason: str, detail: str = "", *, local: bool = False) -> None:
68
+ super().__init__(f"{reason}: {detail}" if detail else reason)
69
+ self.reason = reason
70
+ self.detail = detail
71
+ self.local = local
72
+
73
+ def run_reason(self) -> str:
74
+ """``reason``, suffixed ``:local`` when this machine decided it."""
75
+ return f"{self.reason}:{LOCAL_MARK}" if self.local else self.reason
76
+
77
+
78
+ def post_decision(
79
+ transport: Transport, request: DecisionRequest, timeout_s: float
80
+ ) -> tuple[DecisionResponse, int]:
81
+ """POST one request; ``(response, latency_ms)`` or DecisionUnavailable."""
82
+ try:
83
+ payload = {**request.to_payload(), **transport.body_extras}
84
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
85
+ except (TypeError, ValueError) as exc:
86
+ raise DecisionUnavailable("invalid-shape", type(exc).__name__, local=True) from exc
87
+ req = _request(transport, body)
88
+ start = time.monotonic()
89
+ try:
90
+ raw = _send_by_deadline(req, timeout_s)
91
+ except DecisionUnavailable as exc:
92
+ if exc.reason in ("timeout", "network"):
93
+ backoff.record_failure(exc.reason)
94
+ raise
95
+ latency_ms = int((time.monotonic() - start) * 1000)
96
+ response = _parse(raw)
97
+ backoff.record_success()
98
+ return response, latency_ms
99
+
100
+
101
+ def _request(transport: Transport, body: bytes) -> urllib.request.Request:
102
+ headers = {k: v for k, v in transport.headers.items() if k.lower() != "authorization"}
103
+ req = urllib.request.Request(transport.url, data=body, headers=headers, method="POST")
104
+ for name, value in transport.headers.items():
105
+ if name.lower() == "authorization":
106
+ req.add_unredirected_header(name, value)
107
+ return req
108
+
109
+
110
+ def _send_by_deadline(req: urllib.request.Request, timeout_s: float) -> bytes:
111
+ """``_send`` bounded by wall clock, not per socket operation."""
112
+ box: dict[str, object] = {}
113
+
114
+ def worker() -> None:
115
+ try:
116
+ box["data"] = _send(req, timeout_s)
117
+ except BaseException as exc: # handed to the caller, never lost
118
+ box["error"] = exc
119
+
120
+ thread = threading.Thread(target=worker, name="arka-decision", daemon=True)
121
+ thread.start()
122
+ thread.join(max(0.0, timeout_s))
123
+ if thread.is_alive():
124
+ # Not closed from here: HTTPResponse.close() waits on the reader
125
+ # lock the dripping read holds, so it would block the caller for
126
+ # the whole drip (measured: 11.7 s). The daemon thread ends with
127
+ # the socket or the process; it holds bytes only.
128
+ raise DecisionUnavailable("timeout", "deadline", local=True)
129
+ error = box.get("error")
130
+ if isinstance(error, urllib.error.HTTPError):
131
+ raise _map_http_error(error) from error
132
+ if isinstance(error, BaseException):
133
+ raise error
134
+ data = box.get("data")
135
+ return data if isinstance(data, bytes) else b""
136
+
137
+
138
+ def _send(req: urllib.request.Request, timeout_s: float) -> bytes:
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
141
+ final = getattr(resp, "url", None)
142
+ if isinstance(final, str) and final != req.full_url:
143
+ raise DecisionUnavailable("redirected")
144
+ data: bytes = resp.read()
145
+ return data
146
+ except DecisionUnavailable:
147
+ raise
148
+ except urllib.error.HTTPError:
149
+ raise # mapped on the caller's side: the breaker is never tripped late
150
+ except urllib.error.URLError as exc:
151
+ reason = "timeout" if isinstance(exc.reason, TimeoutError) else "network"
152
+ raise DecisionUnavailable(reason, str(exc.reason)) from exc
153
+ except TimeoutError as exc:
154
+ raise DecisionUnavailable("timeout") from exc
155
+ except Exception as exc:
156
+ raise DecisionUnavailable("network", type(exc).__name__) from exc
157
+
158
+
159
+ def _map_http_error(exc: urllib.error.HTTPError) -> DecisionUnavailable:
160
+ code = int(exc.code)
161
+ if code == 401:
162
+ backoff.trip("http-401", BACKOFF_401_S)
163
+ elif code == 429:
164
+ backoff.trip("http-429", _retry_after(exc))
165
+ elif code == 529:
166
+ backoff.trip("http-529", BACKOFF_529_S)
167
+ return DecisionUnavailable(f"http-{code}", str(exc.reason or ""))
168
+
169
+
170
+ def _retry_after(exc: urllib.error.HTTPError) -> float:
171
+ raw = exc.headers.get("Retry-After") if exc.headers is not None else None
172
+ try:
173
+ seconds = float(str(raw).strip())
174
+ except (TypeError, ValueError):
175
+ return BACKOFF_429_DEFAULT_S
176
+ if seconds <= 0:
177
+ return BACKOFF_429_DEFAULT_S
178
+ return min(seconds, MAX_RETRY_AFTER_S)
179
+
180
+
181
+ def _parse(raw: bytes) -> DecisionResponse:
182
+ try:
183
+ data = json.loads(raw.decode("utf-8"))
184
+ except (UnicodeDecodeError, ValueError) as exc:
185
+ raise DecisionUnavailable("invalid-json", type(exc).__name__) from exc
186
+ if not isinstance(data, dict):
187
+ raise DecisionUnavailable("invalid-shape", "body is not an object")
188
+ try:
189
+ return DecisionResponse.model_validate(data)
190
+ except ValidationError as exc:
191
+ raise DecisionUnavailable("invalid-shape", f"{exc.error_count()} errors") from exc