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,413 @@
1
+ """Replay report — N measured runs per site, the D2 proposal, one evidence folder.
2
+
3
+ ``arka-py -m core.decisions.replay_report (--all | --sites a,b) --session <id>
4
+ [--runs 2] [--live-window 7d]`` (spec PR5, decision D1).
5
+
6
+ * Each run is :func:`core.decisions.replay.replay` at the site's REAL
7
+ ceiling (``site_timeout_ms``; there is no ``--timeout-ms``) on a config
8
+ copy with ``cacheTtlSeconds=0``: ``cache.get`` misses by construction,
9
+ so run 2 asks the endpoint again instead of reading run 1's answers.
10
+ * A run is **measured** only when ≥ :data:`MIN_REACHED_SHARE` of the
11
+ cases that asked a question reached the endpoint and ≤
12
+ :data:`MAX_UNAVAILABLE_SHARE` of all cases were unavailable. Otherwise
13
+ it is ``not-measured``: it moves no mode and the CLI exits 1.
14
+ * The gate per run is the replay gate (Jev ≥ heuristic + 5 pp on the
15
+ answered cases, abstain ≤ 25 %, false escalation ≤ 5 %, MAE ≤ 5.0 for
16
+ slop-score). :func:`core.decisions.promotion.propose_mode` turns the
17
+ runs into a proposal against ``Site.default_mode``; nothing here edits
18
+ config or code.
19
+ * Replay calls land in the cost ledger as ``decision-replay``.
20
+ * The ``live`` column (``telemetry.summarise`` over ``--live-window``) is
21
+ informational: it never decides a mode.
22
+
23
+ Writes ``~/.arkaos/quality-gate/<session>/replay-final/``:
24
+ ``<site>-run<k>.json``, ``REPORT.md``, ``SUMMARY.json``, ``PROPOSALS.json``.
25
+ Exit codes: 0 every site measured, 1 some run not measured, 2 usage /
26
+ bypass / no transport / bad corpus.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import hashlib
33
+ import json
34
+ import re
35
+ import sys
36
+ from collections.abc import Sequence
37
+ from dataclasses import asdict, dataclass, field
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from core.decisions.config import (
43
+ DecisionsConfig,
44
+ Mode,
45
+ bypassed,
46
+ configured_mode,
47
+ load_decisions_config,
48
+ )
49
+ from core.decisions.promotion import ModeProposal, RunGate, RunVerdict, propose_mode
50
+ from core.decisions.registry import SITES
51
+ from core.decisions.replay import ReplayCase, ReplayReport, default_corpus_path, load_corpus, replay
52
+ from core.decisions.telemetry import REPLAY_COST_CATEGORY, SiteSummary, summarise
53
+ from core.decisions.transport import Transport, resolve_transport
54
+
55
+ MIN_REACHED_SHARE = 0.90
56
+ MAX_UNAVAILABLE_SHARE = 0.10
57
+ OUT_DIRNAME = "replay-final"
58
+ SESSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
59
+ LIVE_WINDOWS: dict[str, str] = {
60
+ "today": "today", "7d": "week", "week": "week", "30d": "month", "month": "month",
61
+ "all": "all",
62
+ }
63
+ LIVE_DRIFT_ABSTAIN_PCT = 25.0
64
+ LIVE_DRIFT_MIN_CALLS = 50
65
+ LIVE_DRIFT_PERIODS = frozenset({"week", "month", "all"}) # windows of >= 7 days
66
+
67
+
68
+ @dataclass
69
+ class SiteRun:
70
+ """One replay run of one site and whether it counts."""
71
+
72
+ run: int
73
+ gate: RunGate
74
+ why_not_measured: str
75
+ report: ReplayReport
76
+ ts: str
77
+
78
+
79
+ @dataclass
80
+ class LiveView:
81
+ """Informational live telemetry for one site (never decides a mode)."""
82
+
83
+ calls: int
84
+ abstain_attributable_pct: float | None
85
+ fallback_pct: float
86
+ acted_jev_pct: float
87
+ top_fallback_reasons: list[tuple[str, int]]
88
+ live_drift: bool
89
+
90
+
91
+ @dataclass
92
+ class SiteResult:
93
+ """Every run of one site, its corpus digest, modes and the proposal."""
94
+
95
+ site: str
96
+ corpus_sha256: str
97
+ default_mode: Mode
98
+ effective_mode: Mode
99
+ runs: list[SiteRun]
100
+ proposal: ModeProposal
101
+ live: LiveView | None = None
102
+ cost_usd: float = 0.0
103
+ run_files: list[str] = field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class RunContext:
108
+ """What every site's runs share."""
109
+
110
+ transport: Transport
111
+ cfg: DecisionsConfig
112
+ operator_cfg: DecisionsConfig
113
+ runs: int
114
+ out_dir: Path
115
+ live: dict[str, SiteSummary]
116
+ live_period: str
117
+
118
+
119
+ def _now_iso() -> str:
120
+ return datetime.now(UTC).isoformat()
121
+
122
+
123
+ def corpus_digest(site: str) -> str:
124
+ """sha256 of the corpus file as shipped (a relabel changes it)."""
125
+ return hashlib.sha256(default_corpus_path(site).read_bytes()).hexdigest()
126
+
127
+
128
+ def no_cache_config(cfg: DecisionsConfig) -> DecisionsConfig:
129
+ """``cfg`` with the answer cache off: every run asks the endpoint."""
130
+ return cfg.model_copy(update={"cache_ttl_seconds": 0})
131
+
132
+
133
+ def effective_mode(cfg: DecisionsConfig, site: str) -> Mode:
134
+ """The operator's override when it sets a mode, else the site default (bypass ignored)."""
135
+ return configured_mode(cfg, SITES[site])
136
+
137
+
138
+ def measure(report: ReplayReport) -> tuple[RunGate, str]:
139
+ """The run's gate, or ``not-measured`` with the reason."""
140
+ if report.asked is None or report.reached is None:
141
+ return "not-measured", "offline run"
142
+ if report.asked == 0:
143
+ return "not-measured", "no case asked a question"
144
+ reached = report.reached / report.asked
145
+ if reached < MIN_REACHED_SHARE:
146
+ return "not-measured", (f"{reached:.0%} of asked cases reached the endpoint "
147
+ f"(< {MIN_REACHED_SHARE:.0%})")
148
+ unavailable = report.unavailable / report.cases if report.cases else 1.0
149
+ if unavailable > MAX_UNAVAILABLE_SHARE:
150
+ return "not-measured", f"unavailable {unavailable:.0%} (> {MAX_UNAVAILABLE_SHARE:.0%})"
151
+ return ("pass" if report.gate == "pass" else "fail"), ""
152
+
153
+
154
+ def live_view(summary: SiteSummary | None, period: str) -> LiveView | None:
155
+ """The live column for one site; ``live_drift`` flags a new run + corpus review."""
156
+ if summary is None:
157
+ return None
158
+ pct = summary.abstain_attributable_pct
159
+ drift = (period in LIVE_DRIFT_PERIODS and summary.calls >= LIVE_DRIFT_MIN_CALLS
160
+ and pct is not None and pct > LIVE_DRIFT_ABSTAIN_PCT)
161
+ return LiveView(summary.calls, pct, summary.fallback_pct, summary.acted_jev_pct,
162
+ list(summary.top_fallback_reasons), drift)
163
+
164
+
165
+ def _write_json(path: Path, payload: object) -> None:
166
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str) + "\n",
167
+ encoding="utf-8")
168
+
169
+
170
+ def _run_once(
171
+ site: str, k: int, cases: Sequence[ReplayCase], ctx: RunContext, digest: str
172
+ ) -> SiteRun:
173
+ print(f"replay {site} run {k}/{ctx.runs} ({len(cases)} cases)", file=sys.stderr)
174
+ report = replay(site, cases, transport=ctx.transport, cfg=ctx.cfg)
175
+ gate, why = measure(report)
176
+ run = SiteRun(k, gate, why, report, _now_iso())
177
+ _write_json(ctx.out_dir / f"{site}-run{k}.json", {
178
+ **asdict(report), "run": k, "measured_gate": gate, "why_not_measured": why,
179
+ "corpus_sha256": digest, "cost_category": REPLAY_COST_CATEGORY, "ts": run.ts,
180
+ })
181
+ return run
182
+
183
+
184
+ def run_site(site: str, cases: Sequence[ReplayCase], ctx: RunContext) -> SiteResult:
185
+ """``ctx.runs`` sequential runs of one site, then the D2 proposal."""
186
+ digest = corpus_digest(site)
187
+ runs = [_run_once(site, k, cases, ctx, digest) for k in range(1, ctx.runs + 1)]
188
+ verdicts = [RunVerdict(r.gate, r.report.abstain_rate, digest, r.ts) for r in runs]
189
+ default = SITES[site].default_mode
190
+ return SiteResult(
191
+ site=site, corpus_sha256=digest, default_mode=default,
192
+ effective_mode=effective_mode(ctx.operator_cfg, site), runs=runs,
193
+ proposal=propose_mode(site, default, verdicts),
194
+ live=live_view(ctx.live.get(site), ctx.live_period),
195
+ cost_usd=round(sum(r.report.cost_usd or 0.0 for r in runs), 10),
196
+ run_files=[f"{site}-run{r.run}.json" for r in runs],
197
+ )
198
+
199
+
200
+ # --- rendering ----------------------------------------------------------------
201
+
202
+
203
+ def _pct(value: float | None) -> str:
204
+ return "—" if value is None else f"{value:.1%}"
205
+
206
+
207
+ def _num(value: object) -> str:
208
+ return "—" if value is None else str(value)
209
+
210
+
211
+ def _run_row(result: SiteResult, run: SiteRun) -> str:
212
+ r = run.report
213
+ gate = run.gate if not run.why_not_measured else f"not-measured ({run.why_not_measured})"
214
+ return (
215
+ f"| `{result.site}` | {run.run} | {r.cases} | {r.pt_share:.0%} | "
216
+ f"{_pct(r.jev_accuracy)} vs {_pct(r.heuristic_on_answered)} | {_pct(r.abstain_rate)} | "
217
+ f"{_pct(r.false_escalation_rate)} | {_num(r.mean_abs_error)} | "
218
+ f"{_num(r.p50_latency_ms)} | {_num(r.timeout_ms)} | "
219
+ f"{_num(r.reached)}/{_num(r.asked)} | ${(r.cost_usd or 0.0):.6f} | {gate} |"
220
+ )
221
+
222
+
223
+ def _mode_cell(result: SiteResult) -> str:
224
+ if result.effective_mode == result.default_mode:
225
+ return result.default_mode
226
+ return f"{result.default_mode} (operator override: {result.effective_mode})"
227
+
228
+
229
+ def _proposal_row(result: SiteResult) -> str:
230
+ p = result.proposal
231
+ return (f"| `{result.site}` | {_mode_cell(result)} | {p.action} → {p.proposed} | "
232
+ f"{p.reason} | `{result.corpus_sha256[:16]}` |")
233
+
234
+
235
+ def _live_row(result: SiteResult) -> str:
236
+ lv = result.live
237
+ if lv is None:
238
+ return f"| `{result.site}` | 0 | — | — | — | — | |"
239
+ reasons = ", ".join(f"{r} {n}" for r, n in lv.top_fallback_reasons) or "—"
240
+ drift = "live-drift" if lv.live_drift else ""
241
+ return (f"| `{result.site}` | {lv.calls} | {_pct_raw(lv.abstain_attributable_pct)} | "
242
+ f"{lv.fallback_pct:.1f}% | {lv.acted_jev_pct:.1f}% | {reasons} | {drift} |")
243
+
244
+
245
+ def _pct_raw(value: float | None) -> str:
246
+ return "—" if value is None else f"{value:.1f}%"
247
+
248
+
249
+ def _secondary_lines(results: Sequence[SiteResult]) -> list[str]:
250
+ """Per-run scores outside the gate: D6's two readings, refine's gap choice."""
251
+ rows = [f"| `{res.site}` | {run.run} | {_pct(run.report.abstain_rate)} | "
252
+ f"{_pct(run.report.single_cell_abstain_rate)} | {_pct(run.report.gap_accuracy)} |"
253
+ for res in results for run in res.runs
254
+ if run.report.single_cell_abstain_rate is not None
255
+ or run.report.gap_accuracy is not None]
256
+ if not rows:
257
+ return []
258
+ return ["", "## Secondary scores (never part of the gate)", "",
259
+ "| Site | Run | Abstain (site reading) | Abstain (single-cell, pre-D6) | "
260
+ "Missing choice vs gap |", "|---|---|---|---|---|", *rows]
261
+
262
+
263
+ def render_report(results: Sequence[SiteResult], meta: dict[str, Any]) -> str:
264
+ """REPORT.md: runs, proposals, then the informational live column."""
265
+ lines = [
266
+ f"# Replay report — session {meta['session']}", "",
267
+ f"- Generated: {meta['generated_at']}; runs per site: {meta['runs']}; "
268
+ f"answer cache: off (cacheTtlSeconds=0); ceiling: each site's timeout_ms",
269
+ f"- A run counts only when ≥ {MIN_REACHED_SHARE:.0%} of asked cases reached the "
270
+ f"endpoint and ≤ {MAX_UNAVAILABLE_SHARE:.0%} were unavailable",
271
+ f"- Replay cost: ${meta['cost_usd']:.6f} (ledger category `{REPLAY_COST_CATEGORY}`)",
272
+ "", "## Runs", "",
273
+ "| Site | Run | Cases | pt-PT | Jev vs heuristic (answered) | Abstain | False esc. | "
274
+ "MAE | p50 ms | Ceiling ms | Reached | Cost | Gate |",
275
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|",
276
+ ]
277
+ lines += [_run_row(res, run) for res in results for run in res.runs]
278
+ lines += _secondary_lines(results)
279
+ lines += ["", "## Proposals (rule D2; the PR applies them to `default_mode`)", "",
280
+ "| Site | Mode (default) | Proposal | Why | Corpus sha256 |", "|---|---|---|---|---|"]
281
+ lines += [_proposal_row(res) for res in results]
282
+ lines += ["", f"## Live telemetry ({meta['live_window']}) — informational, never decides "
283
+ "a mode", "",
284
+ "| Site | Calls | Abstain (attributable) | Fallback | Acted Jev | Top fallback "
285
+ "reasons | Flag |", "|---|---|---|---|---|---|---|"]
286
+ lines += [_live_row(res) for res in results]
287
+ return "\n".join(lines) + "\n"
288
+
289
+
290
+ def summary_payload(results: Sequence[SiteResult], meta: dict[str, Any]) -> dict[str, Any]:
291
+ """SUMMARY.json: what the ADR cites per site."""
292
+ sites = {
293
+ res.site: {
294
+ "corpus_sha256": res.corpus_sha256,
295
+ "default_mode": res.default_mode, "effective_mode": res.effective_mode,
296
+ "operator_override": res.effective_mode != res.default_mode,
297
+ "gates": [r.gate for r in res.runs],
298
+ "why_not_measured": [r.why_not_measured for r in res.runs],
299
+ "abstain": [r.report.abstain_rate for r in res.runs],
300
+ "proposal": asdict(res.proposal), "cost_usd": res.cost_usd,
301
+ "live": asdict(res.live) if res.live else None, "run_files": res.run_files,
302
+ }
303
+ for res in results
304
+ }
305
+ return {**meta, "sites": sites}
306
+
307
+
308
+ def write_outputs(results: Sequence[SiteResult], meta: dict[str, Any], out_dir: Path) -> None:
309
+ """REPORT.md, SUMMARY.json and PROPOSALS.json next to the run files."""
310
+ (out_dir / "REPORT.md").write_text(render_report(results, meta), encoding="utf-8")
311
+ _write_json(out_dir / "SUMMARY.json", summary_payload(results, meta))
312
+ _write_json(out_dir / "PROPOSALS.json", [asdict(r.proposal) for r in results])
313
+
314
+
315
+ # --- CLI ----------------------------------------------------------------------
316
+
317
+
318
+ def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
319
+ parser = argparse.ArgumentParser(prog="arka-py -m core.decisions.replay_report")
320
+ which = parser.add_mutually_exclusive_group(required=True)
321
+ which.add_argument("--all", action="store_true", help="every registered site")
322
+ which.add_argument("--sites", help="comma-separated site names")
323
+ parser.add_argument("--session", required=True, help="quality-gate session id")
324
+ parser.add_argument("--runs", type=int, default=2)
325
+ parser.add_argument("--live-window", choices=sorted(LIVE_WINDOWS), default="7d")
326
+ return parser.parse_args(argv)
327
+
328
+
329
+ def selected_sites(ns: argparse.Namespace) -> list[str]:
330
+ """Registry order for ``--all``; ValueError names an unknown site."""
331
+ if ns.all:
332
+ return list(SITES)
333
+ names = [n.strip() for n in str(ns.sites).split(",") if n.strip()]
334
+ unknown = [n for n in names if n not in SITES]
335
+ if unknown or not names:
336
+ raise ValueError(f"unknown site(s): {', '.join(unknown) or '(none given)'}")
337
+ return names
338
+
339
+
340
+ def output_dir(session: str) -> Path:
341
+ """``~/.arkaos/quality-gate/<session>/replay-final`` (HOME read at call time)."""
342
+ if not SESSION_RE.fullmatch(session) or ".." in session:
343
+ raise ValueError(f"invalid session id: {session!r}")
344
+ return Path.home() / ".arkaos" / "quality-gate" / session / OUT_DIRNAME
345
+
346
+
347
+ def _validate(ns: argparse.Namespace) -> tuple[list[str], dict[str, list[ReplayCase]], Path]:
348
+ if ns.runs < 1:
349
+ raise ValueError("--runs must be >= 1")
350
+ names = selected_sites(ns)
351
+ out_dir = output_dir(ns.session)
352
+ # Every corpus is loaded before the first call: a bad one costs nothing.
353
+ return names, {n: load_corpus(n) for n in names}, out_dir
354
+
355
+
356
+ def _context(ns: argparse.Namespace, out_dir: Path) -> RunContext | None:
357
+ operator_cfg = load_decisions_config()
358
+ transport = resolve_transport(operator_cfg)
359
+ if transport is None:
360
+ return None
361
+ period = LIVE_WINDOWS[ns.live_window]
362
+ return RunContext(transport, no_cache_config(operator_cfg), operator_cfg, ns.runs,
363
+ out_dir, summarise(period).by_site, period)
364
+
365
+
366
+ def _meta(ns: argparse.Namespace, results: Sequence[SiteResult]) -> dict[str, Any]:
367
+ measured = all(r.gate != "not-measured" for res in results for r in res.runs)
368
+ return {"session": ns.session, "generated_at": _now_iso(), "runs": ns.runs,
369
+ "live_window": ns.live_window, "all_measured": measured,
370
+ "cost_usd": round(sum(r.cost_usd for r in results), 10),
371
+ "cost_category": REPLAY_COST_CATEGORY,
372
+ "rule": {"min_reached_share": MIN_REACHED_SHARE,
373
+ "max_unavailable_share": MAX_UNAVAILABLE_SHARE}}
374
+
375
+
376
+ def _prepare(argv: Sequence[str] | None) -> tuple[RunContext, dict[str, list[ReplayCase]],
377
+ argparse.Namespace] | int:
378
+ try:
379
+ ns = _parse_args(argv)
380
+ except SystemExit as exc:
381
+ return 0 if exc.code == 0 else 2
382
+ if bypassed():
383
+ print("error: ARKA_BYPASS_DECISIONS=1 — no replay calls", file=sys.stderr)
384
+ return 2
385
+ try:
386
+ names, corpora, out_dir = _validate(ns)
387
+ except (OSError, ValueError) as exc:
388
+ print(f"error: {exc}", file=sys.stderr)
389
+ return 2
390
+ ctx = _context(ns, out_dir)
391
+ if ctx is None:
392
+ print("error: no transport (set OPENROUTER_API_KEY)", file=sys.stderr)
393
+ return 2
394
+ return ctx, {n: corpora[n] for n in names}, ns
395
+
396
+
397
+ def main(argv: Sequence[str] | None = None) -> int:
398
+ """CLI: 0 every run measured, 1 some run not measured, 2 usage / no transport."""
399
+ prepared = _prepare(argv)
400
+ if isinstance(prepared, int):
401
+ return prepared
402
+ ctx, corpora, ns = prepared
403
+ ctx.out_dir.mkdir(parents=True, exist_ok=True)
404
+ results = [run_site(site, cases, ctx) for site, cases in corpora.items()]
405
+ meta = _meta(ns, results)
406
+ write_outputs(results, meta, ctx.out_dir)
407
+ print(render_report(results, meta))
408
+ print(f"written: {ctx.out_dir}", file=sys.stderr)
409
+ return 0 if meta["all_measured"] else 1
410
+
411
+
412
+ if __name__ == "__main__":
413
+ raise SystemExit(main())
@@ -0,0 +1,147 @@
1
+ """Shadow mode: Jev is asked off the hot path, the heuristic acts.
2
+
3
+ :func:`spawn_shadow` spools the call (0600, capped at
4
+ :data:`SPOOL_MAX_PENDING`) and detaches a worker — same pattern as
5
+ ``core/hooks/stop.py`` (``Popen(start_new_session=True)``), so the hook
6
+ pays zero network latency. The worker (:func:`main`) asks Jev with
7
+ a 5 s ceiling, records telemetry with ``agree``, deletes its spool in
8
+ ``finally`` and sweeps spools older than an hour.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import uuid
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+ from core.decisions.backoff import ensure_private_dir
24
+ from core.decisions.models import State
25
+ from core.decisions.paths import repo_root, spool_dir
26
+ from core.decisions.site import SiteCall
27
+
28
+ SPOOL_MAX_PENDING = 8
29
+ STALE_SPOOL_S = 3600.0
30
+ SHADOW_TIMEOUT_S = 5.0
31
+
32
+
33
+ def spawn_shadow(calls: Sequence[SiteCall], state: State, session_id: str) -> bool:
34
+ """Spool and detach; False when the spool is full or anything fails."""
35
+ if not calls:
36
+ return False
37
+ try:
38
+ spool = spool_dir()
39
+ # The spool holds the state in clear for seconds: every directory
40
+ # created on the way is 0700, the spool itself always (QG r1 m3).
41
+ ensure_private_dir(spool)
42
+ os.chmod(spool, 0o700)
43
+ # Workers sweep on exit, but a killed worker leaves its spool
44
+ # behind: without this, 8 orphans disable shadow mode for good.
45
+ sweep_stale()
46
+ if len(list(spool.glob("*.json"))) >= SPOOL_MAX_PENDING:
47
+ return False
48
+ path = _write_spool(spool, calls, state, session_id)
49
+ except (OSError, TypeError, ValueError):
50
+ return False
51
+ return _launch(path)
52
+
53
+
54
+ def _write_spool(spool: Path, calls: Sequence[SiteCall], state: State, session_id: str) -> Path:
55
+ payload = {
56
+ "session_id": session_id,
57
+ "state": state,
58
+ "sites": [{"name": c.site.name, "heuristic": c.heuristic} for c in calls],
59
+ "created": time.time(),
60
+ }
61
+ data = json.dumps(payload, ensure_ascii=False, default=str)
62
+ path = spool / f"{uuid.uuid4().hex}.json"
63
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
64
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
65
+ fh.write(data)
66
+ return path
67
+
68
+
69
+ def _launch(path: Path) -> bool:
70
+ try:
71
+ subprocess.Popen(
72
+ [sys.executable, "-m", "core.decisions.shadow", str(path)],
73
+ stdin=subprocess.DEVNULL,
74
+ stdout=subprocess.DEVNULL,
75
+ stderr=subprocess.DEVNULL,
76
+ env={**os.environ, "PYTHONPATH": str(repo_root())},
77
+ start_new_session=True,
78
+ )
79
+ except (OSError, ValueError):
80
+ with contextlib.suppress(OSError):
81
+ path.unlink()
82
+ return False
83
+ return True
84
+
85
+
86
+ def _in_spool(path: Path) -> bool:
87
+ try:
88
+ return path.resolve().parent == spool_dir().resolve()
89
+ except OSError:
90
+ return False
91
+
92
+
93
+ def _run_spool(path: Path) -> None:
94
+ from core.decisions.config import load_decisions_config
95
+ from core.decisions.engine import run_and_record
96
+ from core.decisions.registry import SITES
97
+ from core.decisions.transport import configured_model, resolve_transport
98
+
99
+ payload = json.loads(path.read_text(encoding="utf-8"))
100
+ calls = [
101
+ SiteCall(SITES[s["name"]], s.get("heuristic"))
102
+ for s in payload.get("sites", []) if s.get("name") in SITES
103
+ ]
104
+ cfg = load_decisions_config()
105
+ transport = resolve_transport(cfg, model=configured_model())
106
+ if not calls or transport is None:
107
+ return
108
+ run_and_record(
109
+ calls, payload.get("state", ""), modes={c.site.name: "shadow" for c in calls},
110
+ cfg=cfg, transport=transport, session_id=str(payload.get("session_id", "")),
111
+ timeout_s=SHADOW_TIMEOUT_S,
112
+ )
113
+
114
+
115
+ def sweep_stale(now: float | None = None) -> int:
116
+ """Delete spools older than :data:`STALE_SPOOL_S`; count removed."""
117
+ ref = time.time() if now is None else now
118
+ removed = 0
119
+ for spool in spool_dir().glob("*.json"):
120
+ with contextlib.suppress(OSError):
121
+ if ref - spool.stat().st_mtime > STALE_SPOOL_S:
122
+ spool.unlink()
123
+ removed += 1
124
+ return removed
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ """Worker entry: ``python -m core.decisions.shadow <spool.json>``."""
129
+ args = argv if argv is not None else sys.argv[1:]
130
+ if len(args) != 1:
131
+ return 2
132
+ path = Path(args[0])
133
+ if not _in_spool(path):
134
+ return 2
135
+ try:
136
+ _run_spool(path)
137
+ except Exception:
138
+ pass # detached worker: nothing to report to
139
+ finally:
140
+ with contextlib.suppress(OSError):
141
+ path.unlink()
142
+ sweep_stale()
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())