arkaos 4.0.2 → 4.1.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 (167) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +16 -22
  4. package/arka/skills/flow/SKILL.md +108 -234
  5. package/config/claude-agents/eduardo-copy.md +53 -0
  6. package/config/claude-agents/francisca-tech.md +52 -0
  7. package/config/claude-agents/marta-cqo.md +52 -0
  8. package/config/claude-agents/paulo-tech-lead.md +50 -0
  9. package/config/cognition/schedules.yaml +15 -0
  10. package/config/constitution.yaml +17 -21
  11. package/config/hooks/_lib/workflow-classifier.sh +1 -1
  12. package/config/hooks/post-tool-use.sh +40 -539
  13. package/config/hooks/pre-tool-use.sh +32 -281
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +30 -425
  16. package/config/hooks/user-prompt-submit.sh +42 -459
  17. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  18. package/core/cognition/dreaming.py +5 -0
  19. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  20. package/core/cognition/scheduler/daemon.py +10 -0
  21. package/core/forge/__init__.py +2 -0
  22. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  23. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  28. package/core/forge/orchestrator.py +50 -27
  29. package/core/forge/persistence.py +16 -0
  30. package/core/forge/renderer.py +16 -0
  31. package/core/forge/runtime_dispatcher.py +34 -19
  32. package/core/forge/schema.py +12 -0
  33. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  35. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  36. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  37. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  38. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  40. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  41. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  42. package/core/governance/closing_marker_check.py +9 -5
  43. package/core/governance/compliance_telemetry.py +1 -1
  44. package/core/governance/compliance_telemetry_cli.py +1 -1
  45. package/core/governance/evidence_checks.py +421 -0
  46. package/core/governance/meta_tag_check.py +36 -0
  47. package/core/governance/qg_verdict.py +78 -0
  48. package/core/governance/review_workflow.py +34 -1
  49. package/core/governance/skill_proposer.py +2 -1
  50. package/core/hooks/__init__.py +12 -0
  51. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  52. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  54. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  55. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  56. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  57. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  58. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  59. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  62. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  63. package/core/hooks/_shared.py +110 -0
  64. package/core/hooks/post_tool_use.py +689 -0
  65. package/core/hooks/pre_tool_use.py +267 -0
  66. package/core/hooks/stop.py +395 -0
  67. package/core/hooks/user_prompt_submit.py +600 -0
  68. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  69. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  70. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  71. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  72. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  73. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  74. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  75. package/core/knowledge/embedder.py +118 -11
  76. package/core/knowledge/vector_store.py +111 -14
  77. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  80. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  81. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  82. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  83. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  84. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  85. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  86. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  87. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  88. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  89. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  90. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  91. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  92. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  93. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  94. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  95. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  98. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  99. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  100. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  101. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  102. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  103. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  104. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  105. package/core/runtime/base.py +29 -0
  106. package/core/runtime/capabilities_cli.py +74 -0
  107. package/core/runtime/claude_code.py +8 -0
  108. package/core/runtime/codex_cli.py +205 -37
  109. package/core/runtime/cost_governor.py +184 -0
  110. package/core/runtime/cursor.py +8 -0
  111. package/core/runtime/gemini_cli.py +8 -0
  112. package/core/runtime/llm_provider.py +32 -13
  113. package/core/runtime/llm_retry.py +233 -0
  114. package/core/runtime/native_usage.py +186 -0
  115. package/core/runtime/pricing.py +9 -0
  116. package/core/runtime/registry.py +3 -0
  117. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  119. package/core/shared/test_evidence.py +34 -0
  120. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  121. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  122. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  124. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  125. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  126. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  127. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  128. package/core/synapse/engine.py +7 -0
  129. package/core/synapse/graph_context_layer.py +212 -0
  130. package/core/synapse/kb_cache.py +3 -0
  131. package/core/synapse/layers.py +104 -20
  132. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  133. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  134. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  135. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  136. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  137. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  138. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  139. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  140. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  141. package/core/workflow/enforcer.py +5 -16
  142. package/core/workflow/flow_enforcer.py +36 -11
  143. package/core/workflow/gate_checkpoint.py +149 -0
  144. package/core/workflow/research_gate.py +15 -3
  145. package/core/workflow/rules_registry.py +137 -389
  146. package/core/workflow/specialist_enforcer.py +21 -4
  147. package/dashboard/app/pages/knowledge/index.vue +4 -1
  148. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  149. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  150. package/departments/quality/SKILL.md +45 -5
  151. package/departments/quality/agents/copy-director.yaml +3 -1
  152. package/departments/quality/agents/tech-director.yaml +3 -1
  153. package/installer/adapters/claude-code.js +46 -2
  154. package/installer/config-seed.js +39 -14
  155. package/installer/doctor.js +8 -0
  156. package/installer/graphify.js +153 -0
  157. package/installer/index.js +20 -0
  158. package/installer/init.js +11 -0
  159. package/installer/update.js +19 -0
  160. package/package.json +1 -1
  161. package/pyproject.toml +1 -1
  162. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  163. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  164. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  165. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  166. package/scripts/knowledge-index.py +8 -2
  167. package/scripts/synapse-bridge.py +102 -26
