arkaos 4.13.2 → 4.14.1

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 (151) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +16 -0
  3. package/bin/arka-doctor +20 -1
  4. package/config/cognition/schedules.yaml +16 -0
  5. package/config/hooks/_lib/fastpath/engine.cjs +337 -0
  6. package/config/hooks/gate-manifest.json +741 -0
  7. package/config/hooks/post-tool-use.cjs +113 -0
  8. package/config/hooks/pre-tool-use.cjs +109 -0
  9. package/config/hooks/session-end.ps1 +40 -0
  10. package/config/hooks/session-end.sh +55 -0
  11. package/config/hooks/session-start.sh +25 -189
  12. package/config/hooks/subagent-stop.ps1 +40 -0
  13. package/config/hooks/subagent-stop.sh +56 -0
  14. package/config/mcp-policy.yaml +8 -0
  15. package/config/skills-curated.yaml +39 -0
  16. package/config/statusline.sh +57 -1
  17. package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
  18. package/core/cognition/capture/store.py +7 -2
  19. package/core/evals/__pycache__/__init__.cpython-314.pyc +0 -0
  20. package/core/evals/__pycache__/sanitizer.cpython-314.pyc +0 -0
  21. package/core/evals/__pycache__/schema.cpython-314.pyc +0 -0
  22. package/core/evals/__pycache__/verdict_labels.cpython-314.pyc +0 -0
  23. package/core/forge/__pycache__/complexity.cpython-314.pyc +0 -0
  24. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/judge.cpython-314.pyc +0 -0
  26. package/core/governance/__pycache__/leak_scanner.cpython-314.pyc +0 -0
  27. package/core/governance/__pycache__/qg_verdict.cpython-314.pyc +0 -0
  28. package/core/governance/__pycache__/routing_feedback.cpython-313.pyc +0 -0
  29. package/core/governance/__pycache__/routing_feedback.cpython-314.pyc +0 -0
  30. package/core/governance/__pycache__/routing_feedback_cli.cpython-313.pyc +0 -0
  31. package/core/governance/evidence_checks.py +42 -7
  32. package/core/governance/routing_feedback.py +229 -0
  33. package/core/governance/routing_feedback_cli.py +49 -0
  34. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  35. package/core/hooks/__pycache__/gate_manifest.cpython-313.pyc +0 -0
  36. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  37. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  38. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  39. package/core/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  40. package/core/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  41. package/core/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  42. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  43. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  44. package/core/hooks/__pycache__/subagent_stop.cpython-313.pyc +0 -0
  45. package/core/hooks/__pycache__/subagent_stop.cpython-314.pyc +0 -0
  46. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  47. package/core/hooks/__pycache__/user_prompt_submit.cpython-314.pyc +0 -0
  48. package/core/hooks/gate_manifest.py +366 -0
  49. package/core/hooks/post_tool_use.py +5 -2
  50. package/core/hooks/session_end.py +127 -0
  51. package/core/hooks/session_start.py +322 -0
  52. package/core/hooks/stop.py +69 -0
  53. package/core/hooks/subagent_stop.py +186 -0
  54. package/core/hooks/user_prompt_submit.py +10 -6
  55. package/core/knowledge/__pycache__/embedding_backends.cpython-313.pyc +0 -0
  56. package/core/knowledge/__pycache__/embedding_backends.cpython-314.pyc +0 -0
  57. package/core/knowledge/__pycache__/pattern_cards.cpython-313.pyc +0 -0
  58. package/core/knowledge/__pycache__/pattern_cards.cpython-314.pyc +0 -0
  59. package/core/knowledge/__pycache__/recipes.cpython-314.pyc +0 -0
  60. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  61. package/core/knowledge/__pycache__/vector_store.cpython-314.pyc +0 -0
  62. package/core/knowledge/embedding_backends.py +278 -0
  63. package/core/knowledge/pattern_cards.py +49 -1
  64. package/core/knowledge/vector_store.py +14 -2
  65. package/core/memory/__pycache__/semantic_store.cpython-312.pyc +0 -0
  66. package/core/memory/__pycache__/semantic_store.cpython-313.pyc +0 -0
  67. package/core/memory/__pycache__/semantic_store.cpython-314.pyc +0 -0
  68. package/core/memory/__pycache__/turn_capture.cpython-313.pyc +0 -0
  69. package/core/memory/__pycache__/turn_capture.cpython-314.pyc +0 -0
  70. package/core/memory/semantic_store.py +301 -0
  71. package/core/memory/turn_capture.py +277 -0
  72. package/core/registry/__pycache__/generator.cpython-312.pyc +0 -0
  73. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  74. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  75. package/core/runtime/__pycache__/cost_governor.cpython-314.pyc +0 -0
  76. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  77. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/mcp_telemetry.cpython-312.pyc +0 -0
  79. package/core/shared/__pycache__/decay.cpython-313.pyc +0 -0
  80. package/core/shared/__pycache__/decay.cpython-314.pyc +0 -0
  81. package/core/shared/__pycache__/sqlite_recovery.cpython-312.pyc +0 -0
  82. package/core/shared/__pycache__/sqlite_recovery.cpython-313.pyc +0 -0
  83. package/core/shared/__pycache__/sqlite_recovery.cpython-314.pyc +0 -0
  84. package/core/shared/decay.py +94 -0
  85. package/core/shared/sqlite_recovery.py +266 -0
  86. package/core/synapse/__pycache__/agent_experiences_layer.cpython-313.pyc +0 -0
  87. package/core/synapse/__pycache__/agent_experiences_layer.cpython-314.pyc +0 -0
  88. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  89. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  90. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  91. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  92. package/core/synapse/__pycache__/pattern_library_layer.cpython-314.pyc +0 -0
  93. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  94. package/core/synapse/__pycache__/recipe_layer.cpython-314.pyc +0 -0
  95. package/core/synapse/__pycache__/routing_feedback_layer.cpython-313.pyc +0 -0
  96. package/core/synapse/__pycache__/routing_feedback_layer.cpython-314.pyc +0 -0
  97. package/core/synapse/__pycache__/session_memory_layer.cpython-312.pyc +0 -0
  98. package/core/synapse/__pycache__/session_memory_layer.cpython-313.pyc +0 -0
  99. package/core/synapse/__pycache__/session_memory_layer.cpython-314.pyc +0 -0
  100. package/core/synapse/agent_experiences_layer.py +42 -3
  101. package/core/synapse/engine.py +13 -0
  102. package/core/synapse/recipe_layer.py +27 -4
  103. package/core/synapse/routing_feedback_layer.py +132 -0
  104. package/core/synapse/session_memory_layer.py +152 -0
  105. package/core/workflow/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/core/workflow/__pycache__/design_authorization.cpython-314.pyc +0 -0
  107. package/core/workflow/__pycache__/frontend_gate.cpython-314.pyc +0 -0
  108. package/core/workflow/__pycache__/specialist_enforcer.cpython-314.pyc +0 -0
  109. package/installer/adapters/claude-code.js +37 -0
  110. package/installer/claude-plugins.js +6 -0
  111. package/installer/cli.js +23 -2
  112. package/installer/config-seed.js +25 -16
  113. package/installer/doctor.js +116 -1
  114. package/installer/hook-lib.js +28 -0
  115. package/installer/index.js +48 -153
  116. package/installer/mcp-runner.js +108 -0
  117. package/installer/skill-deploy.js +189 -0
  118. package/installer/skills-mode.js +64 -0
  119. package/installer/update.js +55 -79
  120. package/knowledge/commands-registry.json.bak +171 -4
  121. package/knowledge/skills-manifest.json +2337 -0
  122. package/mcps/arka-prompts/commands.py +451 -0
  123. package/mcps/arka-prompts/pyproject.toml +15 -0
  124. package/mcps/arka-prompts/server.py +156 -0
  125. package/mcps/arka-tools/__pycache__/server.cpython-313.pyc +0 -0
  126. package/mcps/arka-tools/pyproject.toml +10 -0
  127. package/mcps/arka-tools/server.py +319 -0
  128. package/mcps/profiles/base.json +19 -0
  129. package/mcps/profiles/brand.json +19 -0
  130. package/mcps/profiles/comms.json +13 -0
  131. package/mcps/profiles/content.json +20 -0
  132. package/mcps/profiles/ecommerce.json +13 -0
  133. package/mcps/profiles/full-stack.json +15 -0
  134. package/mcps/profiles/laravel.json +12 -0
  135. package/mcps/profiles/nextjs.json +13 -0
  136. package/mcps/profiles/nuxt.json +13 -0
  137. package/mcps/profiles/react.json +12 -0
  138. package/mcps/profiles/vue.json +12 -0
  139. package/mcps/registry.json +423 -0
  140. package/mcps/scripts/apply-mcps.sh +282 -0
  141. package/mcps/stacks/laravel-packages.json +49 -0
  142. package/mcps/stacks/react-packages.json +58 -0
  143. package/package.json +2 -1
  144. package/pyproject.toml +9 -1
  145. package/scripts/__pycache__/marketplace_gen.cpython-313.pyc +0 -0
  146. package/scripts/__pycache__/synapse-bridge.cpython-314.pyc +0 -0
  147. package/scripts/marketplace_gen.py +310 -0
  148. package/scripts/tools/__pycache__/docs_stats.cpython-313.pyc +0 -0
  149. package/scripts/tools/__pycache__/skill_budget.cpython-313.pyc +0 -0
  150. package/scripts/tools/docs_stats.py +6 -4
  151. package/scripts/tools/skill_budget.py +209 -0
