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.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +16 -22
- package/arka/skills/flow/SKILL.md +108 -234
- package/config/claude-agents/eduardo-copy.md +53 -0
- package/config/claude-agents/francisca-tech.md +52 -0
- package/config/claude-agents/marta-cqo.md +52 -0
- package/config/claude-agents/paulo-tech-lead.md +50 -0
- package/config/cognition/schedules.yaml +15 -0
- package/config/constitution.yaml +17 -21
- package/config/hooks/_lib/workflow-classifier.sh +1 -1
- package/config/hooks/post-tool-use.sh +40 -539
- package/config/hooks/pre-tool-use.sh +32 -281
- package/config/hooks/session-start.sh +8 -10
- package/config/hooks/stop.sh +30 -425
- package/config/hooks/user-prompt-submit.sh +42 -459
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/dreaming.py +5 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +10 -0
- package/core/forge/__init__.py +2 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +50 -27
- package/core/forge/persistence.py +16 -0
- package/core/forge/renderer.py +16 -0
- package/core/forge/runtime_dispatcher.py +34 -19
- package/core/forge/schema.py +12 -0
- package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
- package/core/governance/closing_marker_check.py +9 -5
- package/core/governance/compliance_telemetry.py +1 -1
- package/core/governance/compliance_telemetry_cli.py +1 -1
- package/core/governance/evidence_checks.py +421 -0
- package/core/governance/meta_tag_check.py +36 -0
- package/core/governance/qg_verdict.py +78 -0
- package/core/governance/review_workflow.py +34 -1
- package/core/governance/skill_proposer.py +2 -1
- package/core/hooks/__init__.py +12 -0
- package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/_shared.py +110 -0
- package/core/hooks/post_tool_use.py +689 -0
- package/core/hooks/pre_tool_use.py +267 -0
- package/core/hooks/stop.py +395 -0
- package/core/hooks/user_prompt_submit.py +600 -0
- package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/embedder.py +118 -11
- package/core/knowledge/vector_store.py +111 -14
- package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
- package/core/runtime/base.py +29 -0
- package/core/runtime/capabilities_cli.py +74 -0
- package/core/runtime/claude_code.py +8 -0
- package/core/runtime/codex_cli.py +205 -37
- package/core/runtime/cost_governor.py +184 -0
- package/core/runtime/cursor.py +8 -0
- package/core/runtime/gemini_cli.py +8 -0
- package/core/runtime/llm_provider.py +32 -13
- package/core/runtime/llm_retry.py +233 -0
- package/core/runtime/native_usage.py +186 -0
- package/core/runtime/pricing.py +9 -0
- package/core/runtime/registry.py +3 -0
- package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
- package/core/shared/test_evidence.py +34 -0
- package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +7 -0
- package/core/synapse/graph_context_layer.py +212 -0
- package/core/synapse/kb_cache.py +3 -0
- package/core/synapse/layers.py +104 -20
- package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/enforcer.py +5 -16
- package/core/workflow/flow_enforcer.py +36 -11
- package/core/workflow/gate_checkpoint.py +149 -0
- package/core/workflow/research_gate.py +15 -3
- package/core/workflow/rules_registry.py +137 -389
- package/core/workflow/specialist_enforcer.py +21 -4
- package/dashboard/app/pages/knowledge/index.vue +4 -1
- package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
- package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
- package/departments/quality/SKILL.md +45 -5
- package/departments/quality/agents/copy-director.yaml +3 -1
- package/departments/quality/agents/tech-director.yaml +3 -1
- package/installer/adapters/claude-code.js +46 -2
- package/installer/config-seed.js +39 -14
- package/installer/doctor.js +8 -0
- package/installer/graphify.js +153 -0
- package/installer/index.js +20 -0
- package/installer/init.js +11 -0
- package/installer/update.js +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/knowledge-index.py +8 -2
- 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
|