@@ -0,0 +1,267 @@
1
+ """PreToolUse — consolidated entrypoint (PR-6 v4.1.0 hook hygiene).
2
+
3
+ Replaces the 5-8 python3/jq spawns of the old ``pre-tool-use.sh`` heredocs
4
+ with ONE python process. Gate order is preserved exactly:
5
+
6
+ 1. KB-first research gate (core/workflow/research_gate.py)
7
+ 2. Specialist-dispatch gate (core/workflow/specialist_enforcer.py)
8
+ 3. Fast allow for non-flow-gated tools
9
+ 4. CostGovernor budget check (stdlib-only — MUST run even when the
10
+ yaml-needing enforcers degrade; see ADR 2026-07-04-cost-governor)
11
+ 5. Evidence-flow gate (core/workflow/flow_enforcer.py)
12
+
13
+ Behavior contract (unchanged from the bash version):
14
+ - allow → no stdout, exit 0 (nudges/warnings on stderr)
15
+ - deny → stderr message + permissionDecision=deny JSON, exit 2
16
+ - env bypasses (ARKA_BYPASS_FLOW, ARKA_BYPASS_KB_FIRST) and feature
17
+ flags are honored inside the delegated modules
18
+ - every module import is lazy try/except: a PyYAML-less python3
19
+ degrades each gate to allow, identical to the old heredocs
20
+
21
+ The transcript is parsed at most ONCE per invocation and shared between
22
+ the specialist and flow gates via their ``messages=`` params.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from core.hooks._shared import (
31
+ emit_deny_json,
32
+ ensure_root_on_path,
33
+ get_str,
34
+ read_stdin_json,
35
+ resolve_arkaos_root,
36
+ )
37
+
38
+ _FLOW_GATED_TOOLS = frozenset({
39
+ "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Skill", "Bash",
40
+ })
41
+ _SPECIALIST_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
42
+ _ASSISTANT_WINDOW = 20
43
+
44
+
45
+ def _query_hint(tool_input: dict) -> str:
46
+ """Mirror `jq -r '.tool_input.query // .prompt // .url' | head -c 500`."""
47
+ for key in ("query", "prompt", "url"):
48
+ value = tool_input.get(key)
49
+ if value is not None and value is not False:
50
+ return str(value)[:500]
51
+ return ""
52
+
53
+
54
+ class _MessagesOnce:
55
+ """Parse the transcript at most once, lazily, across both gates.
56
+
57
+ ``peek()`` never triggers a read — gates that have their own cheaper
58
+ early-outs (feature flag off, marker-cache hit) pass ``peek()`` so the
59
+ module self-loads only when it really needs the transcript. ``load()``
60
+ is called by the first gate that has already decided it needs messages.
61
+ """
62
+
63
+ def __init__(self, transcript_path: str):
64
+ self._path = transcript_path
65
+ self._messages: list[str] | None = None
66
+
67
+ def peek(self) -> list[str] | None:
68
+ return self._messages
69
+
70
+ def load(self) -> list[str] | None:
71
+ if self._messages is None:
72
+ try:
73
+ from core.workflow.flow_enforcer import (
74
+ _load_last_assistant_messages,
75
+ )
76
+ self._messages = _load_last_assistant_messages(
77
+ self._path, _ASSISTANT_WINDOW
78
+ )
79
+ except Exception:
80
+ return None
81
+ return self._messages
82
+
83
+
84
+ def _deny(stderr_msg: str) -> int:
85
+ if stderr_msg:
86
+ print(stderr_msg, file=sys.stderr)
87
+ emit_deny_json(stderr_msg)
88
+ return 2
89
+
90
+
91
+ def _kb_gate(root: str, tool_name: str, session_id: str, query: str) -> int | None:
92
+ """KB-first gate. Returns 2 on deny, None to continue."""
93
+ if not (Path(root) / "core" / "workflow" / "research_gate.py").is_file():
94
+ return None
95
+ try:
96
+ from core.workflow.research_gate import (
97
+ evaluate_research_gate,
98
+ record_telemetry,
99
+ )
100
+ except Exception:
101
+ return None # kb-gate-import-failed → allow (old heredoc contract)
102
+ decision = evaluate_research_gate(
103
+ tool_name=tool_name, session_id=session_id, query=query
104
+ )
105
+ try:
106
+ record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
107
+ except Exception:
108
+ pass
109
+ if not decision.allow:
110
+ return _deny(decision.to_stderr_message())
111
+ if decision.nudge and decision.to_stderr_message():
112
+ print(decision.to_stderr_message(), file=sys.stderr)
113
+ return None
114
+
115
+
116
+ def _specialist_gate(
117
+ root: str,
118
+ tool_name: str,
119
+ transcript_path: str,
120
+ session_id: str,
121
+ cwd: str,
122
+ tool_input: dict,
123
+ messages: _MessagesOnce,
124
+ ) -> int | None:
125
+ """Specialist-dispatch gate. Returns 2 on deny, None to continue."""
126
+ if tool_name not in _SPECIALIST_TOOLS:
127
+ return None
128
+ module_path = Path(root) / "core" / "workflow" / "specialist_enforcer.py"
129
+ if not module_path.is_file():
130
+ return None
131
+ try:
132
+ from core.workflow.specialist_enforcer import (
133
+ _feature_flag_on,
134
+ evaluate,
135
+ record_telemetry,
136
+ )
137
+ except Exception:
138
+ return None # specialist-import-failed → allow
139
+ # Load (and share) the transcript only when the gate will actually
140
+ # scan it — flag-off sessions keep the zero-read fast path.
141
+ shared = messages.peek()
142
+ if shared is None and _feature_flag_on():
143
+ shared = messages.load()
144
+ decision = evaluate(
145
+ tool_name=tool_name,
146
+ transcript_path=transcript_path,
147
+ session_id=session_id,
148
+ cwd=cwd,
149
+ tool_input=tool_input,
150
+ messages=shared,
151
+ )
152
+ try:
153
+ record_telemetry(
154
+ session_id=session_id,
155
+ tool=tool_name,
156
+ decision=decision,
157
+ cwd=cwd,
158
+ target_file=str(tool_input.get("file_path", "")),
159
+ model_requested=str(tool_input.get("model", "")),
160
+ )
161
+ except Exception:
162
+ pass
163
+ if not decision.allow:
164
+ return _deny(decision.to_stderr_message())
165
+ return None
166
+
167
+
168
+ def _budget_check(session_id: str) -> tuple[bool, str]:
169
+ """CostGovernor gate (stdlib-only). Returns (allow, warning)."""
170
+ try:
171
+ from core.runtime.cost_governor import check as _check
172
+ gov = _check(session_id)
173
+ return gov.allow, gov.to_warning()
174
+ except Exception:
175
+ return True, ""
176
+
177
+
178
+ def _flow_gate(
179
+ root: str,
180
+ tool_name: str,
181
+ transcript_path: str,
182
+ session_id: str,
183
+ cwd: str,
184
+ tool_input: dict,
185
+ messages: _MessagesOnce,
186
+ ) -> int:
187
+ """Budget + evidence-flow gate. Returns final exit code."""
188
+ # Budget fires even when the flow enforcer degrades (PR-5 contract).
189
+ budget_allow, budget_warning = _budget_check(session_id)
190
+ if budget_warning:
191
+ print(budget_warning, file=sys.stderr)
192
+ if not budget_allow:
193
+ return _deny(budget_warning)
194
+
195
+ try:
196
+ from core.workflow.flow_enforcer import evaluate, record_telemetry
197
+ except Exception:
198
+ return 0 # enforcer-import-failed → allow
199
+ decision = evaluate(
200
+ tool_name=tool_name,
201
+ transcript_path=transcript_path,
202
+ session_id=session_id,
203
+ cwd=cwd,
204
+ tool_input=tool_input,
205
+ # peek() only: when the specialist gate already parsed, reuse it;
206
+ # otherwise let evaluate() keep its lazy early-outs (feature flag,
207
+ # env bypass, classifier flag, marker cache) before reading.
208
+ messages=messages.peek(),
209
+ )
210
+ try:
211
+ record_telemetry(
212
+ session_id=session_id, tool=tool_name, decision=decision, cwd=cwd
213
+ )
214
+ except Exception:
215
+ pass
216
+ if decision.allow:
217
+ return 0
218
+ return _deny(decision.to_stderr_message())
219
+
220
+
221
+ def main(stdin_json: dict | None = None) -> int:
222
+ if stdin_json is None:
223
+ stdin_json, _ = read_stdin_json()
224
+ root = resolve_arkaos_root()
225
+ ensure_root_on_path(root)
226
+
227
+ tool_name = get_str(stdin_json, "tool_name")
228
+ transcript_path = get_str(stdin_json, "transcript_path")
229
+ session_id = get_str(stdin_json, "session_id")
230
+ cwd = get_str(stdin_json, "cwd")
231
+ tool_input = stdin_json.get("tool_input")
232
+ if not isinstance(tool_input, dict):
233
+ tool_input = {}
234
+
235
+ messages = _MessagesOnce(transcript_path)
236
+
237
+ code = _kb_gate(root, tool_name, session_id, _query_hint(tool_input))
238
+ if code is not None:
239
+ return code
240
+
241
+ code = _specialist_gate(
242
+ root, tool_name, transcript_path, session_id, cwd, tool_input, messages
243
+ )
244
+ if code is not None:
245
+ return code
246
+
247
+ # Fast allow: not a flow-gated tool (Bash stays — classified per-command
248
+ # by the enforcer via bash_is_effect()).
249
+ if tool_name not in _FLOW_GATED_TOOLS:
250
+ return 0
251
+ if not (Path(root) / "core" / "workflow" / "flow_enforcer.py").is_file():
252
+ return 0
253
+
254
+ return _flow_gate(
255
+ root, tool_name, transcript_path, session_id, cwd, tool_input, messages
256
+ )
257
+
258
+
259
+ if __name__ == "__main__":
260
+ try:
261
+ raise SystemExit(main())
262
+ except SystemExit:
263
+ raise
264
+ except Exception:
265
+ # Fail open — the bash version piped every heredoc through
266
+ # `2>/dev/null` and exited 0 on internal errors.
267
+ raise SystemExit(0)
@@ -0,0 +1,395 @@
1
+ """Stop — consolidated entrypoint (PR-6 v4.1.0 hook hygiene).
2
+
3
+ Replaces the ~16 python3/jq spawn sites of the old ``stop.sh`` with ONE
4
+ python process. WARN mode: this hook NEVER blocks — always exits 0.
5
+
6
+ Section order preserved exactly:
7
+ 1. DNA fidelity check (persona from last dispatch/routing marker)
8
+ 2. stop_hook_active loop guard
9
+ 3. Native usage cost capture (runs BEFORE the WF_MARKER gate — the
10
+ llm-cost blind spot covers ALL sessions)
11
+ 4. WF_MARKER gate (/tmp/arkaos-wf-required/<session>)
12
+ 5. Flow completion validation + soft-block checks (meta_tag_found,
13
+ [arka:meta], kb-cite, sycophancy, closing marker, skill proposer,
14
+ kb=N reconciliation) → telemetry entry (mode: warn)
15
+ 6. Gate checkpoint persistence (core/workflow/gate_checkpoint.py)
16
+ 7. Auto-documentor enqueue (flow-required + QG APPROVED + external
17
+ research heuristic)
18
+ 8. Belt-and-braces WF_MARKER removal
19
+
20
+ The transcript is read from disk ONCE and shared across native-usage,
21
+ flow validation, gate checkpoint, and the auto-doc external-research
22
+ heuristic (the old hook parsed it four times).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ from core.hooks._shared import (
34
+ ensure_root_on_path,
35
+ get_str,
36
+ read_stdin_json,
37
+ resolve_arkaos_root,
38
+ safe_session_id,
39
+ )
40
+
41
+ _DISPATCH_RE = re.compile(
42
+ r"\[arka:dispatch\][ \t]*[A-Za-z0-9_-]+[ \t]*->[ \t]*([A-Za-z0-9_-]+)",
43
+ re.IGNORECASE,
44
+ )
45
+ _ROUTING_RE = re.compile(
46
+ r"\[arka:routing\][ \t]*[A-Za-z0-9_-]+[ \t]*->[ \t]*([A-Za-z0-9_-]+)",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+ _EXTERNAL_MARKERS = (
51
+ "WebFetch", "WebSearch", "mcp__context7", "mcp__firecrawl",
52
+ "http://", "https://",
53
+ )
54
+
55
+
56
+ def _read_transcript(transcript_path: str) -> str | None:
57
+ """Single shared disk read for the whole hook invocation."""
58
+ if not transcript_path:
59
+ return None
60
+ try:
61
+ return Path(transcript_path).read_text(
62
+ encoding="utf-8", errors="replace"
63
+ )
64
+ except OSError:
65
+ return None
66
+
67
+
68
+ def _extract_persona(assistant_msg: str) -> str:
69
+ """Dispatch marker wins over routing; latest match wins."""
70
+ dispatch_hits = _DISPATCH_RE.findall(assistant_msg)
71
+ if dispatch_hits:
72
+ return dispatch_hits[-1].lower()
73
+ routing_hits = _ROUTING_RE.findall(assistant_msg)
74
+ if routing_hits:
75
+ return routing_hits[-1].lower()
76
+ return ""
77
+
78
+
79
+ def _dna_fidelity(assistant_msg: str, session_id: str) -> None:
80
+ if not assistant_msg or not session_id:
81
+ return
82
+ persona = _extract_persona(assistant_msg)
83
+ if not persona:
84
+ return
85
+ try:
86
+ from core.governance.dna_fidelity import check_fidelity, record_fidelity
87
+ violations = check_fidelity(persona, assistant_msg)
88
+ record_fidelity(persona, session_id, violations)
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ def _native_usage(transcript_path: str, session_id: str, raw: str | None) -> None:
94
+ if not transcript_path or not session_id:
95
+ return
96
+ try:
97
+ from core.runtime.native_usage import record_native_usage
98
+ record_native_usage(transcript_path, session_id, raw_text=raw)
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ def _write_tmp_state(subdir: str, safe_sid: str, payload: dict) -> None:
104
+ """Owner-only /tmp state file (umask 0o077 — PR25 v2.46.1)."""
105
+ prev_umask = os.umask(0o077)
106
+ try:
107
+ state_dir = Path("/tmp") / subdir
108
+ state_dir.mkdir(parents=True, exist_ok=True)
109
+ (state_dir / f"{safe_sid}.json").write_text(
110
+ json.dumps(payload), encoding="utf-8"
111
+ )
112
+ finally:
113
+ os.umask(prev_umask)
114
+
115
+
116
+ def _flow_checks(
117
+ session_id: str, transcript_path: str, cwd: str,
118
+ effort_level: str, raw: str | None,
119
+ ) -> None:
120
+ """Sections 5-6: soft-block checks + telemetry + gate checkpoint."""
121
+ try:
122
+ from core.workflow.flow_enforcer import (
123
+ TELEMETRY_PATH,
124
+ _load_last_assistant_messages,
125
+ clear_flow_required,
126
+ )
127
+ except Exception:
128
+ return
129
+
130
+ # Parse once; the last message drives every check, the 20-window
131
+ # feeds the gate checkpoint.
132
+ messages = _load_last_assistant_messages(
133
+ transcript_path, n=20, raw_text=raw
134
+ )
135
+ last = messages[-1] if messages else ""
136
+
137
+ gate4 = bool(re.search(r"\[arka:gate:4\]", last, re.IGNORECASE))
138
+ phase13 = bool(re.search(r"\[arka:phase:13\]", last, re.IGNORECASE))
139
+ trivial = bool(re.search(r"\[arka:trivial\]", last, re.IGNORECASE))
140
+ closing_ok = gate4 or phase13 or trivial
141
+
142
+ # v4.1.0 — persist gate transitions for structured resume.
143
+ try:
144
+ from core.workflow.gate_checkpoint import checkpoint as _gate_checkpoint
145
+ _gate_checkpoint(
146
+ transcript_path, session_id, project=cwd, messages=messages
147
+ )
148
+ except Exception:
149
+ pass
150
+
151
+ meta_tag_found = bool(re.search(r"\[arka:meta\]", last, re.IGNORECASE))
152
+
153
+ sycophancy_signals: list = []
154
+ sycophancy_confidence = 0.0
155
+ is_sycophantic = False
156
+ try:
157
+ from core.governance.sycophancy_detector import detect_sycophancy
158
+ sv = detect_sycophancy(last)
159
+ sycophancy_signals = sv.signals
160
+ sycophancy_confidence = sv.confidence
161
+ is_sycophantic = sv.is_sycophantic
162
+ except Exception:
163
+ pass
164
+
165
+ cite_passed = True
166
+ cite_reason = "trivial"
167
+ cite_count = 0
168
+ cite_topic_score = 0.0
169
+ safe_sid: str | None = None
170
+ try:
171
+ from core.governance.kb_cite_check import check_citation
172
+ cr = check_citation(last)
173
+ cite_passed = cr.passed
174
+ cite_reason = cr.reason
175
+ cite_count = cr.citation_count
176
+ cite_topic_score = cr.topic_score
177
+ safe_sid = safe_session_id(session_id)
178
+ if safe_sid:
179
+ _write_tmp_state("arkaos-cite", safe_sid, {
180
+ "passed": cr.passed,
181
+ "reason": cr.reason,
182
+ "suggestion": cr.suggestion,
183
+ "citation_count": cr.citation_count,
184
+ "topic_score": cr.topic_score,
185
+ })
186
+ except Exception:
187
+ pass
188
+
189
+ try:
190
+ from core.governance.skill_proposer import evaluate as _eval_skill
191
+ _eval_skill(last)
192
+ except Exception:
193
+ pass
194
+
195
+ meta_passed = True
196
+ meta_reason = "trivial"
197
+ try:
198
+ from core.governance.meta_tag_check import check_meta_tag
199
+ mr = check_meta_tag(last)
200
+ meta_passed = mr.passed
201
+ meta_reason = mr.reason
202
+ if safe_sid:
203
+ _write_tmp_state("arkaos-meta", safe_sid, {
204
+ "passed": mr.passed,
205
+ "reason": mr.reason,
206
+ "suggestion": mr.suggestion,
207
+ })
208
+ except Exception:
209
+ pass
210
+
211
+ # Structural honesty PR-2 — kb=N reconciliation (warn-only).
212
+ kb_reported = None
213
+ kb_injected = None
214
+ kb_inflated = False
215
+ try:
216
+ from core.governance.meta_tag_check import (
217
+ parse_reported_kb,
218
+ reconcile_kb_count,
219
+ )
220
+ kb_reported = parse_reported_kb(last)
221
+ if safe_sid:
222
+ injected_path = (
223
+ Path("/tmp/arkaos-kb-injected") / f"{safe_sid}.json"
224
+ )
225
+ if injected_path.exists():
226
+ injected_data = json.loads(
227
+ injected_path.read_text(encoding="utf-8")
228
+ )
229
+ raw_injected = injected_data.get("kb_injected")
230
+ kb_injected = (
231
+ int(raw_injected) if raw_injected is not None else None
232
+ )
233
+ reconciled = reconcile_kb_count(kb_reported, kb_injected)
234
+ kb_reported = reconciled["kb_reported"]
235
+ kb_injected = reconciled["kb_injected"]
236
+ kb_inflated = reconciled["kb_inflated"]
237
+ except Exception:
238
+ pass
239
+
240
+ closing_check_passed = True
241
+ closing_check_reason = "trivial"
242
+ try:
243
+ from core.governance.closing_marker_check import check_closing_marker
244
+ cmr = check_closing_marker(last)
245
+ closing_check_passed = cmr.passed
246
+ closing_check_reason = cmr.reason
247
+ if safe_sid:
248
+ _write_tmp_state("arkaos-closing", safe_sid, {
249
+ "passed": cmr.passed,
250
+ "reason": cmr.reason,
251
+ "suggestion": cmr.suggestion,
252
+ })
253
+ except Exception:
254
+ pass
255
+
256
+ entry = {
257
+ "ts": datetime.now(timezone.utc).isoformat(),
258
+ "session_id": session_id,
259
+ "cwd": cwd,
260
+ "event": "stop-hook-flow-check",
261
+ "closing_marker_found": closing_ok,
262
+ "phase13": phase13,
263
+ "trivial": trivial,
264
+ "meta_tag_found": meta_tag_found,
265
+ "sycophancy_is_flagged": is_sycophantic,
266
+ "sycophancy_signals": sycophancy_signals,
267
+ "sycophancy_confidence": sycophancy_confidence,
268
+ "kb_cite_passed": cite_passed,
269
+ "kb_cite_reason": cite_reason,
270
+ "kb_cite_count": cite_count,
271
+ "kb_cite_topic_score": cite_topic_score,
272
+ "meta_tag_check_passed": meta_passed,
273
+ "meta_tag_check_reason": meta_reason,
274
+ "kb_reported": kb_reported,
275
+ "kb_injected": kb_injected,
276
+ "kb_inflated": kb_inflated,
277
+ "closing_marker_check_passed": closing_check_passed,
278
+ "closing_marker_check_reason": closing_check_reason,
279
+ "effort_level": effort_level,
280
+ "mode": "warn",
281
+ }
282
+ try:
283
+ TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
284
+ with TELEMETRY_PATH.open("a", encoding="utf-8") as fh:
285
+ fh.write(json.dumps(entry) + "\n")
286
+ except Exception:
287
+ pass
288
+
289
+ try:
290
+ clear_flow_required(session_id)
291
+ except Exception:
292
+ pass
293
+
294
+
295
+ def _auto_doc_enqueue(
296
+ session_id: str, transcript_path: str, raw: str | None
297
+ ) -> None:
298
+ """Section 7: fire-and-forget auto-documentor job enqueue."""
299
+ try:
300
+ from core.jobs.auto_doc_worker import enqueue_job
301
+ from core.workflow.flow_enforcer import _load_last_assistant_messages
302
+ except Exception:
303
+ return
304
+ if not session_id or not transcript_path:
305
+ return
306
+
307
+ try:
308
+ msgs = _load_last_assistant_messages(transcript_path, 1, raw_text=raw)
309
+ last = msgs[-1] if msgs else ""
310
+ except Exception:
311
+ last = ""
312
+
313
+ qg_approved = bool(re.search(r"\[arka:qg:approved\]", last, re.IGNORECASE))
314
+ if not qg_approved:
315
+ qg_log = Path.home() / ".arkaos" / "telemetry" / "qg.jsonl"
316
+ if qg_log.exists():
317
+ try:
318
+ for line in reversed(
319
+ qg_log.read_text(encoding="utf-8").splitlines()
320
+ ):
321
+ if not line.strip():
322
+ continue
323
+ try:
324
+ rec = json.loads(line)
325
+ except Exception:
326
+ continue
327
+ if rec.get("session_id") == session_id:
328
+ qg_approved = (
329
+ rec.get("verdict", "").upper() == "APPROVED"
330
+ )
331
+ break
332
+ except Exception:
333
+ pass
334
+ if not qg_approved:
335
+ return
336
+
337
+ data = raw if raw is not None else ""
338
+ if not any(marker in data for marker in _EXTERNAL_MARKERS):
339
+ return
340
+ try:
341
+ enqueue_job(session_id, transcript_path, "APPROVED")
342
+ except Exception:
343
+ pass
344
+
345
+
346
+ def main(stdin_json: dict | None = None) -> int:
347
+ if stdin_json is None:
348
+ stdin_json, _ = read_stdin_json()
349
+ root = resolve_arkaos_root()
350
+ ensure_root_on_path(root)
351
+
352
+ session_id = get_str(stdin_json, "session_id")
353
+ transcript_path = get_str(stdin_json, "transcript_path")
354
+ stop_hook_active = get_str(stdin_json, "stop_hook_active")
355
+ cwd = get_str(stdin_json, "cwd")
356
+ effort_level = get_str(stdin_json, "effort", "level") or os.environ.get(
357
+ "CLAUDE_EFFORT", ""
358
+ )
359
+ assistant_msg = get_str(stdin_json, "assistant_message")
360
+
361
+ _dna_fidelity(assistant_msg, session_id)
362
+
363
+ # Prevent infinite loops when Stop was triggered by its own decision.
364
+ if stop_hook_active == "true":
365
+ return 0
366
+
367
+ raw = _read_transcript(transcript_path)
368
+
369
+ _native_usage(transcript_path, session_id, raw)
370
+
371
+ # Only evaluate sessions where the classifier flagged creation intent.
372
+ wf_marker = Path("/tmp/arkaos-wf-required") / session_id if session_id else None
373
+ if wf_marker is None or not safe_session_id(session_id) or not wf_marker.is_file():
374
+ return 0
375
+
376
+ if (Path(root) / "core" / "workflow" / "flow_enforcer.py").is_file():
377
+ _flow_checks(session_id, transcript_path, cwd, effort_level, raw)
378
+
379
+ _auto_doc_enqueue(session_id, transcript_path, raw)
380
+
381
+ # Belt-and-braces marker removal (session id already allowlisted).
382
+ try:
383
+ wf_marker.unlink(missing_ok=True)
384
+ except OSError:
385
+ pass
386
+ return 0
387
+
388
+
389
+ if __name__ == "__main__":
390
+ try:
391
+ raise SystemExit(main())
392
+ except SystemExit:
393
+ raise
394
+ except Exception:
395
+ raise SystemExit(0) # Stop hook must never break the turn