@@ -14,6 +14,7 @@ policies:
14
14
  active:
15
15
  - codebase-memory
16
16
  - arka-prompts
17
+ - arka-tools
17
18
  - obsidian
18
19
  - context7
19
20
  - gh-grep
@@ -46,6 +47,7 @@ policies:
46
47
  active:
47
48
  - codebase-memory
48
49
  - arka-prompts
50
+ - arka-tools
49
51
  - obsidian
50
52
  - context7
51
53
  - gh-grep
@@ -78,6 +80,7 @@ policies:
78
80
  active:
79
81
  - codebase-memory
80
82
  - arka-prompts
83
+ - arka-tools
81
84
  - obsidian
82
85
  - context7
83
86
  - gh-grep
@@ -109,6 +112,7 @@ policies:
109
112
  stack_includes: [shopify]
110
113
  active:
111
114
  - arka-prompts
115
+ - arka-tools
112
116
  - obsidian
113
117
  - context7
114
118
  - gh-grep
@@ -142,6 +146,7 @@ policies:
142
146
  active:
143
147
  - codebase-memory
144
148
  - arka-prompts
149
+ - arka-tools
145
150
  - obsidian
146
151
  - context7
147
152
  - gh-grep
@@ -173,6 +178,7 @@ policies:
173
178
  ecosystem: marketing
174
179
  active:
175
180
  - arka-prompts
181
+ - arka-tools
176
182
  - obsidian
177
183
  - context7
178
184
  - canva
@@ -205,6 +211,7 @@ policies:
205
211
  ecosystem: content
206
212
  active:
207
213
  - arka-prompts
214
+ - arka-tools
208
215
  - obsidian
209
216
  - context7
210
217
  - canva
@@ -237,6 +244,7 @@ policies:
237
244
  default: true
238
245
  active:
239
246
  - arka-prompts
247
+ - arka-tools
240
248
  - obsidian
241
249
  - context7
242
250
  - gh-grep
@@ -0,0 +1,39 @@
1
+ # Curated default skill set — the single source of truth for the F2-7
2
+ # distribution cut (marketplace plugins now, curated installer default in
3
+ # F2-7c). Everything downstream is GENERATED from this file plus the
4
+ # departments/ tree by scripts/marketplace_gen.py — never hand-typed
5
+ # (docs_stats/gate_manifest precedent).
6
+ #
7
+ # The cut: the default install carries the structural surface (main
8
+ # /arka, the 17 department hubs, the 14 meta skills — 32 in total) plus
9
+ # the curated sub-skills below (37) = 69 skills, inside the 60-80 band.
10
+ # Every OTHER sub-skill still ships in the repo and deploys via the
11
+ # per-department marketplace plugins (arkaos-<dept>@arkaos), where the
12
+ # plugin namespace also disambiguates the later-wins collision slugs —
13
+ # which are BANNED from this list (enforced by test, computed from disk).
14
+ #
15
+ # Operator-tunable: edit, then run `python scripts/marketplace_gen.py`
16
+ # and commit the regenerated tree. The drift gate fails CI otherwise.
17
+ version: 1
18
+ structural:
19
+ arka: true # main /arka skill + nested reference bundle
20
+ dept_hubs: all # departments/<dept>/SKILL.md (17)
21
+ meta: all # arka/skills/* (14 — includes flow: NON-NEGOTIABLE)
22
+ curated_subskills:
23
+ brand: [design-review, design-system]
24
+ community: [moderation]
25
+ content: [calendar, hook-write]
26
+ dev: [api-design, architecture-design, clean-code-review, code-review,
27
+ db-design, refactor-plan, runbook, security-audit, spec, tdd-cycle]
28
+ ecom: [cart-recovery, store-audit]
29
+ finance: [financial-model, unit-economics]
30
+ kb: [search-kb, zettelkasten-process]
31
+ landing: [funnel-design, landing-gen]
32
+ leadership: [feedback-give]
33
+ marketing: [growth-loop, seo-audit]
34
+ ops: [sop-create, workflow-automate]
35
+ org: [org-design]
36
+ pm: [roadmap-build, sprint-plan]
37
+ saas: [mvp-build, plg-setup]
38
+ sales: [discovery-call, objection-handle]
39
+ strategy: [five-forces, moat-analysis]
@@ -117,6 +117,61 @@ fi
117
117
  # ─── Format cost ──────────────────────────────────────────────────────────
118
118
  COST_FMT=$(printf '$%.2f' "${COST:-0}")
119
119
 
120
+ # ─── Workflow gate + budget (F2-5, statusline v3) ─────────────────────────
121
+ # Read ~/.arkaos/workflow-state.json directly (plain JSON, one jq call) —
122
+ # no Python spawn on this hot path (the statusline re-renders continuously).
123
+ # Shows the active workflow, current gate as G<n>/<total>, and violations.
124
+ WF_SEGMENT=""
125
+ WF_STATE="$HOME/.arkaos/workflow-state.json"
126
+ if [ -f "$WF_STATE" ]; then
127
+ WF_LINE=$(jq -r '
128
+ (.phases // {}) as $p
129
+ | ($p | length) as $total
130
+ # No workflow name, or a phase-less (degenerate) state -> no segment
131
+ # (avoids a meaningless "G0/0").
132
+ | if (.workflow // "") == "" or $total == 0 then empty
133
+ else
134
+ ([$p[] | select(.status == "completed")] | length) as $done
135
+ # Gate index = position of the in_progress phase (1-based), else
136
+ # done+1 — DERIVED FROM POSITION, never grepped from the name
137
+ # (a "gate-5-x" key in a 3-phase workflow must not render G5/3).
138
+ # Clamped to $total so a fully-completed workflow shows G<n>/<n>,
139
+ # not G<n+1>/<n>.
140
+ | ([$p | to_entries | to_entries[]
141
+ | select(.value.value.status == "in_progress") | .key + 1][0]
142
+ // ([$done + 1, $total] | min)) as $gate
143
+ | (.violations // [] | length) as $v
144
+ # Unit-separator (), NOT tab: an empty field between tabs is
145
+ # IFS-whitespace and collapses under `read`, shifting every later
146
+ # field and SILENTLY DROPPING the violations warning (QG B1). The
147
+ # workflow name is stripped of control bytes so a malformed state
148
+ # file cannot inject ANSI or a newline into the terminal (B4/M1).
149
+ | ((.workflow | gsub("[[:cntrl:]\\\\]"; "")) as $wf
150
+ | [$wf, ($gate|tostring), ($total|tostring), ($v|tostring)] | join("\u001f"))
151
+ end
152
+ ' "$WF_STATE" 2>/dev/null)
153
+ if [ -n "$WF_LINE" ]; then
154
+ IFS=$'\x1f' read -r WF_NAME WF_GATE_NUM WF_TOTAL WF_VIOL <<< "$WF_LINE"
155
+ WF_SEGMENT=" ${C_DIM}|${C_RESET} ${C_CYAN}⚑ ${WF_NAME}${C_RESET} ${C_DIM}G${WF_GATE_NUM}/${WF_TOTAL}${C_RESET}"
156
+ if [ "${WF_VIOL:-0}" -gt 0 ] 2>/dev/null; then
157
+ WF_SEGMENT+=" ${C_RED}⚠${WF_VIOL}${C_RESET}"
158
+ fi
159
+ fi
160
+ fi
161
+
162
+ # Budget: show spend against the per-session cap when one is configured
163
+ # (~/.arkaos/config.json budget.hardCapUsd). Read-only, one jq call.
164
+ # `numbers` coerces the type: a non-numeric cap ("lots") yields empty
165
+ # and the segment is omitted rather than rendering a misleading /$0.00.
166
+ BUDGET_FMT=""
167
+ ARKA_CONFIG="$HOME/.arkaos/config.json"
168
+ if [ -f "$ARKA_CONFIG" ]; then
169
+ CAP=$(jq -r '.budget.hardCapUsd | numbers // empty' "$ARKA_CONFIG" 2>/dev/null)
170
+ if [ -n "$CAP" ]; then
171
+ BUDGET_FMT=$(awk -v cap="$CAP" 'BEGIN{printf "/$%.2f", cap}')
172
+ fi
173
+ fi
174
+
120
175
  # ─── Build Line 1: Context bar ───────────────────────────────────────────
121
176
  LINE1="${C_CYAN}▲ARKA${C_RESET} ${C_WHITE}${DIR_NAME}${C_RESET}"
122
177
 
@@ -126,13 +181,14 @@ if [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; the
126
181
  fi
127
182
 
128
183
  LINE1+=" ${C_DIM}|${C_RESET} ${MODEL}"
184
+ LINE1+="$WF_SEGMENT"
129
185
 
130
186
  # ─── Build Line 2: Metrics bar ───────────────────────────────────────────
131
187
  LINE2="${C_BAR}${BAR} ${PCT}%${C_RESET}"
132
188
  LINE2+=" ${C_DIM}|${C_RESET} ${IN_FMT} in ${OUT_FMT} out"
133
189
  LINE2+=" ${C_DIM}|${C_RESET} ${C_GREEN}+${ADDED}${C_RESET} ${C_RED}-${REMOVED}${C_RESET}"
134
190
  LINE2+=" ${C_DIM}|${C_RESET} ${TIME_FMT}"
135
- LINE2+=" ${C_DIM}|${C_RESET} ${COST_FMT}"
191
+ LINE2+=" ${C_DIM}|${C_RESET} ${COST_FMT}${BUDGET_FMT}"
136
192
 
137
193
  # ─── Output ───────────────────────────────────────────────────────────────
138
194
  echo -e "$LINE1"
@@ -16,10 +16,15 @@ class CaptureStore:
16
16
  """SQLite-backed store for raw session captures."""
17
17
 
18
18
  def __init__(self, db_path: str) -> None:
19
- """Connect to SQLite database and initialize tables."""
19
+ """Connect to SQLite database and initialize tables.
20
+
21
+ A corrupt file triggers ONE self-heal attempt (F1-D1) before
22
+ the error propagates — the original is never destroyed.
23
+ """
20
24
  Path(db_path).parent.mkdir(parents=True, exist_ok=True)
21
25
  self._db_path = db_path
22
- self._init_db()
26
+ from core.shared.sqlite_recovery import open_with_recovery
27
+ open_with_recovery(db_path, self._init_db)
23
28
 
24
29
  def _conn(self) -> sqlite3.Connection:
25
30
  conn = sqlite3.connect(self._db_path)
@@ -23,11 +23,13 @@ CLI (for hooks/skills)::
23
23
  from __future__ import annotations
24
24
 
25
25
  import argparse
26
+ import importlib.util
26
27
  import json
27
28
  import re
28
29
  import shlex
29
30
  import shutil
30
31
  import subprocess
32
+ import sys
31
33
  import time
32
34
  from dataclasses import asdict, dataclass, field
33
35
  from pathlib import Path
@@ -216,6 +218,25 @@ def _labelled(result: CheckResult, label: str) -> CheckResult:
216
218
  return result
217
219
 
218
220
 
221
+ def _tool_cmd(tool: str) -> list[str] | None:
222
+ """Resolve a Python tool: PATH binary, else the interpreter's module.
223
+
224
+ Operator machines install ruff/pytest into the ArkaOS venv with no
225
+ PATH binary — keying on shutil.which alone silently downgraded
226
+ Python lint to eslint over installer/*.js, a FALSE GREEN on a
227
+ NON-NEGOTIABLE gate (QG findings, F1-B2/F1-C1 reviews).
228
+ """
229
+ if shutil.which(tool):
230
+ return [tool]
231
+ if importlib.util.find_spec(tool) is not None:
232
+ return [sys.executable, "-m", tool]
233
+ return None
234
+
235
+
236
+ def _ruff_cmd() -> list[str] | None:
237
+ return _tool_cmd("ruff")
238
+
239
+
219
240
  def _lint_scoped(
220
241
  project_dir: Path, changed: list[str], timeout: int,
221
242
  ) -> CheckResult | None:
@@ -225,10 +246,11 @@ def _lint_scoped(
225
246
  the same principle _check_security_grep already applies to its diff
226
247
  scope. Returns None when no scoped run applies (caller falls back).
227
248
  """
228
- if shutil.which("ruff"):
249
+ ruff = _ruff_cmd()
250
+ if ruff:
229
251
  files = _scoped_files(project_dir, changed, _LINTABLE_PY)
230
252
  if files:
231
- result = _run("lint", ["ruff", "check", *files], project_dir, timeout)
253
+ result = _run("lint", [*ruff, "check", *files], project_dir, timeout)
232
254
  return _labelled(result, f"lint(scoped: {len(files)} file(s))")
233
255
  eslint = project_dir / "node_modules" / ".bin" / "eslint"
234
256
  if eslint.is_file():
@@ -261,10 +283,22 @@ def _check_lint(
261
283
  or _scoped_files(project_dir, changed, _LINTABLE_PHP)
262
284
  )
263
285
  if not lintable:
264
- return _skip("lint", "changed files contain no lintable sources")
265
- if _has_python(project_dir, changed) and shutil.which("ruff"):
286
+ # Skipping is only honest when the DIFF has no lintable
287
+ # extensions. When it does but none resolved under
288
+ # project_dir (deleted files, a different checkout/cwd,
289
+ # cross-root paths), "no lintable sources" is a blind gate
290
+ # (QG 2026-07-12: a 2 .py + 6 .js diff got skipped) — fall
291
+ # through to the project-wide lint of what IS on disk.
292
+ all_lintable_exts = _LINTABLE_PY | _LINTABLE_JS | _LINTABLE_PHP
293
+ changed_exts = {s.lower() for s in _suffixes(changed)}
294
+ if not (changed_exts & all_lintable_exts):
295
+ return _skip(
296
+ "lint", "changed files contain no lintable sources"
297
+ )
298
+ ruff = _ruff_cmd()
299
+ if _has_python(project_dir, changed) and ruff:
266
300
  return _labelled(
267
- _run("lint", ["ruff", "check", "."], project_dir, timeout),
301
+ _run("lint", [*ruff, "check", "."], project_dir, timeout),
268
302
  "lint(project-wide)",
269
303
  )
270
304
  if _package_json_script(project_dir, "lint"):
@@ -307,8 +341,9 @@ def _check_tests(
307
341
  ) -> CheckResult:
308
342
  if test_command:
309
343
  return _run("tests", shlex.split(test_command), project_dir, timeout)
310
- if _has_python(project_dir, changed) and shutil.which("pytest"):
311
- return _run("tests", ["pytest", "-q"], project_dir, timeout)
344
+ pytest_cmd = _tool_cmd("pytest")
345
+ if _has_python(project_dir, changed) and pytest_cmd:
346
+ return _run("tests", [*pytest_cmd, "-q"], project_dir, timeout)
312
347
  if _package_json_script(project_dir, "test"):
313
348
  return _run(
314
349
  "tests", ["npm", "test", "--silent"], project_dir, timeout,
@@ -0,0 +1,229 @@
1
+ """Routing feedback aggregator (F1-B1 — memory/learning reform).
2
+
3
+ Closes the telemetry loop the ruflo teardown flagged as ArkaOS's G1 gap:
4
+ QG verdicts and gate-judge verdicts are written every review but nothing
5
+ ever read them back. This module aggregates both corpora into
6
+ ``~/.arkaos/routing-scores.json`` — per-department approval scores that
7
+ the Synapse L5.5 layer (F1-B2) injects as ``[arka:redo-risk]`` warnings.
8
+
9
+ Scored table, deliberately NOT Q-learning: sample sizes are dozens, not
10
+ thousands (Q would never converge), and the constitution's evidence-flow
11
+ demands citable counts — ``approvals=3/9 (30d)`` is auditable, a Q-table
12
+ weight is not. Recency weighting gives the same "reward changes future
13
+ behaviour" property with zero exploration risk.
14
+
15
+ Sources consumed (both have ``department`` on every record):
16
+ - qg-verdicts.jsonl (QGVerdict: APPROVED/REJECTED + blockers)
17
+ - judge-verdicts.jsonl (JudgeVerdict: PASS/REVISE)
18
+ specialist-dispatch.jsonl is deliberately NOT consumed in v1: its records
19
+ carry tool/file but no department — mapping would require fragile
20
+ file-to-department inference, and a wrong attribution is worse than none.
21
+
22
+ CLI: ``python3 -m core.governance.routing_feedback_cli rebuild|show``
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import math
29
+ import os
30
+ import tempfile
31
+ from collections import Counter
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ from pydantic import BaseModel, Field
37
+
38
+ SCORES_ENV = "ARKA_ROUTING_SCORES_PATH"
39
+ _WINDOW_DAYS = 90 # records older than this are ignored entirely
40
+ _HALF_LIFE_DAYS = 30.0 # recency weight halves every 30 days
41
+ _REDO_WINDOW_DAYS = 7 # >=2 REJECTED same deliverable+department within 7d
42
+ _TOP_BLOCKERS = 3
43
+
44
+
45
+ class RoutingScore(BaseModel):
46
+ """Per-department evidence the router can cite verbatim."""
47
+
48
+ department: str
49
+ approvals: int = 0 # raw QG counts inside the window (citable)
50
+ rejections: int = 0
51
+ smoothed_approval: float = Field(
52
+ default=0.5,
53
+ description="Laplace (a+1)/(a+r+2) over recency-weighted QG counts",
54
+ )
55
+ judge_passes: int = 0 # gate-judge signal, kept separate from QG
56
+ judge_revises: int = 0
57
+ redo_count: int = 0
58
+ top_blocker_patterns: list[str] = Field(default_factory=list)
59
+ samples: int = 0 # QG verdicts backing smoothed_approval
60
+
61
+
62
+ class RoutingScores(BaseModel):
63
+ version: int = 1
64
+ computed_at: str = ""
65
+ window_days: int = _WINDOW_DAYS
66
+ sources: list[str] = Field(default_factory=list)
67
+ scores: list[RoutingScore] = Field(default_factory=list)
68
+
69
+
70
+ def scores_path() -> Path:
71
+ override = os.environ.get(SCORES_ENV, "").strip()
72
+ if override:
73
+ return Path(override)
74
+ return Path.home() / ".arkaos" / "routing-scores.json"
75
+
76
+
77
+ def _parse_ts(ts: str) -> Optional[datetime]:
78
+ """ONE timestamp parser for the whole module — naive stamps are
79
+ normalized to UTC everywhere, so mixed naive+aware corpora can
80
+ never poison a later comparison/sort with a TypeError."""
81
+ try:
82
+ parsed = datetime.fromisoformat(ts)
83
+ except (ValueError, TypeError):
84
+ return None
85
+ if parsed.tzinfo is None:
86
+ parsed = parsed.replace(tzinfo=timezone.utc)
87
+ return parsed
88
+
89
+
90
+ def _age_days(ts: str, now: datetime) -> Optional[float]:
91
+ parsed = _parse_ts(ts)
92
+ if parsed is None:
93
+ return None
94
+ return max(0.0, (now - parsed).total_seconds() / 86400.0)
95
+
96
+
97
+ def _recency_weight(age_days: float) -> float:
98
+ return math.pow(0.5, age_days / _HALF_LIFE_DAYS)
99
+
100
+
101
+ def _windowed(records: list[dict], now: datetime) -> list[tuple[dict, float]]:
102
+ """Pair each in-window record with its recency weight; drop the rest."""
103
+ kept: list[tuple[dict, float]] = []
104
+ for record in records:
105
+ age = _age_days(str(record.get("ts") or ""), now)
106
+ if age is None or age > _WINDOW_DAYS:
107
+ continue
108
+ department = str(record.get("department") or "").strip()
109
+ if not department:
110
+ continue # unattributable — wrong attribution is worse than none
111
+ kept.append((record, _recency_weight(age)))
112
+ return kept
113
+
114
+
115
+ def _count_redos(qg: list[tuple[dict, float]]) -> Counter:
116
+ """>=2 REJECTED for the same deliverable+department within 7 days.
117
+
118
+ Records with an EMPTY deliverable are skipped: the live pipeline
119
+ defaults deliverable to "" (record_cli), and an empty string cannot
120
+ establish "same deliverable" — counting it would flag a department
121
+ for two unrelated rejections (noise sold as signal).
122
+ """
123
+ rejected_ts: dict[tuple[str, str], list[datetime]] = {}
124
+ for record, _w in qg:
125
+ if str(record.get("verdict")).upper() != "REJECTED":
126
+ continue
127
+ deliverable = str(record.get("deliverable") or "").strip()
128
+ if not deliverable:
129
+ continue
130
+ when = _parse_ts(str(record.get("ts")))
131
+ if when is None:
132
+ continue
133
+ key = (str(record.get("department")), deliverable)
134
+ rejected_ts.setdefault(key, []).append(when)
135
+ redos: Counter = Counter()
136
+ for (department, _deliverable), stamps in rejected_ts.items():
137
+ stamps.sort() # all aware-UTC via _parse_ts — sort cannot raise
138
+ for earlier, later in zip(stamps, stamps[1:]):
139
+ if (later - earlier).total_seconds() <= _REDO_WINDOW_DAYS * 86400:
140
+ redos[department] += 1
141
+ return redos
142
+
143
+
144
+ def _blocker_patterns(records: list[dict]) -> list[str]:
145
+ counts: Counter = Counter()
146
+ for record in records:
147
+ for blocker in record.get("blockers") or []:
148
+ check = str((blocker or {}).get("check") or "").strip()
149
+ if check:
150
+ counts[check] += 1
151
+ return [check for check, _n in counts.most_common(_TOP_BLOCKERS)]
152
+
153
+
154
+ def _department_score(
155
+ department: str,
156
+ qg: list[tuple[dict, float]],
157
+ judge: list[tuple[dict, float]],
158
+ redos: Counter,
159
+ ) -> RoutingScore:
160
+ dept_qg = [(r, w) for r, w in qg if r.get("department") == department]
161
+ approvals_w = sum(w for r, w in dept_qg if str(r.get("verdict")).upper() == "APPROVED")
162
+ rejections_w = sum(w for r, w in dept_qg if str(r.get("verdict")).upper() == "REJECTED")
163
+ dept_judge = [(r, w) for r, w in judge if r.get("department") == department]
164
+ rejected_records = [
165
+ r for r, _w in dept_qg if str(r.get("verdict")).upper() == "REJECTED"
166
+ ]
167
+ return RoutingScore(
168
+ department=department,
169
+ approvals=sum(1 for r, _ in dept_qg if str(r.get("verdict")).upper() == "APPROVED"),
170
+ rejections=len(rejected_records),
171
+ smoothed_approval=(approvals_w + 1.0) / (approvals_w + rejections_w + 2.0),
172
+ judge_passes=sum(1 for r, _ in dept_judge if str(r.get("verdict")).upper() == "PASS"),
173
+ judge_revises=sum(1 for r, _ in dept_judge if str(r.get("verdict")).upper() == "REVISE"),
174
+ redo_count=redos.get(department, 0),
175
+ top_blocker_patterns=_blocker_patterns(rejected_records),
176
+ samples=len(dept_qg),
177
+ )
178
+
179
+
180
+ def rebuild(now: datetime | None = None) -> RoutingScores:
181
+ """Aggregate both corpora and atomically write routing-scores.json."""
182
+ from core.evals.verdict_labels import load_judge_labels, load_verdict_labels
183
+
184
+ current = now or datetime.now(timezone.utc)
185
+ qg = _windowed(load_verdict_labels(), current)
186
+ judge = _windowed(load_judge_labels(), current)
187
+ departments = sorted(
188
+ {str(r.get("department")) for r, _ in qg}
189
+ | {str(r.get("department")) for r, _ in judge}
190
+ )
191
+ redos = _count_redos(qg)
192
+ result = RoutingScores(
193
+ computed_at=current.isoformat(),
194
+ sources=["qg-verdicts.jsonl", "judge-verdicts.jsonl"],
195
+ scores=[_department_score(d, qg, judge, redos) for d in departments],
196
+ )
197
+ _atomic_write(scores_path(), result)
198
+ return result
199
+
200
+
201
+ def _atomic_write(target: Path, payload: RoutingScores) -> None:
202
+ target.parent.mkdir(parents=True, exist_ok=True)
203
+ fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f"{target.name}.", suffix=".tmp")
204
+ try:
205
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
206
+ fh.write(payload.model_dump_json(indent=2))
207
+ os.replace(tmp_name, target)
208
+ except OSError:
209
+ Path(tmp_name).unlink(missing_ok=True)
210
+
211
+
212
+ def load_scores() -> Optional[RoutingScores]:
213
+ """Read the scores file; None when absent/invalid (consumer stays inert)."""
214
+ try:
215
+ data: dict[str, Any] = json.loads(scores_path().read_text(encoding="utf-8"))
216
+ if data.get("version") != 1:
217
+ return None
218
+ return RoutingScores(**data)
219
+ except Exception: # noqa: BLE001 — consumers must never break on this file
220
+ return None
221
+
222
+
223
+ def stale(max_age_seconds: int = 3600) -> bool:
224
+ """True when the scores file is missing or older than the threshold."""
225
+ try:
226
+ mtime = scores_path().stat().st_mtime
227
+ except OSError:
228
+ return True
229
+ return (datetime.now(timezone.utc).timestamp() - mtime) > max_age_seconds
@@ -0,0 +1,49 @@
1
+ """CLI for the routing feedback aggregator (F1-B1).
2
+
3
+ Usage:
4
+ python3 -m core.governance.routing_feedback_cli rebuild
5
+ python3 -m core.governance.routing_feedback_cli show
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from core.governance.routing_feedback import load_scores, rebuild, scores_path
13
+
14
+
15
+ def _show() -> int:
16
+ scores = load_scores()
17
+ if scores is None:
18
+ print(f"no routing scores at {scores_path()} — run: rebuild")
19
+ return 1
20
+ print(f"routing scores @ {scores.computed_at} (window {scores.window_days}d)")
21
+ print(f"sources: {', '.join(scores.sources)}")
22
+ for score in scores.scores:
23
+ blockers = ", ".join(score.top_blocker_patterns) or "-"
24
+ print(
25
+ f" {score.department}: {score.approvals}/{score.samples} approved"
26
+ f" (smoothed {score.smoothed_approval:.2f}),"
27
+ f" judge {score.judge_passes}P/{score.judge_revises}R,"
28
+ f" redos {score.redo_count}, top blockers: {blockers}"
29
+ )
30
+ if not scores.scores:
31
+ print(" (no attributable verdicts in window)")
32
+ return 0
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ args = argv if argv is not None else sys.argv[1:]
37
+ command = args[0] if args else "show"
38
+ if command == "rebuild":
39
+ result = rebuild()
40
+ print(f"rebuilt {scores_path()} — {len(result.scores)} department(s)")
41
+ return 0
42
+ if command == "show":
43
+ return _show()
44
+ print("usage: python3 -m core.governance.routing_feedback_cli rebuild|show")
45
+ return 2
46
+
47
+
48
+ if __name__ == "__main__": # pragma: no cover
49
+ raise SystemExit(main())